package handler import ( "archive/zip" "fmt" "html/template" "io" "log" "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("slug") if batchID == "" { http.NotFound(w, r) return } files, err := h.store.GetBatch(batchID) if err != nil { log.Printf("db get batch error: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if len(files) == 0 { http.NotFound(w, r) return } // Check batch-level password if hash := batchPasswordHash(files); hash != nil { if !checkBatchAuth(r, batchID) { http.Redirect(w, r, "/"+batchID+"/auth", http.StatusSeeOther) return } } type fileData struct { ID string Filename string Size int64 ContentType string UploadedAt string ThumbType string PreviewURL string ViewURL string DownloadURL string } var items []fileData var totalSize int64 for _, f := range files { if f.ExpiresAt != nil && f.ExpiresAt.Before(time.Now().UTC()) { continue } thumbType := "" previewURL := "" if strings.HasPrefix(f.ContentType, "image/") { thumbType = "image" } else if strings.HasPrefix(f.ContentType, "video/") { thumbType = "video" } else if strings.HasPrefix(f.ContentType, "audio/") { thumbType = "audio" } else if f.ContentType == "application/pdf" { thumbType = "pdf" } if thumbType != "" { if url, err := h.r2.PresignGet(r.Context(), f.R2Key, f.Filename, true); err == nil { previewURL = url } } totalSize += f.SizeBytes items = append(items, fileData{ ID: f.ID, Filename: f.Filename, Size: f.SizeBytes, ContentType: f.ContentType, UploadedAt: f.UploadedAt.Format("Jan 2, 2006 at 3:04 PM"), ThumbType: thumbType, PreviewURL: previewURL, ViewURL: "/f/" + f.ID + "/view", DownloadURL: "/f/" + f.ID + "/dl", }) } if len(items) == 0 { http.NotFound(w, r) return } data := map[string]any{ "BatchID": batchID, "Files": items, "Count": len(items), "TotalSize": totalSize, } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache, must-revalidate") batchTmpl.Execute(w, data) } // BatchDownload streams all batch files as a zip archive. func (h *Handler) BatchDownload(w http.ResponseWriter, r *http.Request) { batchID := r.PathValue("slug") if batchID == "" { http.NotFound(w, r) return } files, err := h.store.GetBatch(batchID) if err != nil { log.Printf("db get batch error: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if len(files) == 0 { http.NotFound(w, r) return } // Check batch-level password if hash := batchPasswordHash(files); hash != nil { if !checkBatchAuth(r, batchID) { http.Redirect(w, r, "/"+batchID+"/auth", http.StatusSeeOther) return } } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, batchID)) zw := zip.NewWriter(w) defer zw.Close() for _, f := range files { if f.ExpiresAt != nil && f.ExpiresAt.Before(time.Now().UTC()) { continue } 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 } resp, err := http.Get(dlURL) if err != nil { log.Printf("batch zip fetch error for %s: %v", f.ID, err) continue } fw, err := zw.Create(f.Filename) if err != nil { resp.Body.Close() log.Printf("batch zip create entry error: %v", err) continue } io.Copy(fw, resp.Body) resp.Body.Close() } } // BatchAuthPage shows the password form for a batch. func (h *Handler) BatchAuthPage(w http.ResponseWriter, r *http.Request) { batchID := r.PathValue("slug") 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, "/"+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("slug") 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, "/"+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, "/"+batchID, http.StatusSeeOther) return } if err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(password)); err != nil { http.Redirect(w, r, "/"+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, "/"+batchID, http.StatusSeeOther) }