chore: flat ZIP structure + event-name download filenames (/dl/{token}/{name}.zip)

This commit is contained in:
Claus Lohmar 2026-06-17 12:28:20 +00:00
parent 4bc9fe52ae
commit 64d7494e33
2 changed files with 46 additions and 13 deletions

View file

@ -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) {
<div style="font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">Report Ready</div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">%s &amp; %d receipt images packaged.</p>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
<a href="/dl/%s" class="btn btn-primary" style="flex:1; text-align:center; text-decoration:none; font-size:0.85rem;" download> Download Now</a>
<a href="/dl/%s/%s" class="btn btn-primary" style="flex:1; text-align:center; text-decoration:none; font-size:0.85rem;" download> Download Now</a>
</div>
<div style="border-top: 1px solid #065f46; padding-top: 0.75rem;">
<p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">Or send a download link via email (tiny email, no attachment limits):</p>
@ -353,7 +373,7 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
</div>
</div>`,
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)
}

View file

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