From c5b710498f1e1caced679dc5c0588cb56a0c93a6 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 8 Apr 2026 13:23:03 -0400 Subject: [PATCH] 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 --- internal/handler/batch.go | 22 +++++++++++----------- internal/handler/upload.go | 25 ++++++++++++++++++++++++- main.go | 29 ++++++++++++++++++++--------- web/batch-password.html | 4 ++-- web/batch.html | 4 ++-- web/download.html | 2 +- web/index.html | 7 ++++--- web/password.html | 2 +- web/static/style.css | 7 +++++++ web/static/upload.js | 38 ++++++++++++++++++++++++++++++++------ 10 files changed, 104 insertions(+), 36 deletions(-) diff --git a/internal/handler/batch.go b/internal/handler/batch.go index bc1125b..fd701c3 100644 --- a/internal/handler/batch.go +++ b/internal/handler/batch.go @@ -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) } diff --git a/internal/handler/upload.go b/internal/handler/upload.go index e1735dd..fbfd1d4 100644 --- a/internal/handler/upload.go +++ b/internal/handler/upload.go @@ -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") diff --git a/main.go b/main.go index ae2d031..7aae621 100644 --- a/main.go +++ b/main.go @@ -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) { diff --git a/web/batch-password.html b/web/batch-password.html index a2f763e..09f44b5 100644 --- a/web/batch-password.html +++ b/web/batch-password.html @@ -4,7 +4,7 @@ Password Required - +
@@ -15,7 +15,7 @@
{{.Error}}
{{end}} -
+
diff --git a/web/batch.html b/web/batch.html index f976a48..f07890f 100644 --- a/web/batch.html +++ b/web/batch.html @@ -4,7 +4,7 @@ {{.Count}} files — upload.jeffemmett.com - +
@@ -13,7 +13,7 @@

{{.Count}} files

{{formatSize .TotalSize}} total

- Download all + Download all
diff --git a/web/download.html b/web/download.html index 9ab72be..4821159 100644 --- a/web/download.html +++ b/web/download.html @@ -4,7 +4,7 @@ {{.Filename}} — upload.jeffemmett.com - +
diff --git a/web/index.html b/web/index.html index 5a26c18..0f7f3aa 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ upload.jeffemmett.com - +
@@ -29,10 +29,11 @@
- /f/ + /f/
+