270 lines
6.2 KiB
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)
|
|
}
|