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