diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 800cd6e..276d419 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -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 { diff --git a/internal/handler/batch.go b/internal/handler/batch.go index 7c6322e..bc1125b 100644 --- a/internal/handler/batch.go +++ b/internal/handler/batch.go @@ -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) +} diff --git a/internal/handler/download.go b/internal/handler/download.go index 9411481..8b8b883 100644 --- a/internal/handler/download.go +++ b/internal/handler/download.go @@ -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 } diff --git a/main.go b/main.go index 9f41161..ae2d031 100644 --- a/main.go +++ b/main.go @@ -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) { diff --git a/web/batch-password.html b/web/batch-password.html new file mode 100644 index 0000000..d80fcc6 --- /dev/null +++ b/web/batch-password.html @@ -0,0 +1,28 @@ + + + + + + Password Required + + + +
+

Password Required

+

Enter the password to access {{.Count}} files

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ + +
+ + +
+ + diff --git a/web/password.html b/web/password.html index 44f3986..05bf8fd 100644 --- a/web/password.html +++ b/web/password.html @@ -4,7 +4,7 @@ Password Required - +