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:
parent
573e7d0d80
commit
ff62bbf5a9
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
2
main.go
2
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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue