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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 10:54:44 -07:00
parent 9ab1cb3349
commit 18442a7564
4 changed files with 95 additions and 13 deletions

View File

@ -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,

View File

@ -24,6 +24,13 @@
</div>
<div id="options" class="options hidden">
<div class="option-group">
<label for="slug">Custom URL</label>
<div class="slug-input">
<span class="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 class="option-group">
<label for="expires">Expires in</label>
<select id="expires">

View File

@ -100,6 +100,35 @@ h1 {
font-size: 0.875rem;
}
.slug-input {
flex: 1;
display: flex;
align-items: center;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.slug-prefix {
padding: 0.5rem 0 0.5rem 0.75rem;
color: var(--text-dim);
font-size: 0.875rem;
font-family: monospace;
flex-shrink: 0;
}
.slug-input input {
flex: 1;
background: transparent;
border: none;
padding: 0.5rem 0.75rem 0.5rem 0;
color: var(--text);
font-size: 0.875rem;
font-family: monospace;
outline: none;
}
.btn {
background: var(--accent);
color: #fff;

View File

@ -57,6 +57,10 @@
function upload(file) {
const formData = new FormData();
const slug = document.getElementById('slug').value.trim();
if (slug) formData.append('slug', slug);
formData.append('file', file);
const expires = document.getElementById('expires').value;