// 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 // dashboard via the HX-Redirect header. // // Flow: // 1. Extract event ID from the URL via chi.URLParam // 2. Parse the form for target email and report format // 3. Verify the authenticated user owns this event // 4. Fetch all expenses for the event from the database // 5. Generate the report in the requested format (CSV or PDF) // 6. Send the report as an email attachment // 7. Update the event status to "closed" // 8. Return an HX-Redirect header pointing to /dashboard func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { // 1. Get event ID from the URL path parameter. eventID := chi.URLParam(r, "id") if eventID == "" { log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL", time.Now().Format(time.RFC3339)) renderFileError(w, "Missing event 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") format := r.FormValue("format") if to == "" { log.Printf("ERROR [%s] handlers: FileEvent: missing email field", time.Now().Format(time.RFC3339)) renderFileError(w, "Email address is required.") return } if format != "csv" && format != "pdf" { log.Printf("ERROR [%s] handlers: FileEvent: invalid format %q", time.Now().Format(time.RFC3339), format) renderFileError(w, "Format must be 'csv' or 'pdf'.") return } // 3. Verify the authenticated user owns this event. 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 } 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.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 the report in the requested format. var reportAttachment *email.Attachment switch format { case "csv": reportAttachment, err = generateCSV(event.Name, expenses, userName, userDept) case "pdf": reportAttachment, err = generatePDF(event.Name, expenses, userName, userDept) } if err != nil { log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v", time.Now().Format(time.RFC3339), format, err) renderFileError(w, "Failed to generate 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 (report + ZIP if available). attachments := []*email.Attachment{reportAttachment} 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 := "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 dashboard via HTMX. w.Header().Set("HX-Redirect", "/dashboard") 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) { eventID := chi.URLParam(r, "id") if eventID == "" { log.Printf("ERROR [%s] handlers: GenerateReport: missing event ID", time.Now().Format(time.RFC3339)) renderFileError(w, "Missing event 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 } format := strings.ToLower(strings.TrimSpace(r.FormValue("format"))) if format != "csv" && format != "pdf" { format = "pdf" } userID := getUserID(r) if userID == "" { renderFileError(w, "Session expired. Please log in again.") return } event, err := database.GetEventByID(h.DB, eventID) if err != nil || event == nil { renderFileError(w, "Event not found.") return } if 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 the report. var reportAtt *email.Attachment if format == "csv" { reportAtt, err = generateCSV(event.Name, expenses, uName, uDept) } else { reportAtt, err = generatePDF(event.Name, expenses, uName, uDept) } if err != nil { log.Printf("ERROR [%s] handlers: GenerateReport: generate %s: %v", time.Now().Format(time.RFC3339), format, err) renderFileError(w, "Failed to generate report.") return } // 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 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. ext := format reportName := fmt.Sprintf("%s-report.%s", sanitiseFilename(event.Name), ext) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
Report Ready

%s & %d receipt images packaged.

⬇ Download Now

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

