upload-service/internal/handler/batch.go

270 lines
6.2 KiB
Go

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)
}