// Package handlers implements HTTP request handlers for NextExpense. // // This file implements the event filing workflow — generating CSV or PDF // expense reports and emailing them as attachments to a specified recipient. package handlers import ( "archive/zip" "bytes" "crypto/rand" "database/sql" "encoding/csv" "encoding/hex" "fmt" "html/template" "log" "net/http" "os" "path/filepath" "strings" "time" "github.com/go-chi/chi/v5" "github.com/jung-kurt/gofpdf" "github.com/cclohmar/NextExpense/internal/database" "github.com/cclohmar/NextExpense/internal/email" ) // --------------------------------------------------------------------------- // FileHandler // --------------------------------------------------------------------------- // FileHandler handles the event filing workflow: generating expense reports // in CSV or PDF format and emailing them to a specified address. // It depends on a shared *sql.DB handle for database access and an // *email.Sender for delivering the report as an email attachment. type FileHandler struct { DB *sql.DB EmailSender *email.Sender } // --------------------------------------------------------------------------- // POST /events/{id}/file — FileEvent // --------------------------------------------------------------------------- // FileEvent generates an expense report (CSV or PDF) for a given event and // emails it as an attachment to the specified recipient. On success the // event status is updated to "closed" and the client is redirected to the // month view via the HX-Redirect header. func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { // 1. Get month and event IDs from the URL path parameters. monthID := chi.URLParam(r, "mid") eventID := chi.URLParam(r, "eid") if monthID == "" || eventID == "" { log.Printf("ERROR [%s] handlers: FileEvent: missing ID in URL", time.Now().Format(time.RFC3339)) renderFileError(w, "Missing ID.") return } // 2. Parse form fields. if err := r.ParseForm(); err != nil { log.Printf("ERROR [%s] handlers: FileEvent: parse form: %v", time.Now().Format(time.RFC3339), err) renderFileError(w, "Cannot parse form data.") return } to := r.FormValue("email") if to == "" { log.Printf("ERROR [%s] handlers: FileEvent: missing email field", time.Now().Format(time.RFC3339)) renderFileError(w, "Email address is required.") return } // 3. Verify the authenticated user owns this event (via month). userID := getUserID(r) if userID == "" { log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request", time.Now().Format(time.RFC3339)) renderFileError(w, "Session expired. Please log in again.") return } if !verifyMonthOwnership(h.DB, monthID, userID) { renderFileError(w, "You do not have permission to file this event.") return } event, err := database.GetEventByID(h.DB, eventID) if err != nil { log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v", time.Now().Format(time.RFC3339), eventID, err) renderFileError(w, "Failed to retrieve event. Please try again.") return } if event == nil { log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s", time.Now().Format(time.RFC3339), eventID) renderFileError(w, "Event not found.") return } if event.MonthID != monthID || event.UserID != userID { log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s", time.Now().Format(time.RFC3339), userID, eventID) renderFileError(w, "You do not have permission to file this event.") return } // 4. Fetch all expenses for the event. expenses, err := database.GetExpensesByEvent(h.DB, eventID) if err != nil { log.Printf("ERROR [%s] handlers: FileEvent: GetExpensesByEvent(%s): %v", time.Now().Format(time.RFC3339), eventID, err) renderFileError(w, "Failed to retrieve expenses. Please try again.") return } // Fetch user info for report personalisation. reportUser, _ := database.GetUserByID(h.DB, userID) userName := "" userDept := "" if reportUser != nil { userName = reportUser.Name userDept = reportUser.Department } // 5. Generate both CSV and PDF reports. csvAttachment, err := generateCSV(event.Name, expenses, userName, userDept) if err != nil { log.Printf("ERROR [%s] handlers: FileEvent: generate CSV: %v", time.Now().Format(time.RFC3339), err) renderFileError(w, "Failed to generate CSV report. Please try again.") return } pdfAttachment, err := generatePDF(event.Name, expenses, userName, userDept) if err != nil { log.Printf("ERROR [%s] handlers: FileEvent: generate PDF: %v", time.Now().Format(time.RFC3339), err) renderFileError(w, "Failed to generate PDF report. Please try again.") return } // 6. Create a ZIP of all receipt images. zipAttachment, zipErr := createReceiptZip(event.Name, expenses) // 7. Build the list of attachments (CSV + PDF + ZIP if available). attachments := []*email.Attachment{csvAttachment, pdfAttachment} if zipErr == nil && zipAttachment != nil { attachments = append(attachments, zipAttachment) } else if zipErr != nil { log.Printf("WARN [%s] handlers: FileEvent: receipt zip failed: %v", time.Now().Format(time.RFC3339), zipErr) } // 8. Send the email with all attachments. if h.EmailSender == nil { log.Printf("ERROR [%s] handlers: FileEvent: SMTP not configured, cannot send email", time.Now().Format(time.RFC3339)) renderFileError(w, "SMTP not configured. Please contact the administrator.") return } subject := fmt.Sprintf("%s | Expense report for event %s", userName, event.Name) if userName == "" { subject = "Expense report for event " + event.Name } body := "Please find attached the expense report and receipt images." if err := h.EmailSender.SendReport(to, subject, body, attachments); err != nil { log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v", time.Now().Format(time.RFC3339), to, err) renderFileError(w, "Failed to send report: "+err.Error()) return } // 7. Update the event status to "closed". if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil { log.Printf("ERROR [%s] handlers: FileEvent: UpdateEventStatus(%s): %v", time.Now().Format(time.RFC3339), eventID, err) renderFileError(w, "Report sent but failed to close the event. Please try again.") return } // 8. Redirect to the month view via HTMX. w.Header().Set("HX-Redirect", "/months/"+monthID) w.WriteHeader(http.StatusOK) } // --------------------------------------------------------------------------- // POST /events/{id}/generate — GenerateReport // --------------------------------------------------------------------------- // GenerateReport creates a report package (CSV/PDF + receipt images ZIP), // stores it on disk with a crypto-random download token, and returns an // HTMX fragment with download and email-link options. The event is NOT // closed — the user can add more receipts and regenerate. func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) { monthID := chi.URLParam(r, "mid") eventID := chi.URLParam(r, "eid") if monthID == "" || eventID == "" { log.Printf("ERROR [%s] handlers: GenerateReport: missing ID", time.Now().Format(time.RFC3339)) renderFileError(w, "Missing ID.") return } if err := r.ParseForm(); err != nil { log.Printf("ERROR [%s] handlers: GenerateReport: parse form: %v", time.Now().Format(time.RFC3339), err) renderFileError(w, "Cannot parse form data.") return } userID := getUserID(r) if userID == "" { renderFileError(w, "Session expired. Please log in again.") return } if !verifyMonthOwnership(h.DB, monthID, userID) { renderFileError(w, "You do not have permission to access this event.") return } event, err := database.GetEventByID(h.DB, eventID) if err != nil || event == nil { renderFileError(w, "Event not found.") return } if event.MonthID != monthID || event.UserID != userID { renderFileError(w, "You do not have permission to access this event.") return } expenses, err := database.GetExpensesByEvent(h.DB, eventID) if err != nil { renderFileError(w, "Failed to retrieve expenses.") return } if len(expenses) == 0 { renderFileError(w, "No expenses to include in the report.") return } // Fetch user info for report personalisation. repUser, _ := database.GetUserByID(h.DB, userID) uName := "" uDept := "" if repUser != nil { uName = repUser.Name uDept = repUser.Department } // Generate both CSV and PDF reports. csvAtt, err := generateCSV(event.Name, expenses, uName, uDept) if err != nil { log.Printf("ERROR [%s] handlers: GenerateReport: generate CSV: %v", time.Now().Format(time.RFC3339), err) renderFileError(w, "Failed to generate CSV report.") return } pdfAtt, err := generatePDF(event.Name, expenses, uName, uDept) if err != nil { log.Printf("ERROR [%s] handlers: GenerateReport: generate PDF: %v", time.Now().Format(time.RFC3339), err) renderFileError(w, "Failed to generate PDF report.") return } // Package everything into a single flat ZIP (report + receipt images). var pkgBuf bytes.Buffer pkg := zip.NewWriter(&pkgBuf) // Add both report files. addToZip(pkg, csvAtt.Filename, csvAtt.Content) addToZip(pkg, pdfAtt.Filename, pdfAtt.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 { renderFileError(w, "Failed to create package.") return } // Save to postbox directory. os.MkdirAll("storage/postbox", 0755) tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { renderFileError(w, "Failed to generate download token.") return } token := hex.EncodeToString(tokenBytes) // 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 { log.Printf("ERROR [%s] handlers: GenerateReport: write %s: %v", time.Now().Format(time.RFC3339), pkgPath, err) renderFileError(w, "Failed to save report package.") return } // Store token in DB (24h expiry). expiresAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339) if err := database.CreateDownloadToken(h.DB, token, eventID, pkgFilename, expiresAt); err != nil { os.Remove(pkgPath) renderFileError(w, "Failed to store download token.") return } log.Printf("INFO [%s] handlers: GenerateReport: package %s created for event %s", time.Now().Format(time.RFC3339), pkgFilename, eventID) // Render the download/send fragment. w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Report Ready

