feat: Batch-level password auth — unlock once for all files

Password-protected batch uploads now require a single password entry
at /b/{id}/auth. The batch cookie (auth_b_{id}) also grants access
to individual file pages (/f/{id}), so users don't re-enter password
per file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-07 16:10:45 -04:00
parent 573e7d0d80
commit ff62bbf5a9
6 changed files with 155 additions and 12 deletions

View File

@ -19,6 +19,7 @@ func InitTemplates(webFS embed.FS) {
passwordTmpl = template.Must(template.ParseFS(webFS, "web/password.html"))
downloadTmpl = template.Must(template.New("download.html").Funcs(funcs).ParseFS(webFS, "web/download.html"))
batchTmpl = template.Must(template.New("batch.html").Funcs(funcs).ParseFS(webFS, "web/batch.html"))
batchPasswordTmpl = template.Must(template.ParseFS(webFS, "web/batch-password.html"))
}
func formatSizeHTML(bytes int64) string {

View File

@ -9,9 +9,30 @@ import (
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/jeffemmett/upload-service/internal/store"
)
var batchTmpl *template.Template
var batchPasswordTmpl *template.Template
// batchPasswordHash returns the password hash if any file in the batch is password-protected.
func batchPasswordHash(files []*store.FileRecord) *string {
for _, f := range files {
if f.PasswordHash != nil {
return f.PasswordHash
}
}
return nil
}
// checkBatchAuth returns true if the batch auth cookie is present and valid.
func checkBatchAuth(r *http.Request, batchID string) bool {
cookie, err := r.Cookie("auth_b_" + batchID)
return err == nil && cookie.Value == "granted"
}
func (h *Handler) Batch(w http.ResponseWriter, r *http.Request) {
batchID := r.PathValue("id")
@ -31,6 +52,14 @@ func (h *Handler) Batch(w http.ResponseWriter, r *http.Request) {
return
}
// Check batch-level password
if hash := batchPasswordHash(files); hash != nil {
if !checkBatchAuth(r, batchID) {
http.Redirect(w, r, "/b/"+batchID+"/auth", http.StatusSeeOther)
return
}
}
type fileData struct {
ID string
Filename string
@ -46,7 +75,6 @@ func (h *Handler) Batch(w http.ResponseWriter, r *http.Request) {
var items []fileData
var totalSize int64
for _, f := range files {
// Skip expired files
if f.ExpiresAt != nil && f.ExpiresAt.Before(time.Now().UTC()) {
continue
}
@ -118,6 +146,14 @@ func (h *Handler) BatchDownload(w http.ResponseWriter, r *http.Request) {
return
}
// Check batch-level password
if hash := batchPasswordHash(files); hash != nil {
if !checkBatchAuth(r, batchID) {
http.Redirect(w, r, "/b/"+batchID+"/auth", http.StatusSeeOther)
return
}
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, batchID))
@ -128,18 +164,13 @@ func (h *Handler) BatchDownload(w http.ResponseWriter, r *http.Request) {
if f.ExpiresAt != nil && f.ExpiresAt.Before(time.Now().UTC()) {
continue
}
if f.PasswordHash != nil {
continue // skip password-protected files
}
// Get presigned URL for this file
dlURL, err := h.r2.PresignGet(r.Context(), f.R2Key, f.Filename, false)
if err != nil {
log.Printf("batch zip presign error for %s: %v", f.ID, err)
continue
}
// Fetch the file from R2
resp, err := http.Get(dlURL)
if err != nil {
log.Printf("batch zip fetch error for %s: %v", f.ID, err)
@ -157,3 +188,82 @@ func (h *Handler) BatchDownload(w http.ResponseWriter, r *http.Request) {
resp.Body.Close()
}
}
// BatchAuthPage shows the password form for a batch.
func (h *Handler) BatchAuthPage(w http.ResponseWriter, r *http.Request) {
batchID := r.PathValue("id")
if batchID == "" {
http.NotFound(w, r)
return
}
files, err := h.store.GetBatch(batchID)
if err != nil || len(files) == 0 {
http.NotFound(w, r)
return
}
if batchPasswordHash(files) == nil {
http.Redirect(w, r, "/b/"+batchID, http.StatusSeeOther)
return
}
data := map[string]any{
"BatchID": batchID,
"Count": len(files),
"Error": r.URL.Query().Get("error"),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
batchPasswordTmpl.Execute(w, data)
}
// BatchAuthSubmit validates the password and sets a batch cookie.
func (h *Handler) BatchAuthSubmit(w http.ResponseWriter, r *http.Request) {
batchID := r.PathValue("id")
if batchID == "" {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
password := r.FormValue("password")
if password == "" {
http.Redirect(w, r, "/b/"+batchID+"/auth?error=password+required", http.StatusSeeOther)
return
}
files, err := h.store.GetBatch(batchID)
if err != nil || len(files) == 0 {
http.NotFound(w, r)
return
}
hash := batchPasswordHash(files)
if hash == nil {
http.Redirect(w, r, "/b/"+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)
return
}
// Set batch auth cookie (10 min, covers /b/ and /f/ paths)
http.SetCookie(w, &http.Cookie{
Name: "auth_b_" + batchID,
Value: "granted",
Path: "/",
MaxAge: 600,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: true,
Expires: time.Now().Add(10 * time.Minute),
})
http.Redirect(w, r, "/b/"+batchID, http.StatusSeeOther)
}

View File

@ -48,10 +48,11 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
return
}
// Check password
// Check password — accept individual file cookie OR batch cookie
if rec.PasswordHash != nil {
cookie, err := r.Cookie("auth_" + id)
if err != nil || cookie.Value != "granted" {
fileAuth, fileErr := r.Cookie("auth_" + id)
batchAuthed := rec.BatchID != nil && checkBatchAuth(r, *rec.BatchID)
if !batchAuthed && (fileErr != nil || fileAuth.Value != "granted") {
http.Redirect(w, r, "/f/"+id+"/auth", http.StatusSeeOther)
return
}
@ -130,8 +131,9 @@ func (h *Handler) serveFile(w http.ResponseWriter, r *http.Request, inline bool)
}
if rec.PasswordHash != nil {
cookie, err := r.Cookie("auth_" + id)
if err != nil || cookie.Value != "granted" {
fileAuth, fileErr := r.Cookie("auth_" + id)
batchAuthed := rec.BatchID != nil && checkBatchAuth(r, *rec.BatchID)
if !batchAuthed && (fileErr != nil || fileAuth.Value != "granted") {
http.Redirect(w, r, "/f/"+id+"/auth", http.StatusSeeOther)
return
}

View File

@ -73,6 +73,8 @@ func main() {
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)
// Favicon (prevent 404)
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, _ *http.Request) {

28
web/batch-password.html Normal file
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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=4">
</head>
<body>
<div class="container">
<h1>Password Required</h1>
<p class="subtitle">Enter the password to access {{.Count}} files</p>
{{if .Error}}
<div class="error">{{.Error}}</div>
{{end}}
<form method="POST" action="/b/{{.BatchID}}/auth" class="auth-form">
<input type="password" name="password" placeholder="Password" autofocus required>
<button type="submit" class="btn">Unlock</button>
</form>
<footer>
<a href="/">upload.jeffemmett.com</a>
</footer>
</div>
</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=2">
<link rel="stylesheet" href="/static/style.css?v=4">
</head>
<body>
<div class="container">