From 18442a7564cb184ec0b98f342ac0dd19e924380c Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Mar 2026 10:54:44 -0700 Subject: [PATCH] feat: Allow custom URL slugs for uploads Uploaders can optionally set a custom path via the "slug" field. Slug appears before the file in multipart form so it's available before streaming begins. Validated: alphanumeric + hyphens/dots/ underscores, 1-64 chars, checked for uniqueness. Co-Authored-By: Claude Opus 4.6 --- internal/handler/upload.go | 68 ++++++++++++++++++++++++++++++-------- web/index.html | 7 ++++ web/static/style.css | 29 ++++++++++++++++ web/static/upload.js | 4 +++ 4 files changed, 95 insertions(+), 13 deletions(-) diff --git a/internal/handler/upload.go b/internal/handler/upload.go index b134c6e..a19bea0 100644 --- a/internal/handler/upload.go +++ b/internal/handler/upload.go @@ -2,12 +2,14 @@ package handler import ( "crypto/rand" + "database/sql" "encoding/hex" "encoding/json" "fmt" "log" "mime" "net/http" + "regexp" "strconv" "strings" "time" @@ -20,6 +22,8 @@ import ( "github.com/jeffemmett/upload-service/internal/store" ) +var slugRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) + type Handler struct { store *store.Store r2 *r2.Client @@ -38,22 +42,17 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { } var ( - filename string - contentType string - expiresIn string - password string - fileSize int64 + filename string + contentType string + expiresIn string + password string + customSlug string + fileSize int64 fileUploaded bool - fileID string - r2Key string + fileID string + r2Key string ) - fileID, err = gonanoid.New(8) - if err != nil { - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - deleteToken := make([]byte, 32) if _, err := rand.Read(deleteToken); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) @@ -79,6 +78,17 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { return } + // Resolve file ID: use custom slug or generate nanoid + if customSlug != "" { + fileID = customSlug + } else { + fileID, err = gonanoid.New(8) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } + // Detect content type from extension, fall back to part header ct := mime.TypeByExtension("." + fileExtension(filename)) if ct == "" { @@ -126,6 +136,11 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { buf := make([]byte, 256) n, _ := part.Read(buf) password = strings.TrimSpace(string(buf[:n])) + + case "slug": + buf := make([]byte, 128) + n, _ := part.Read(buf) + customSlug = strings.TrimSpace(string(buf[:n])) } part.Close() } @@ -135,6 +150,33 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { return } + // If slug was provided after file part (handle late slug) + if customSlug != "" && fileID != customSlug { + // Slug came after file — need to validate and re-key + // This shouldn't happen with well-ordered form data, but handle gracefully + // by ignoring the late slug since file is already uploaded with nanoid + } + + // Validate custom slug + if customSlug != "" { + if !slugRe.MatchString(customSlug) { + h.r2.Delete(r.Context(), r2Key) + http.Error(w, "invalid slug: use letters, numbers, hyphens, dots, underscores (1-64 chars)", http.StatusBadRequest) + return + } + // Check slug isn't already taken + if _, err := h.store.Get(customSlug); err == nil { + h.r2.Delete(r.Context(), r2Key) + http.Error(w, "slug already taken", http.StatusConflict) + return + } else if err != sql.ErrNoRows { + h.r2.Delete(r.Context(), r2Key) + log.Printf("db check slug error: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + } + rec := &store.FileRecord{ ID: fileID, Filename: filename, diff --git a/web/index.html b/web/index.html index fcfd650..70fdb0a 100644 --- a/web/index.html +++ b/web/index.html @@ -24,6 +24,13 @@