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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create receipt images ZIP.
|
// Package everything into a single flat ZIP (report + receipt images).
|
||||||
var zipAtt *email.Attachment
|
|
||||||
zipAtt, _ = createReceiptZip(event.Name, expenses)
|
|
||||||
|
|
||||||
// Package everything into a single download ZIP.
|
|
||||||
var pkgBuf bytes.Buffer
|
var pkgBuf bytes.Buffer
|
||||||
pkg := zip.NewWriter(&pkgBuf)
|
pkg := zip.NewWriter(&pkgBuf)
|
||||||
|
|
||||||
// Add report file.
|
// Add report file.
|
||||||
addToZip(pkg, reportAtt.Filename, reportAtt.Content)
|
addToZip(pkg, reportAtt.Filename, reportAtt.Content)
|
||||||
// Add images ZIP if present.
|
|
||||||
if zipAtt != nil {
|
// Add receipt images directly (not nested).
|
||||||
addToZip(pkg, zipAtt.Filename, zipAtt.Content)
|
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 {
|
if err := pkg.Close(); err != nil {
|
||||||
|
|
@ -310,7 +323,14 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token := hex.EncodeToString(tokenBytes)
|
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)
|
pkgPath := filepath.Join("storage", "postbox", pkgFilename)
|
||||||
|
|
||||||
if err := os.WriteFile(pkgPath, pkgBuf.Bytes(), 0644); err != nil {
|
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>
|
<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>
|
<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;">
|
<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>
|
||||||
<div style="border-top: 1px solid #065f46; padding-top: 0.75rem;">
|
<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>
|
<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>
|
||||||
</div>`,
|
</div>`,
|
||||||
template.HTMLEscapeString(reportName), len(expenses),
|
template.HTMLEscapeString(reportName), len(expenses),
|
||||||
template.HTMLEscapeString(token),
|
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
|
||||||
template.HTMLEscapeString(eventID), template.HTMLEscapeString(token))
|
template.HTMLEscapeString(eventID), template.HTMLEscapeString(token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -405,7 +425,11 @@ func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) {
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "http://localhost:8080"
|
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
|
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)
|
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.
|
// ServeDownload streams a previously generated report package to the client.
|
||||||
// Access is controlled via the crypto-random token in the URL — no login
|
// Access is controlled via the crypto-random token in the URL — no login
|
||||||
// required. The token is valid for 24 hours from creation.
|
// 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) {
|
func (h *FileHandler) ServeDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
token := chi.URLParam(r, "token")
|
token := chi.URLParam(r, "token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
|
@ -460,8 +486,14 @@ func (h *FileHandler) ServeDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
// Mark as accessed.
|
// Mark as accessed.
|
||||||
database.MarkDownloadTokenAccessed(h.DB, token)
|
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-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)
|
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).
|
// Download link (token-based auth, no login required).
|
||||||
r.Get("/dl/{token}", fileHandler.ServeDownload)
|
r.Get("/dl/{token}", fileHandler.ServeDownload)
|
||||||
|
r.Get("/dl/{token}/{name}", fileHandler.ServeDownload)
|
||||||
|
|
||||||
// ---- Logout (invalidates server-side session + clears cookie) ----
|
// ---- Logout (invalidates server-side session + clears cookie) ----
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue