chore: flat ZIP structure + event-name download filenames (/dl/{token}/{name}.zip)
This commit is contained in:
parent
4bc9fe52ae
commit
64d7494e33
2 changed files with 46 additions and 13 deletions
|
|
@ -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 & %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)
|
||||
}
|
||||
|
||||
|
|
|
|||
1
main.go
1
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) ----
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue