From be3aa7a036d670c4f7f880f69f687885b181ef7a Mon Sep 17 00:00:00 2001 From: cclohmar Date: Wed, 17 Jun 2026 12:13:34 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20add=20postbox=20=E2=80=94=20generate?= =?UTF-8?q?=20report=20package,=20download=20or=20email=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/database/db.go | 112 +++++++++++++ internal/handlers/file.go | 307 ++++++++++++++++++++++++++++++++++ main.go | 8 + templates/event_expenses.html | 62 ++++--- 4 files changed, 457 insertions(+), 32 deletions(-) diff --git a/internal/database/db.go b/internal/database/db.go index ea93f67..00fbd1f 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -60,6 +60,16 @@ type Expense struct { CreatedAt string } +// DownloadToken represents a download token for a generated report package. +type DownloadToken struct { + Token string + EventID string + Filename string + CreatedAt string + ExpiresAt string + Accessed bool +} + // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- @@ -127,6 +137,14 @@ func createTables(db *sql.DB) error { created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE )`, + `CREATE TABLE IF NOT EXISTS download_tokens ( + token TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + filename TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + accessed INTEGER NOT NULL DEFAULT 0 + )`, } for _, stmt := range statements { @@ -421,3 +439,97 @@ func DeleteExpense(db *sql.DB, id string) error { } return err } + +// --------------------------------------------------------------------------- +// Download token queries +// --------------------------------------------------------------------------- + +// CreateDownloadToken inserts a new download token row. +func CreateDownloadToken(db *sql.DB, token, eventID, filename, expiresAt string) error { + _, err := db.Exec( + "INSERT INTO download_tokens (token, event_id, filename, expires_at) VALUES (?, ?, ?, ?)", + token, eventID, filename, expiresAt, + ) + if err != nil { + log.Printf("ERROR [%s] database: CreateDownloadToken(%s): %v", + time.Now().Format(time.RFC3339), token, err) + } + return err +} + +// GetDownloadTokenByToken retrieves a download token record by its token string. +func GetDownloadTokenByToken(db *sql.DB, token string) (*DownloadToken, error) { + dt := &DownloadToken{} + err := db.QueryRow( + "SELECT token, event_id, filename, created_at, expires_at, accessed FROM download_tokens WHERE token = ?", + token, + ).Scan(&dt.Token, &dt.EventID, &dt.Filename, &dt.CreatedAt, &dt.ExpiresAt, &dt.Accessed) + if err != nil { + return nil, err + } + return dt, nil +} + +// MarkDownloadTokenAccessed sets the accessed flag for a token. +func MarkDownloadTokenAccessed(db *sql.DB, token string) error { + _, err := db.Exec("UPDATE download_tokens SET accessed = 1 WHERE token = ?", token) + if err != nil { + log.Printf("ERROR [%s] database: MarkDownloadTokenAccessed(%s): %v", + time.Now().Format(time.RFC3339), token, err) + } + return err +} + +// DeleteExpiredDownloadTokens removes tokens past their expiry and their files. +// Returns the filenames of deleted tokens so the caller can clean up disk files. +func DeleteExpiredDownloadTokens(db *sql.DB) ([]string, error) { + rows, err := db.Query("SELECT filename FROM download_tokens WHERE expires_at < datetime('now')") + if err != nil { + return nil, err + } + defer rows.Close() + + var filenames []string + for rows.Next() { + var fn string + if err := rows.Scan(&fn); err != nil { + continue + } + filenames = append(filenames, fn) + } + + if len(filenames) > 0 { + if _, err := db.Exec("DELETE FROM download_tokens WHERE expires_at < datetime('now')"); err != nil { + return filenames, err + } + } + + return filenames, nil +} + +// DeleteDownloadTokensByEvent removes all download tokens for a given event. +// Returns the filenames so the caller can clean up disk files. +func DeleteDownloadTokensByEvent(db *sql.DB, eventID string) ([]string, error) { + rows, err := db.Query("SELECT filename FROM download_tokens WHERE event_id = ?", eventID) + if err != nil { + return nil, err + } + defer rows.Close() + + var filenames []string + for rows.Next() { + var fn string + if err := rows.Scan(&fn); err != nil { + continue + } + filenames = append(filenames, fn) + } + + if len(filenames) > 0 { + if _, err := db.Exec("DELETE FROM download_tokens WHERE event_id = ?", eventID); err != nil { + return filenames, err + } + } + + return filenames, nil +} diff --git a/internal/handlers/file.go b/internal/handlers/file.go index 86abfa7..df4cfc9 100644 --- a/internal/handlers/file.go +++ b/internal/handlers/file.go @@ -7,8 +7,10 @@ package handlers import ( "archive/zip" "bytes" + "crypto/rand" "database/sql" "encoding/csv" + "encoding/hex" "fmt" "html/template" "log" @@ -184,6 +186,311 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { 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 + } + + // Generate the report. + var reportAtt *email.Attachment + if format == "csv" { + reportAtt, err = generateCSV(event.Name, expenses) + } else { + reportAtt, err = generatePDF(event.Name, expenses) + } + 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 + } + + // Create receipt images ZIP. + var zipAtt *email.Attachment + zipAtt, _ = createReceiptZip(event.Name, expenses) + + // Package everything into a single download ZIP. + 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) + } + + if err := pkg.Close(); err != nil { + renderFileError(w, "Failed to create package.") + return + } + + // Save to postbox directory — first clean up any previous packages for this event. + os.MkdirAll("storage/postbox", 0755) + + // Delete old download tokens and their files for this event. + if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil { + for _, fn := range oldFiles { + oldPath := filepath.Join("storage", "postbox", fn) + os.Remove(oldPath) + } + } + + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + renderFileError(w, "Failed to generate download token.") + return + } + token := hex.EncodeToString(tokenBytes) + pkgFilename := token + ".zip" + 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(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 BASE_URL. + baseURL := os.Getenv("BASE_URL") + if baseURL == "" { + baseURL = "http://localhost:8080" + } + link := strings.TrimRight(baseURL, "/") + "/dl/" + token + + 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. +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) + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, dt.Filename)) + 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. +func addToZip(zw *zip.Writer, name string, data []byte) { + f, err := zw.Create(name) + if err != nil { + return + } + f.Write(data) +} + // --------------------------------------------------------------------------- // Report generation helpers // --------------------------------------------------------------------------- diff --git a/main.go b/main.go index 78e388d..00ccd8c 100644 --- a/main.go +++ b/main.go @@ -107,6 +107,9 @@ func main() { EmailSender: emailSender, } + // Start background cleanup of expired download packages. + fileHandler.StartDownloadCleanup() + // ----------------------------------------------------------------------- // Router // ----------------------------------------------------------------------- @@ -193,6 +196,9 @@ func main() { r.Post("/request-otp", authHandler.RequestOTP) r.Post("/verify-otp", authHandler.VerifyOTP) + // Download link (token-based auth, no login required). + r.Get("/dl/{token}", fileHandler.ServeDownload) + // ---- Logout (invalidates server-side session + clears cookie) ---- r.Post("/logout", authHandler.Logout) @@ -220,6 +226,8 @@ func main() { // Filing. r.Post("/events/{id}/file", fileHandler.FileEvent) + r.Post("/events/{id}/generate", fileHandler.GenerateReport) + r.Post("/events/{id}/send-link", fileHandler.SendDownloadLink) // Storage (receipt images) — protected by auth + path traversal check. r.With(authHandler.RequireAuth).Get("/storage/*", func(w http.ResponseWriter, r *http.Request) { diff --git a/templates/event_expenses.html b/templates/event_expenses.html index 856ea6e..18afb36 100644 --- a/templates/event_expenses.html +++ b/templates/event_expenses.html @@ -124,44 +124,42 @@

Analyzing receipt...

- + {{if and (eq .Event.Status "open") .Expenses}}
-

Submit Event

+

Generate Report

-
-
- - -
-
-
- -
- - +
+ +
+
+ +
+ + +
+
+
+ +
+ {{printf "%.2f" .TotalClaim}} {{.Event.BaseCurrency}} +
-
- -
- {{printf "%.2f" .TotalClaim}} {{.Event.BaseCurrency}} -
+
+
+

Packaging report…

-
-
-
-

Sending report via email…

-
- - + + +
{{end}}