CSV + PDF report & %d receipt images packaged.

⬇ Download Now

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

`, len(expenses), template.HTMLEscapeString(token), template.HTMLEscapeString(dlName), template.HTMLEscapeString(monthID), template.HTMLEscapeString(eventID), template.HTMLEscapeString(token)) } // --------------------------------------------------------------------------- // POST /months/{mid}/events/{eid}/send-link — SendDownloadLink // --------------------------------------------------------------------------- // SendDownloadLink emails a download link for a previously generated report // package to the specified recipient. func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) { monthID := chi.URLParam(r, "mid") if err := r.ParseForm(); err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Failed to parse form.
`) return } token := strings.TrimSpace(r.FormValue("token")) to := strings.TrimSpace(r.FormValue("email")) if token == "" || to == "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Token and email are required.
`) return } // Verify token exists and belongs to user's event. dt, err := database.GetDownloadTokenByToken(h.DB, token) if err != nil || dt == nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Invalid or expired download token.
`) return } event, err := database.GetEventByID(h.DB, dt.EventID) if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Permission denied.
`) return } if h.EmailSender == nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
SMTP not configured.
`) return } // Build the download URL using the request's Host header (most reliable), // falling back to BASE_URL env var. scheme := "https" host := r.Host baseURL := os.Getenv("BASE_URL") if host == "" && baseURL != "" { // Parse scheme and host from BASE_URL as fallback. if strings.HasPrefix(baseURL, "https://") { host = strings.TrimPrefix(baseURL, "https://") } else if strings.HasPrefix(baseURL, "http://") { scheme = "http" host = strings.TrimPrefix(baseURL, "http://") } } if host == "" { host = "localhost:8080" } safeName := sanitiseFilename(event.Name) if safeName == "" { safeName = "report" } link := fmt.Sprintf("%s://%s/dl/%s/%s.zip", scheme, host, token, safeName) // Fetch user name for the subject line. repUser, _ := database.GetUserByID(h.DB, getUserID(r)) userName := "" if repUser != nil { userName = repUser.Name } subject := fmt.Sprintf("%s | Expense report: %s", userName, event.Name) if userName == "" { 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) if err := h.EmailSender.SendReport(to, subject, body, nil); err != nil { log.Printf("ERROR [%s] handlers: SendDownloadLink: %v", time.Now().Format(time.RFC3339), err) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Failed to send: %s
`, template.HTMLEscapeString(err.Error())) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Download link sent to %s.
`, template.HTMLEscapeString(to)) } // --------------------------------------------------------------------------- // GET /dl/{token} — ServeDownload // --------------------------------------------------------------------------- // 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 == "" { http.NotFound(w, r) return } dt, err := database.GetDownloadTokenByToken(h.DB, token) if err != nil || dt == nil { http.NotFound(w, r) return } // Check expiry. expiresAt, err := time.Parse(time.RFC3339, dt.ExpiresAt) if err != nil || time.Now().After(expiresAt) { http.NotFound(w, r) return } pkgPath := filepath.Join("storage", "postbox", dt.Filename) if _, err := os.Stat(pkgPath); os.IsNotExist(err) { http.NotFound(w, r) return } // 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"`, dlName)) http.ServeFile(w, r, pkgPath) } // --------------------------------------------------------------------------- // StartDownloadCleanup // --------------------------------------------------------------------------- // StartDownloadCleanup runs a background goroutine that periodically deletes // expired download tokens and their associated files from disk. func (h *FileHandler) StartDownloadCleanup() { go func() { for { time.Sleep(1 * time.Hour) filenames, err := database.DeleteExpiredDownloadTokens(h.DB) if err != nil { log.Printf("ERROR [%s] handlers: download cleanup: %v", time.Now().Format(time.RFC3339), err) continue } for _, fn := range filenames { path := filepath.Join("storage", "postbox", fn) if err := os.Remove(path); err != nil { log.Printf("WARN [%s] handlers: download cleanup: remove %s: %v", time.Now().Format(time.RFC3339), path, err) } } if len(filenames) > 0 { log.Printf("INFO [%s] handlers: download cleanup: removed %d expired packages", time.Now().Format(time.RFC3339), len(filenames)) } } }() } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- // addToZip adds a file to a zip.Writer. Errors are logged but not returned // since a missing image in the ZIP is non-fatal — the report is the priority. func addToZip(zw *zip.Writer, name string, data []byte) { f, err := zw.Create(name) if err != nil { log.Printf("WARN [%s] handlers: addToZip: create %q: %v", time.Now().Format(time.RFC3339), name, err) return } if _, err := f.Write(data); err != nil { log.Printf("WARN [%s] handlers: addToZip: write %q: %v", time.Now().Format(time.RFC3339), name, err) } } // --------------------------------------------------------------------------- // Report generation helpers // --------------------------------------------------------------------------- // generateCSV creates a CSV attachment from the provided expenses. // The CSV includes a header row and one data row per expense. // If the expenses use a different currency than the base currency, both // original and converted amounts are included. func generateCSV(eventName string, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) { var buf bytes.Buffer writer := csv.NewWriter(&buf) // Write user metadata row. if userName != "" { metaLine := fmt.Sprintf("Prepared by: %s", userName) if userDept != "" && userDept != "-" { metaLine += fmt.Sprintf(" | Department: %s", userDept) } writer.Write([]string{metaLine}) writer.Write([]string{""}) } // Total claim summary. var totalClaim float64 claimCur := "" for _, exp := range expenses { cAmt := exp.ConvertedAmount if cAmt <= 0 { cAmt = exp.Amount } totalClaim += cAmt if claimCur == "" && exp.BaseCurrency != "" { claimCur = exp.BaseCurrency } } if claimCur == "" && len(expenses) > 0 { claimCur = expenses[0].Currency } writer.Write([]string{fmt.Sprintf("Total Claim: %.2f %s", totalClaim, claimCur)}) writer.Write([]string{""}) // Write header row with both local and claim columns. header := []string{"#", "Date", "Merchant", "Local Amt", "Currency", "Claim Amt", "Claim Curr", "Category", "Description"} writer.Write(header) // Write data rows. var tableLocal, tableClaim float64 for i, exp := range expenses { claimAmt := exp.ConvertedAmount claimCur := exp.BaseCurrency if claimAmt <= 0 { claimAmt = exp.Amount } if claimCur == "" { claimCur = exp.Currency } row := []string{ fmt.Sprintf("%d", i+1), exp.Date, exp.Merchant, fmt.Sprintf("%.2f", exp.Amount), exp.Currency, fmt.Sprintf("%.2f", claimAmt), claimCur, exp.Category, exp.Description, } writer.Write(row) tableLocal += exp.Amount tableClaim += claimAmt } // Write totals row. writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", tableLocal), "", fmt.Sprintf("%.2f", tableClaim), "", "", ""}) writer.Flush() if err := writer.Error(); err != nil { return nil, fmt.Errorf("CSV writer flush: %w", err) } filename := fmt.Sprintf("expense-%s-report.csv", sanitiseFilename(eventName)) return &email.Attachment{ Filename: filename, Content: buf.Bytes(), }, nil } // generatePDF creates a PDF attachment from the provided expenses using gofpdf. // The PDF contains a title row, a header row, and one data row per expense. // If the expenses use a different currency than the base currency, both // original and converted amounts are included. func generatePDF(eventName string, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) { pdf := gofpdf.New("L", "mm", "A4", "") pdf.AddPage() // Title. pdf.SetFont("Helvetica", "B", 14) pdf.Cell(0, 10, "Expense Report: "+eventName) pdf.Ln(8) // User info. if userName != "" { pdf.SetFont("Helvetica", "", 9) infoLine := fmt.Sprintf("Prepared by: %s", userName) if userDept != "" && userDept != "-" { infoLine += fmt.Sprintf(" | Department: %s", userDept) } pdf.Cell(0, 6, infoLine) pdf.Ln(6) } // Total claim summary. var totalClaim float64 claimCur := "" for _, exp := range expenses { cAmt := exp.ConvertedAmount if cAmt <= 0 { cAmt = exp.Amount } totalClaim += cAmt if claimCur == "" && exp.BaseCurrency != "" { claimCur = exp.BaseCurrency } } if claimCur == "" && len(expenses) > 0 { claimCur = expenses[0].Currency } pdf.SetFont("Helvetica", "B", 10) pdf.Cell(0, 8, fmt.Sprintf("Total Claim: %.2f %s", totalClaim, claimCur)) pdf.Ln(12) // Table header. pdf.SetFont("Helvetica", "B", 8) headers := []string{"#", "Date", "Merchant", "Local Amt", "Cur", "Claim Amt", "Claim", "Category", "Description"} colWidths := []float64{7, 22, 52, 18, 12, 18, 12, 36, 100} for i, h := range headers { pdf.Cell(colWidths[i], 7, h) } pdf.Ln(7) // Table data rows. pdf.SetFont("Helvetica", "", 8) marginBottom := 18.0 var totalLocal, tableClaim float64 for i, exp := range expenses { if pdf.GetY() > 210-marginBottom { pdf.AddPage() pdf.SetFont("Helvetica", "B", 8) for j, h := range headers { pdf.Cell(colWidths[j], 7, h) } pdf.Ln(7) pdf.SetFont("Helvetica", "", 8) } // Compute claim amount (auto-calc if not set). claimAmt := exp.ConvertedAmount claimCur := exp.BaseCurrency if claimAmt <= 0 { claimAmt = exp.Amount } if claimCur == "" { claimCur = exp.Currency } itemNum := i + 1 pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum)) pdf.Cell(colWidths[1], 6, exp.Date) pdf.Cell(colWidths[2], 6, exp.Merchant) pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount)) pdf.Cell(colWidths[4], 6, exp.Currency) pdf.Cell(colWidths[5], 6, fmt.Sprintf("%.2f", claimAmt)) pdf.Cell(colWidths[6], 6, claimCur) pdf.Cell(colWidths[7], 6, exp.Category) pdf.Cell(colWidths[8], 6, exp.Description) pdf.Ln(6) totalLocal += exp.Amount tableClaim += claimAmt } // Totals row in claim currency. pdf.SetDrawColor(71, 85, 105) pdf.Line(10, pdf.GetY()+1, 287, pdf.GetY()+1) pdf.Ln(3) pdf.SetFont("Helvetica", "B", 9) claimTotalCur := "" for _, exp := range expenses { if exp.BaseCurrency != "" { claimTotalCur = exp.BaseCurrency break } } if claimTotalCur == "" { claimTotalCur = expenses[0].Currency } pdf.Cell(colWidths[0], 8, "") pdf.Cell(colWidths[1], 8, "") pdf.Cell(colWidths[2], 8, "TOTAL") pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", totalLocal)) pdf.Cell(colWidths[4], 8, "") pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", tableClaim)) pdf.Cell(colWidths[6], 8, claimTotalCur) pdf.Cell(colWidths[7], 8, "") pdf.Cell(colWidths[8], 8, "") // Write to buffer. var buf bytes.Buffer if err := pdf.Output(&buf); err != nil { return nil, fmt.Errorf("PDF output: %w", err) } filename := fmt.Sprintf("expense-%s-report.pdf", sanitiseFilename(eventName)) return &email.Attachment{ Filename: filename, Content: buf.Bytes(), }, nil } // createReceiptZip creates a ZIP archive containing all receipt images from the // given expenses. Each image is named {event-name}-{index}.{ext} inside the ZIP. // Returns nil if there are no expenses with images, or if all image files are // missing from disk. func createReceiptZip(eventName string, expenses []database.Expense) (*email.Attachment, error) { safeName := sanitiseFilename(eventName) if safeName == "" { safeName = "event" } var buf bytes.Buffer zw := zip.NewWriter(&buf) added := 0 for i, exp := range expenses { if exp.ImagePath == "" { continue } // Normalize path (strip legacy "storage/" prefix if present), // then construct the full path safely within the storage directory. // filepath.Base prevents path traversal by extracting only the filename. normPath := normalizeImagePath(exp.ImagePath) safePath := filepath.Join("storage", filepath.Base(normPath)) // Read the image file from disk. data, err := os.ReadFile(safePath) if err != nil { log.Printf("WARN [%s] handlers: createReceiptZip: reading %q: %v", time.Now().Format(time.RFC3339), safePath, err) continue } // Determine file extension from the image path. ext := filepath.Ext(exp.ImagePath) if ext == "" { ext = ".jpg" } filename := fmt.Sprintf("%s-%d%s", safeName, i+1, ext) f, err := zw.Create(filename) if err != nil { log.Printf("WARN [%s] handlers: createReceiptZip: creating entry %q: %v", time.Now().Format(time.RFC3339), filename, err) continue } if _, err := f.Write(data); err != nil { log.Printf("WARN [%s] handlers: createReceiptZip: writing %q: %v", time.Now().Format(time.RFC3339), filename, err) continue } added++ } if err := zw.Close(); err != nil { return nil, fmt.Errorf("closing zip: %w", err) } if added == 0 { log.Printf("INFO [%s] handlers: createReceiptZip: no receipt images found for event %q", time.Now().Format(time.RFC3339), eventName) return nil, nil } return &email.Attachment{ Filename: fmt.Sprintf("expense-%s-images.zip", safeName), Content: buf.Bytes(), }, nil } // renderFileError writes an HTMX-compatible error fragment targeted at the // #submit-error container on the event expenses page. Using a 200 status // ensures HTMX always swaps the content (HTMX skips 4xx/5xx by default). func renderFileError(w http.ResponseWriter, message string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("HX-Retarget", "#submit-error") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, `
%s
`, template.HTMLEscapeString(message)) } // truncateString truncates a string to the given maximum length, appending "…" // if the string was shortened. func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen-1] + "…" } // sanitiseFilename converts a string into a safe filename (alphanumerics, // hyphens, underscores only — no spaces or special characters). func sanitiseFilename(s string) string { var result []rune for _, r := range s { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { result = append(result, r) } else if r == ' ' || r == '.' { result = append(result, '-') } } if len(result) == 0 { return "expenses" } return strings.Trim(string(result), "-") }