From 64d7494e333fdbf4207161d6c34144be7eb39695 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Wed, 17 Jun 2026 12:28:20 +0000 Subject: [PATCH] chore: flat ZIP structure + event-name download filenames (/dl/{token}/{name}.zip) --- internal/handlers/file.go | 58 ++++++++++++++++++++++++++++++--------- main.go | 1 + 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/internal/handlers/file.go b/internal/handlers/file.go index c4d0d16..349a628 100644 --- a/internal/handlers/file.go +++ b/internal/handlers/file.go @@ -273,19 +273,32 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) { return } - // Create receipt images ZIP. - var zipAtt *email.Attachment - zipAtt, _ = createReceiptZip(event.Name, expenses) - - // Package everything into a single download ZIP. + // Package everything into a single flat ZIP (report + receipt images). var pkgBuf bytes.Buffer pkg := zip.NewWriter(&pkgBuf) // Add report file. addToZip(pkg, reportAtt.Filename, reportAtt.Content) - // Add images ZIP if present. - if zipAtt != nil { - addToZip(pkg, zipAtt.Filename, zipAtt.Content) + + // Add receipt images directly (not nested). + for i, exp := range expenses { + if exp.ImagePath == "" { + continue + } + normPath := normalizeImagePath(exp.ImagePath) + safePath := filepath.Join("storage", filepath.Base(normPath)) + data, err := os.ReadFile(safePath) + if err != nil { + log.Printf("WARN [%s] handlers: GenerateReport: reading %q: %v", + time.Now().Format(time.RFC3339), safePath, err) + continue + } + ext := filepath.Ext(exp.ImagePath) + if ext == "" { + ext = ".jpg" + } + imgName := fmt.Sprintf("receipt-%d%s", i+1, ext) + addToZip(pkg, imgName, data) } if err := pkg.Close(); err != nil { @@ -310,7 +323,14 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) { return } token := hex.EncodeToString(tokenBytes) - pkgFilename := token + ".zip" + + // Use event name as the download filename (GUID only in storage path). + safeEvent := sanitiseFilename(event.Name) + if safeEvent == "" { + safeEvent = "report" + } + dlName := safeEvent + ".zip" + pkgFilename := token + ".zip" // storage filename is always the GUID pkgPath := filepath.Join("storage", "postbox", pkgFilename) if err := os.WriteFile(pkgPath, pkgBuf.Bytes(), 0644); err != nil { @@ -339,7 +359,7 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
Report Ready

%s & %d receipt images packaged.

- ⬇ Download Now + ⬇ Download Now

Or send a download link via email (tiny email, no attachment limits):

@@ -353,7 +373,7 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
`, template.HTMLEscapeString(reportName), len(expenses), - template.HTMLEscapeString(token), + template.HTMLEscapeString(token), template.HTMLEscapeString(dlName), template.HTMLEscapeString(eventID), template.HTMLEscapeString(token)) } @@ -405,7 +425,11 @@ func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) { if baseURL == "" { baseURL = "http://localhost:8080" } - link := strings.TrimRight(baseURL, "/") + "/dl/" + token + safeName := sanitiseFilename(event.Name) + if safeName == "" { + safeName = "report" + } + link := fmt.Sprintf("%s/dl/%s/%s.zip", strings.TrimRight(baseURL, "/"), token, safeName) subject := "Expense report: " + event.Name body := fmt.Sprintf("Expense report for %s is ready.\n\nDownload: %s\n\nThis link expires in 24 hours.", event.Name, link) @@ -431,6 +455,8 @@ func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) { // ServeDownload streams a previously generated report package to the client. // Access is controlled via the crypto-random token in the URL — no login // required. The token is valid for 24 hours from creation. +// The optional filename suffix in the URL (e.g. /dl/{token}/Lagos-report.zip) +// is used for the Content-Disposition header but does not affect access control. func (h *FileHandler) ServeDownload(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") if token == "" { @@ -460,8 +486,14 @@ func (h *FileHandler) ServeDownload(w http.ResponseWriter, r *http.Request) { // Mark as accessed. database.MarkDownloadTokenAccessed(h.DB, token) + // Build a friendly download filename from the URL suffix, falling back to the token. + dlName := dt.Filename + if name := chi.URLParam(r, "name"); name != "" { + dlName = filepath.Base(name) // prevent path traversal in the suffix + } + w.Header().Set("Content-Type", "application/zip") - w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, dt.Filename)) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, dlName)) http.ServeFile(w, r, pkgPath) } diff --git a/main.go b/main.go index b4b6bab..2fec95c 100644 --- a/main.go +++ b/main.go @@ -198,6 +198,7 @@ func main() { // Download link (token-based auth, no login required). r.Get("/dl/{token}", fileHandler.ServeDownload) + r.Get("/dl/{token}/{name}", fileHandler.ServeDownload) // ---- Logout (invalidates server-side session + clears cookie) ----