chore: add postbox — generate report package, download or email link

This commit is contained in:
Claus Lohmar 2026-06-17 12:13:34 +00:00
parent cc56d1ac9f
commit be3aa7a036
4 changed files with 457 additions and 32 deletions

View file

@ -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
}

View file

@ -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, `<div id="report-package" style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-top: 1rem;">
<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 &amp; %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>
</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>
<form hx-post="/events/%s/send-link" hx-target="#send-link-result" hx-indicator="#send-link-spinner" style="display: flex; gap: 0.5rem;">
<input type="hidden" name="token" value="%s">
<input type="email" name="email" placeholder="finance@company.com" required style="flex:1; padding:0.5rem; border:1px solid #475569; border-radius:0.375rem; background:#1e293b; color:#f8fafc; font-size:0.85rem;">
<button type="submit" class="btn btn-secondary" style="font-size:0.85rem; white-space:nowrap;">Send Link</button>
</form>
<div id="send-link-spinner" class="htmx-indicator" style="text-align:center; padding:0.5rem;"><div class="spinner"></div></div>
<div id="send-link-result"></div>
</div>
</div>`,
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, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to parse form.</div>`)
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, `<div style="color:#fca5a5; font-size:0.8rem;">Token and email are required.</div>`)
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, `<div style="color:#fca5a5; font-size:0.8rem;">Invalid or expired download token.</div>`)
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, `<div style="color:#fca5a5; font-size:0.8rem;">Permission denied.</div>`)
return
}
if h.EmailSender == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">SMTP not configured.</div>`)
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, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to send: %s</div>`,
template.HTMLEscapeString(err.Error()))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#6ee7b7; font-size:0.8rem; margin-top:0.5rem;">Download link sent to %s.</div>`,
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
// ---------------------------------------------------------------------------

View file

@ -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) {

View file

@ -124,44 +124,42 @@
<p>Analyzing receipt...</p>
</div>
<!-- Submit Event (only if open and has expenses) -->
<!-- Generate Report (only if open and has expenses) -->
{{if and (eq .Event.Status "open") .Expenses}}
<div style="margin-top: 2rem; border-top: 1px solid var(--color-border); padding-top: 1.5rem;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem;">Submit Event</h3>
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem;">Generate Report</h3>
<div id="submit-error"></div>
<form hx-post="/events/{{.Event.ID}}/file" hx-target="body" hx-push-url="true"
hx-indicator="#submit-spinner">
<div class="form-group">
<label for="file-email">Send claim to</label>
<input type="email" id="file-email" name="email" placeholder="finance@company.com" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Format</label>
<div style="display: flex; gap: 1rem; margin-top: 0.25rem;">
<label style="display: flex; align-items: center; gap: 0.25rem;">
<input type="radio" name="format" value="csv" checked> CSV
</label>
<label style="display: flex; align-items: center; gap: 0.25rem;">
<input type="radio" name="format" value="pdf"> PDF
</label>
<div id="report-section">
<form hx-post="/events/{{.Event.ID}}/generate" hx-target="#report-section" hx-swap="outerHTML"
hx-indicator="#generate-spinner">
<div class="form-row">
<div class="form-group">
<label>Format</label>
<div style="display: flex; gap: 1rem; margin-top: 0.25rem;">
<label style="display: flex; align-items: center; gap: 0.25rem;">
<input type="radio" name="format" value="csv"> CSV
</label>
<label style="display: flex; align-items: center; gap: 0.25rem;">
<input type="radio" name="format" value="pdf" checked> PDF
</label>
</div>
</div>
<div class="form-group">
<label>Total claim</label>
<div style="font-size: 1.25rem; font-weight: 700; color: #166534; margin-top: 0.25rem;">
{{printf "%.2f" .TotalClaim}} {{.Event.BaseCurrency}}
</div>
</div>
</div>
<div class="form-group">
<label>Total claim</label>
<div style="font-size: 1.25rem; font-weight: 700; color: #166534; margin-top: 0.25rem;">
{{printf "%.2f" .TotalClaim}} {{.Event.BaseCurrency}}
</div>
<div id="generate-spinner" class="htmx-indicator" style="text-align: center; padding: 0.5rem;">
<div class="spinner"></div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-top: 0.25rem;">Packaging report…</p>
</div>
</div>
<div id="submit-spinner" class="htmx-indicator" style="text-align: center; padding: 0.5rem;">
<div class="spinner"></div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-top: 0.25rem;">Sending report via email…</p>
</div>
<button type="submit" class="btn btn-primary btn-block" style="margin-top: 0.5rem;">
Submit & Close Event
</button>
</form>
<button type="submit" class="btn btn-primary btn-block" style="margin-top: 0.5rem;">
Generate Report
</button>
</form>
</div>
</div>
{{end}}