`, template.HTMLEscapeString(reportName), len(expenses), template.HTMLEscapeString(token), template.HTMLEscapeString(dlName), template.HTMLEscapeString(eventID), template.HTMLEscapeString(token)) } // --------------------------------------------------------------------------- // POST /events/{id}/send-link — SendDownloadLink // --------------------------------------------------------------------------- // SendDownloadLink emails a download link for a previously generated report // package to the specified recipient. Returns an HTMX fragment with success // or error feedback. func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) { 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) { 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) 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 (prepared by). 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{""}) // blank separator } // Determine if we need conversion columns. hasConversion := false for _, exp := range expenses { if exp.ConvertedAmount > 0 && exp.BaseCurrency != "" && exp.BaseCurrency != exp.Currency { hasConversion = true break } } // Write header row. var header []string if hasConversion { header = []string{"#", "Date", "Merchant", "Amount", "Currency", "Converted", "Claim Currency", "Category", "Description"} } else { header = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category", "Description"} } if err := writer.Write(header); err != nil { return nil, fmt.Errorf("write CSV header: %w", err) } // Write one data row per expense with item number. totalOrig := 0.0 totalConv := 0.0 for i, exp := range expenses { itemNum := i + 1 var row []string if hasConversion { row = []string{ fmt.Sprintf("%d", itemNum), exp.Date, exp.Merchant, fmt.Sprintf("%.2f", exp.Amount), exp.Currency, fmt.Sprintf("%.2f", exp.ConvertedAmount), exp.BaseCurrency, exp.Category, exp.Description, } } else { row = []string{ fmt.Sprintf("%d", itemNum), exp.Date, exp.Merchant, fmt.Sprintf("%.2f", exp.Amount), exp.Currency, exp.Category, exp.Description, } } if err := writer.Write(row); err != nil { return nil, fmt.Errorf("write CSV row: %w", err) } totalOrig += exp.Amount totalConv += exp.ConvertedAmount } // Write totals row. if hasConversion { baseCur := "" for _, exp := range expenses { if exp.BaseCurrency != "" { baseCur = exp.BaseCurrency break } } writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", totalOrig), "", fmt.Sprintf("%.2f", totalConv), baseCur, "", ""}) } else { writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", totalOrig), "", "", "", ""}) } 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("P", "mm", "A4", "") pdf.AddPage() // Title: "Expense Report: " pdf.SetFont("Helvetica", "B", 16) pdf.Cell(0, 10, "Expense Report: "+eventName) pdf.Ln(8) // User info block. 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(10) } // Determine if we need conversion columns. hasConversion := false for _, exp := range expenses { if exp.ConvertedAmount > 0 && exp.BaseCurrency != "" && exp.BaseCurrency != exp.Currency { hasConversion = true break } } // Table header row with item number. pdf.SetFont("Helvetica", "B", 10) var headers []string var colWidths []float64 if hasConversion { headers = []string{"#", "Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category"} colWidths = []float64{8, 22, 38, 18, 12, 20, 14, 30} } else { headers = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category"} colWidths = []float64{10, 28, 48, 22, 18, 40} } for i, h := range headers { pdf.Cell(colWidths[i], 8, h) } pdf.Ln(8) // Table data rows with item numbers and automatic page breaks. pdf.SetFont("Helvetica", "", 9) marginBottom := 20.0 // mm margin from bottom before page break var totalOrig, totalConv float64 for i, exp := range expenses { // Check if we need a page break (A4 = 297mm height). if pdf.GetY() > 297-marginBottom { pdf.AddPage() // Re-draw header row on new page. pdf.SetFont("Helvetica", "B", 9) for j, h := range headers { pdf.Cell(colWidths[j], 8, h) } pdf.Ln(8) pdf.SetFont("Helvetica", "", 9) } itemNum := i + 1 if hasConversion { pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum)) pdf.Cell(colWidths[1], 8, exp.Date) pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 18)) pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount)) pdf.Cell(colWidths[4], 8, exp.Currency) pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", exp.ConvertedAmount)) pdf.Cell(colWidths[6], 8, exp.BaseCurrency) pdf.Cell(colWidths[7], 8, truncateString(exp.Category, 12)) totalOrig += exp.Amount totalConv += exp.ConvertedAmount } else { pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum)) pdf.Cell(colWidths[1], 8, exp.Date) pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 20)) pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount)) pdf.Cell(colWidths[4], 8, exp.Currency) pdf.Cell(colWidths[5], 8, truncateString(exp.Category, 20)) totalOrig += exp.Amount } pdf.Ln(8) } // Totals row with a separator line. pdf.SetDrawColor(71, 85, 105) // #475569 border colour pdf.Line(10, pdf.GetY()+1, 200, pdf.GetY()+1) pdf.Ln(3) pdf.SetFont("Helvetica", "B", 9) if hasConversion { // Determine the base/claim currency from the first expense that has one. baseCur := "" for _, exp := range expenses { if exp.BaseCurrency != "" { baseCur = exp.BaseCurrency break } } pdf.Cell(colWidths[0], 8, "") pdf.Cell(colWidths[1], 8, "") pdf.Cell(colWidths[2], 8, "TOTAL") pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", totalOrig)) pdf.Cell(colWidths[4], 8, "") pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", totalConv)) pdf.Cell(colWidths[6], 8, baseCur) pdf.Cell(colWidths[7], 8, "") } else { pdf.Cell(colWidths[0], 8, "") pdf.Cell(colWidths[1], 8, "") pdf.Cell(colWidths[2], 8, "TOTAL") pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", totalOrig)) pdf.Cell(colWidths[4], 8, "") pdf.Cell(colWidths[5], 8, "") } // Write the PDF document to a memory 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), "-") }