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"))
|
passwordTmpl = template.Must(template.ParseFS(webFS, "web/password.html"))
|
||||||
downloadTmpl = template.Must(template.New("download.html").Funcs(funcs).ParseFS(webFS, "web/download.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"))
|
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 {
|
func formatSizeHTML(bytes int64) string {
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,30 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/jeffemmett/upload-service/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
var batchTmpl *template.Template
|
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) {
|
func (h *Handler) Batch(w http.ResponseWriter, r *http.Request) {
|
||||||
batchID := r.PathValue("id")
|
batchID := r.PathValue("id")
|
||||||
|
|
@ -31,6 +52,14 @@ func (h *Handler) Batch(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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 {
|
type fileData struct {
|
||||||
ID string
|
ID string
|
||||||
Filename string
|
Filename string
|
||||||
|
|
@ -46,7 +75,6 @@ func (h *Handler) Batch(w http.ResponseWriter, r *http.Request) {
|
||||||
var items []fileData
|
var items []fileData
|
||||||
var totalSize int64
|
var totalSize int64
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
// Skip expired files
|
|
||||||
if f.ExpiresAt != nil && f.ExpiresAt.Before(time.Now().UTC()) {
|
if f.ExpiresAt != nil && f.ExpiresAt.Before(time.Now().UTC()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +146,14 @@ func (h *Handler) BatchDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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-Type", "application/zip")
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, batchID))
|
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()) {
|
if f.ExpiresAt != nil && f.ExpiresAt.Before(time.Now().UTC()) {
|
||||||
continue
|
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)
|
dlURL, err := h.r2.PresignGet(r.Context(), f.R2Key, f.Filename, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("batch zip presign error for %s: %v", f.ID, err)
|
log.Printf("batch zip presign error for %s: %v", f.ID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the file from R2
|
|
||||||
resp, err := http.Get(dlURL)
|
resp, err := http.Get(dlURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("batch zip fetch error for %s: %v", f.ID, err)
|
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()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check password
|
// Check password — accept individual file cookie OR batch cookie
|
||||||
if rec.PasswordHash != nil {
|
if rec.PasswordHash != nil {
|
||||||
cookie, err := r.Cookie("auth_" + id)
|
fileAuth, fileErr := r.Cookie("auth_" + id)
|
||||||
if err != nil || cookie.Value != "granted" {
|
batchAuthed := rec.BatchID != nil && checkBatchAuth(r, *rec.BatchID)
|
||||||
|
if !batchAuthed && (fileErr != nil || fileAuth.Value != "granted") {
|
||||||
http.Redirect(w, r, "/f/"+id+"/auth", http.StatusSeeOther)
|
http.Redirect(w, r, "/f/"+id+"/auth", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -130,8 +131,9 @@ func (h *Handler) serveFile(w http.ResponseWriter, r *http.Request, inline bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rec.PasswordHash != nil {
|
if rec.PasswordHash != nil {
|
||||||
cookie, err := r.Cookie("auth_" + id)
|
fileAuth, fileErr := r.Cookie("auth_" + id)
|
||||||
if err != nil || cookie.Value != "granted" {
|
batchAuthed := rec.BatchID != nil && checkBatchAuth(r, *rec.BatchID)
|
||||||
|
if !batchAuthed && (fileErr != nil || fileAuth.Value != "granted") {
|
||||||
http.Redirect(w, r, "/f/"+id+"/auth", http.StatusSeeOther)
|
http.Redirect(w, r, "/f/"+id+"/auth", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
main.go
2
main.go
|
|
@ -73,6 +73,8 @@ func main() {
|
||||||
mux.HandleFunc("DELETE /f/{id}", h.Delete)
|
mux.HandleFunc("DELETE /f/{id}", h.Delete)
|
||||||
mux.HandleFunc("GET /b/{id}", h.Batch)
|
mux.HandleFunc("GET /b/{id}", h.Batch)
|
||||||
mux.HandleFunc("GET /b/{id}/dl", h.BatchDownload)
|
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)
|
// Favicon (prevent 404)
|
||||||
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, _ *http.Request) {
|
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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Password Required</title>
|
<title>Password Required</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=2">
|
<link rel="stylesheet" href="/static/style.css?v=4">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue