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:
parent
92c931d7da
commit
c5b710498f
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
29
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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in New Issue