feat: Custom batch slugs with top-level URLs (no /b/ prefix)

Batch uploads now use top-level URLs (e.g., /my-photos instead of
/b/abc123). Users can name their batch slug or let it auto-generate.
Slug field dynamically shows / prefix for batches, /f/ for single files.
Added slug validation with live feedback and reserved slug protection.
Old /b/ URLs redirect permanently for backward compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-08 13:23:03 -04:00
parent 92c931d7da
commit c5b710498f
10 changed files with 104 additions and 36 deletions

View File

@ -35,7 +35,7 @@ func checkBatchAuth(r *http.Request, batchID string) bool {
}
func (h *Handler) Batch(w http.ResponseWriter, r *http.Request) {
batchID := r.PathValue("id")
batchID := r.PathValue("slug")
if batchID == "" {
http.NotFound(w, r)
return
@ -55,7 +55,7 @@ func (h *Handler) Batch(w http.ResponseWriter, r *http.Request) {
// Check batch-level password
if hash := batchPasswordHash(files); hash != nil {
if !checkBatchAuth(r, batchID) {
http.Redirect(w, r, "/b/"+batchID+"/auth", http.StatusSeeOther)
http.Redirect(w, r, "/"+batchID+"/auth", http.StatusSeeOther)
return
}
}
@ -129,7 +129,7 @@ func (h *Handler) Batch(w http.ResponseWriter, r *http.Request) {
// BatchDownload streams all batch files as a zip archive.
func (h *Handler) BatchDownload(w http.ResponseWriter, r *http.Request) {
batchID := r.PathValue("id")
batchID := r.PathValue("slug")
if batchID == "" {
http.NotFound(w, r)
return
@ -149,7 +149,7 @@ func (h *Handler) BatchDownload(w http.ResponseWriter, r *http.Request) {
// Check batch-level password
if hash := batchPasswordHash(files); hash != nil {
if !checkBatchAuth(r, batchID) {
http.Redirect(w, r, "/b/"+batchID+"/auth", http.StatusSeeOther)
http.Redirect(w, r, "/"+batchID+"/auth", http.StatusSeeOther)
return
}
}
@ -191,7 +191,7 @@ func (h *Handler) BatchDownload(w http.ResponseWriter, r *http.Request) {
// BatchAuthPage shows the password form for a batch.
func (h *Handler) BatchAuthPage(w http.ResponseWriter, r *http.Request) {
batchID := r.PathValue("id")
batchID := r.PathValue("slug")
if batchID == "" {
http.NotFound(w, r)
return
@ -204,7 +204,7 @@ func (h *Handler) BatchAuthPage(w http.ResponseWriter, r *http.Request) {
}
if batchPasswordHash(files) == nil {
http.Redirect(w, r, "/b/"+batchID, http.StatusSeeOther)
http.Redirect(w, r, "/"+batchID, http.StatusSeeOther)
return
}
@ -219,7 +219,7 @@ func (h *Handler) BatchAuthPage(w http.ResponseWriter, r *http.Request) {
// BatchAuthSubmit validates the password and sets a batch cookie.
func (h *Handler) BatchAuthSubmit(w http.ResponseWriter, r *http.Request) {
batchID := r.PathValue("id")
batchID := r.PathValue("slug")
if batchID == "" {
http.NotFound(w, r)
return
@ -232,7 +232,7 @@ func (h *Handler) BatchAuthSubmit(w http.ResponseWriter, r *http.Request) {
password := r.FormValue("password")
if password == "" {
http.Redirect(w, r, "/b/"+batchID+"/auth?error=password+required", http.StatusSeeOther)
http.Redirect(w, r, "/"+batchID+"/auth?error=password+required", http.StatusSeeOther)
return
}
@ -244,12 +244,12 @@ func (h *Handler) BatchAuthSubmit(w http.ResponseWriter, r *http.Request) {
hash := batchPasswordHash(files)
if hash == nil {
http.Redirect(w, r, "/b/"+batchID, http.StatusSeeOther)
http.Redirect(w, r, "/"+batchID, http.StatusSeeOther)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(password)); err != nil {
http.Redirect(w, r, "/b/"+batchID+"/auth?error=wrong+password", http.StatusSeeOther)
http.Redirect(w, r, "/"+batchID+"/auth?error=wrong+password", http.StatusSeeOther)
return
}
@ -265,5 +265,5 @@ func (h *Handler) BatchAuthSubmit(w http.ResponseWriter, r *http.Request) {
Expires: time.Now().Add(10 * time.Minute),
})
http.Redirect(w, r, "/b/"+batchID, http.StatusSeeOther)
http.Redirect(w, r, "/"+batchID, http.StatusSeeOther)
}

View File

@ -24,6 +24,15 @@ import (
var slugRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
var reservedSlugs = map[string]bool{
"f": true, "b": true, "health": true, "cli": true,
"static": true, "upload": true, "favicon.ico": true,
}
func isReservedSlug(s string) bool {
return reservedSlugs[strings.ToLower(s)]
}
type Handler struct {
store *store.Store
r2 *r2.Client
@ -183,6 +192,20 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
}
}
// Validate batch_id format and reserved slugs
if batchID != "" {
if !slugRe.MatchString(batchID) {
h.r2.Delete(r.Context(), r2Key)
http.Error(w, "invalid batch slug: use letters, numbers, hyphens, dots, underscores (1-64 chars)", http.StatusBadRequest)
return
}
if isReservedSlug(batchID) {
h.r2.Delete(r.Context(), r2Key)
http.Error(w, "that batch slug is reserved", http.StatusConflict)
return
}
}
rec := &store.FileRecord{
ID: fileID,
Filename: filename,
@ -239,7 +262,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
}
if batchID != "" {
resp["batch_id"] = batchID
resp["batch_url"] = fmt.Sprintf("%s/b/%s", h.config.BaseURL, batchID)
resp["batch_url"] = fmt.Sprintf("%s/%s", h.config.BaseURL, batchID)
}
w.Header().Set("Content-Type", "application/json")

29
main.go
View File

@ -47,11 +47,7 @@ func main() {
mux := http.NewServeMux()
// Web UI
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
data, _ := fs.ReadFile(webFS, "web/index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
@ -71,10 +67,25 @@ func main() {
mux.HandleFunc("GET /f/{id}/auth", h.AuthPage)
mux.HandleFunc("POST /f/{id}/auth", h.AuthSubmit)
mux.HandleFunc("DELETE /f/{id}", h.Delete)
mux.HandleFunc("GET /b/{id}", h.Batch)
mux.HandleFunc("GET /b/{id}/dl", h.BatchDownload)
mux.HandleFunc("GET /b/{id}/auth", h.BatchAuthPage)
mux.HandleFunc("POST /b/{id}/auth", h.BatchAuthSubmit)
// Top-level batch routes (batches use their own slug)
mux.HandleFunc("GET /{slug}", h.Batch)
mux.HandleFunc("GET /{slug}/dl", h.BatchDownload)
mux.HandleFunc("GET /{slug}/auth", h.BatchAuthPage)
mux.HandleFunc("POST /{slug}/auth", h.BatchAuthSubmit)
// Backward compat: /b/{id} redirects to /{id}
mux.HandleFunc("GET /b/{id}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/"+r.PathValue("id"), http.StatusMovedPermanently)
})
mux.HandleFunc("GET /b/{id}/dl", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/"+r.PathValue("id")+"/dl", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /b/{id}/auth", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/"+r.PathValue("id")+"/auth", http.StatusMovedPermanently)
})
mux.HandleFunc("POST /b/{id}/auth", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/"+r.PathValue("id")+"/auth", http.StatusTemporaryRedirect)
})
// Favicon (prevent 404)
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, _ *http.Request) {

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Required</title>
<link rel="stylesheet" href="/static/style.css?v=5">
<link rel="stylesheet" href="/static/style.css?v=6">
</head>
<body>
<div class="container">
@ -15,7 +15,7 @@
<div class="error">{{.Error}}</div>
{{end}}
<form method="POST" action="/b/{{.BatchID}}/auth" class="auth-form">
<form method="POST" action="/{{.BatchID}}/auth" class="auth-form">
<input type="password" name="password" placeholder="Password" autofocus required>
<button type="submit" class="btn">Unlock</button>
</form>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Count}} files — upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css?v=5">
<link rel="stylesheet" href="/static/style.css?v=6">
</head>
<body>
<div class="container batch-container">
@ -13,7 +13,7 @@
<h1>{{.Count}} files</h1>
<p class="subtitle">{{formatSize .TotalSize}} total</p>
</div>
<a href="/b/{{.BatchID}}/dl" class="btn">Download all</a>
<a href="/{{.BatchID}}/dl" class="btn">Download all</a>
</div>
<div class="batch-grid">

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Filename}} — upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css?v=5">
<link rel="stylesheet" href="/static/style.css?v=6">
</head>
<body>
<div class="container">

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css?v=5">
<link rel="stylesheet" href="/static/style.css?v=6">
</head>
<body>
<div class="container">
@ -29,10 +29,11 @@
<div id="slug-group" class="option-group">
<label for="slug">Custom URL</label>
<div class="slug-input">
<span class="slug-prefix">/f/</span>
<span class="slug-prefix" id="slug-prefix">/f/</span>
<input type="text" id="slug" placeholder="my-file" pattern="[a-zA-Z0-9][a-zA-Z0-9.\-_]*" maxlength="64">
</div>
</div>
<div id="slug-hint" class="slug-hint hidden">Use letters, numbers, hyphens, dots, underscores only</div>
<div class="option-group">
<label for="expires">Expires in</label>
<select id="expires">
@ -83,6 +84,6 @@
<a href="/cli">CLI tool</a>
</footer>
</div>
<script src="/static/upload.js?v=3"></script>
<script src="/static/upload.js?v=4"></script>
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Required</title>
<link rel="stylesheet" href="/static/style.css?v=5">
<link rel="stylesheet" href="/static/style.css?v=6">
</head>
<body>
<div class="container">

View File

@ -129,6 +129,13 @@ h1 {
outline: none;
}
.slug-hint {
font-size: 0.75rem;
color: var(--error);
margin-top: -0.25rem;
padding-left: calc(80px + 0.75rem);
}
.btn {
background: var(--accent);
color: #fff;

View File

@ -82,10 +82,15 @@
fileListEl.classList.remove('hidden');
options.classList.remove('hidden');
// Hide custom slug for multi-file uploads
slugGroup.classList.toggle('hidden', selectedFiles.length > 1);
// Update slug prefix based on single vs multi-file
const slugPrefix = document.getElementById('slug-prefix');
const slugInput = document.getElementById('slug');
if (selectedFiles.length > 1) {
document.getElementById('slug').value = '';
slugPrefix.textContent = '/';
slugInput.placeholder = 'my-photos';
} else {
slugPrefix.textContent = '/f/';
slugInput.placeholder = 'my-file';
}
const totalSize = selectedFiles.reduce((sum, f) => sum + f.size, 0);
@ -136,8 +141,15 @@
const password = document.getElementById('password').value;
const slug = document.getElementById('slug').value.trim();
// Generate batch ID for multi-file uploads
const batchId = files.length > 1 ? nanoid(8) : '';
// For multi-file: slug becomes batch ID (auto-generate if empty)
// For single-file: slug is the file slug, no batch
let batchId = '';
let fileSlug = '';
if (files.length > 1) {
batchId = slug || nanoid(8);
} else {
fileSlug = slug;
}
for (let i = 0; i < files.length; i++) {
const file = files[i];
@ -154,7 +166,7 @@
const data = await uploadOne(file, {
expires,
password,
slug: files.length === 1 ? slug : '',
slug: fileSlug,
batchId
});
results.push(data);
@ -313,4 +325,18 @@
for (let i = 0; i < size; i++) id += alphabet[bytes[i] & 63];
return id;
}
// Slug validation feedback
const slugValidation = document.getElementById('slug');
const slugHint = document.getElementById('slug-hint');
slugValidation.addEventListener('input', () => {
const val = slugValidation.value;
if (val && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(val)) {
slugHint.classList.remove('hidden');
slugValidation.style.borderColor = 'var(--error)';
} else {
slugHint.classList.add('hidden');
slugValidation.style.borderColor = '';
}
});
})();