- Rename Go module from github.com/cclohmar/ReceiptNext to NextExpense - Update all import paths across 7 Go source files - Update templates (titles, headings, branding) - Update static files (manifest.json, sw.js, CSS) - Update config (Makefile, install.sh, .env.example) - Update README with new name and URLs - Rename service file receiptnext.service -> nextexpense.service - Update install paths, service names, log paths in install.sh
719 lines
25 KiB
Go
719 lines
25 KiB
Go
// Package handlers provides HTTP request handlers for NextExpense.
|
|
//
|
|
// This file implements expense upload, AI extraction, and save handlers
|
|
// that drive the core receipt capture workflow using HTMX partial responses.
|
|
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"fmt"
|
|
"html/template"
|
|
"image"
|
|
"image/jpeg"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"golang.org/x/image/draw"
|
|
|
|
"github.com/cclohmar/NextExpense/internal/ai"
|
|
"github.com/cclohmar/NextExpense/internal/database"
|
|
"github.com/cclohmar/NextExpense/internal/utils"
|
|
)
|
|
|
|
var uuidRe = regexp.MustCompile(`^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ExpenseHandler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ExpenseHandler groups HTTP handlers related to expense management.
|
|
// It depends on a shared *sql.DB handle for database operations.
|
|
type ExpenseHandler struct {
|
|
DB *sql.DB
|
|
}
|
|
|
|
// NewExpenseHandler creates a new ExpenseHandler with the given database handle.
|
|
func NewExpenseHandler(db *sql.DB) *ExpenseHandler {
|
|
return &ExpenseHandler{DB: db}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /expenses/upload — UploadReceipt
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// UploadReceipt handles receipt image upload, AI extraction, and returns
|
|
// an HTMX fragment with a pre-filled receipt edit form.
|
|
//
|
|
// Flow:
|
|
// 1. Parse multipart form (max 10 MB memory buffer)
|
|
// 2. Validate file (size ≤ 10 MB, content type JPEG/PNG)
|
|
// 3. Save to ./storage/{uuid}.{jpg|png}
|
|
// 4. Call ai.ExtractReceipt for AI-powered data extraction
|
|
// 5. Render templates/receipt_form.html with pre-filled fields or error banner
|
|
func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
|
|
// 1. Parse multipart form with 10 MB max memory.
|
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: parse form: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderUploadError(w, "Failed to parse upload form.")
|
|
return
|
|
}
|
|
defer r.MultipartForm.RemoveAll()
|
|
|
|
// 2. Get the file from the "receipt" form field.
|
|
file, header, err := r.FormFile("receipt")
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: missing receipt field: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderUploadError(w, "Missing receipt file.")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// 3. Validate file size (max 10 MB).
|
|
if header.Size > 10<<20 {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: file too large: %d bytes",
|
|
time.Now().Format(time.RFC3339), header.Size)
|
|
renderUploadError(w, "File too large. Maximum size is 10 MB.")
|
|
return
|
|
}
|
|
|
|
// 4. Read the full file data.
|
|
fileData, err := io.ReadAll(file)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: read file: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderUploadError(w, "Failed to read uploaded file.")
|
|
return
|
|
}
|
|
|
|
// 5. Validate content type by inspecting magic bytes.
|
|
ext := detectImageExtension(fileData)
|
|
if ext == "" {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported file type: %q",
|
|
time.Now().Format(time.RFC3339), ext)
|
|
renderUploadError(w, "Unsupported file format. Please upload a receipt image (JPEG, PNG, HEIC) or PDF.")
|
|
return
|
|
}
|
|
|
|
// Resize the image (max 2048px, JPEG 85%) to keep attachment sizes manageable
|
|
// and prevent SMTP size-limit rejections when filing events with many receipts.
|
|
resized, resizeErr := resizeImage(fileData)
|
|
if resizeErr == nil && len(resized) > 0 {
|
|
fileData = resized
|
|
// If the original was not JPEG, update extension since output is always JPEG.
|
|
if ext != "jpg" && ext != "jpeg" {
|
|
ext = "jpg"
|
|
}
|
|
}
|
|
|
|
// 6. Generate a UUID-based filename and ensure the storage directory exists.
|
|
filename := utils.NewUUID() + "." + ext
|
|
storagePath := filepath.Join("storage", filename)
|
|
|
|
if err := os.MkdirAll("storage", 0755); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: mkdir storage: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderUploadError(w, "Server error. Please try again.")
|
|
return
|
|
}
|
|
|
|
// 7. Save the image file to disk.
|
|
if err := os.WriteFile(storagePath, fileData, 0644); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: write file: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
renderUploadError(w, "Failed to save receipt image. Please try again.")
|
|
return
|
|
}
|
|
|
|
// 8. Fetch the event's base currency and exchange rate.
|
|
eventID := getCurrentEventID(r)
|
|
var baseCurrency string
|
|
var exchangeRate float64
|
|
if eventID != "" {
|
|
if event, err := database.GetEventByID(h.DB, eventID); err == nil && event != nil {
|
|
baseCurrency = event.BaseCurrency
|
|
exchangeRate = event.ExchangeRate
|
|
}
|
|
}
|
|
if baseCurrency == "" {
|
|
baseCurrency = "EUR"
|
|
}
|
|
if exchangeRate <= 0 {
|
|
exchangeRate = 1.0
|
|
}
|
|
|
|
// 9. Call the Gemini Vision API for AI extraction (uses full disk path).
|
|
receipt, aiErr := ai.ExtractReceipt(filepath.Join("storage", filename))
|
|
|
|
// Strip the storage/ prefix so the template can build a proper URL: /storage/{file}
|
|
storagePath = filename
|
|
|
|
// 10. Render the receipt_form.html fragment.
|
|
tmpl := getTemplate("receipt_form.html")
|
|
|
|
data := map[string]interface{}{
|
|
"ImagePath": storagePath,
|
|
"AIError": "",
|
|
"Amount": "",
|
|
"Currency": "",
|
|
"Merchant": "",
|
|
"Category": "",
|
|
"Date": "",
|
|
"Description": "",
|
|
"BaseCurrency": baseCurrency,
|
|
"ExchangeRate": exchangeRate,
|
|
"ConvertedAmount": "",
|
|
}
|
|
|
|
if aiErr != nil {
|
|
data["AIError"] = "Could not read receipt automatically. Please fill in the fields below."
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: AI extraction failed: %v",
|
|
time.Now().Format(time.RFC3339), aiErr)
|
|
} else if receipt != nil {
|
|
data["Amount"] = strconv.FormatFloat(receipt.Amount, 'f', 2, 64)
|
|
data["Currency"] = receipt.Currency
|
|
data["Merchant"] = receipt.Merchant
|
|
data["Category"] = receipt.Category
|
|
data["Date"] = receipt.Date
|
|
|
|
// Compute converted amount only if currencies differ.
|
|
if receipt.Currency != "" && receipt.Currency != baseCurrency && receipt.Amount > 0 {
|
|
converted := receipt.Amount * exchangeRate
|
|
data["ConvertedAmount"] = strconv.FormatFloat(converted, 'f', 2, 64)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.Execute(w, data); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UploadReceipt: template execute: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /expenses — SaveExpense
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// SaveExpense handles the receipt form submission, saves the expense to the
|
|
// database, and returns an HTMX multi-target response that replaces both the
|
|
// receipt form (with a success message) and the expense list.
|
|
//
|
|
// Flow:
|
|
// 1. Read event_id from the current_event_id cookie
|
|
// 2. Parse and validate form fields (amount, currency, merchant, category, date)
|
|
// 3. Create the expense record in the database
|
|
// 4. Fetch the updated expense list
|
|
// 5. Render HTMX response with success banner + updated expense list
|
|
func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
|
|
// 1. Get the active event ID from cookie.
|
|
eventID := getCurrentEventID(r)
|
|
if eventID == "" {
|
|
log.Printf("ERROR [%s] handlers: SaveExpense: missing current_event_id cookie",
|
|
time.Now().Format(time.RFC3339))
|
|
http.Error(w, "No active event. Please select an event first.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// 2. Parse form fields.
|
|
if err := r.ParseForm(); err != nil {
|
|
log.Printf("ERROR [%s] handlers: SaveExpense: parse form: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
amountStr := r.FormValue("amount")
|
|
currency := r.FormValue("currency")
|
|
merchant := r.FormValue("merchant")
|
|
category := r.FormValue("category")
|
|
date := r.FormValue("date")
|
|
description := r.FormValue("description")
|
|
imagePath := r.FormValue("image_path")
|
|
|
|
// Read conversion fields (hidden fields from receipt form).
|
|
baseCurrency := r.FormValue("base_currency")
|
|
convertedAmountStr := r.FormValue("converted_amount")
|
|
|
|
// 3. Validate required fields.
|
|
var missing []string
|
|
if amountStr == "" {
|
|
missing = append(missing, "amount")
|
|
}
|
|
if currency == "" {
|
|
missing = append(missing, "currency")
|
|
}
|
|
if merchant == "" {
|
|
missing = append(missing, "merchant")
|
|
}
|
|
if category == "" {
|
|
missing = append(missing, "category")
|
|
}
|
|
if date == "" {
|
|
missing = append(missing, "date")
|
|
}
|
|
if len(missing) > 0 {
|
|
log.Printf("ERROR [%s] handlers: SaveExpense: missing fields: %s",
|
|
time.Now().Format(time.RFC3339), strings.Join(missing, ", "))
|
|
http.Error(w, "Missing required fields: "+strings.Join(missing, ", "),
|
|
http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// 4. Parse amount as float64.
|
|
amount, err := strconv.ParseFloat(amountStr, 64)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: SaveExpense: invalid amount %q: %v",
|
|
time.Now().Format(time.RFC3339), amountStr, err)
|
|
http.Error(w, "Invalid amount value", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse converted amount (optional).
|
|
convertedAmount := 0.0
|
|
if convertedAmountStr != "" {
|
|
convertedAmount, _ = strconv.ParseFloat(convertedAmountStr, 64)
|
|
}
|
|
if baseCurrency == "" || baseCurrency == currency {
|
|
baseCurrency = currency
|
|
convertedAmount = amount
|
|
}
|
|
if convertedAmount <= 0 {
|
|
convertedAmount = amount
|
|
baseCurrency = currency
|
|
}
|
|
|
|
// 5. Build and save the expense record.
|
|
expense := database.Expense{
|
|
ID: utils.NewUUID(),
|
|
EventID: eventID,
|
|
Amount: amount,
|
|
Currency: currency,
|
|
ConvertedAmount: convertedAmount,
|
|
BaseCurrency: baseCurrency,
|
|
Merchant: merchant,
|
|
Category: category,
|
|
Description: description,
|
|
Date: date,
|
|
ImagePath: imagePath,
|
|
}
|
|
|
|
if err := database.CreateExpense(h.DB, expense); err != nil {
|
|
log.Printf("ERROR [%s] handlers: SaveExpense: create expense: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to save expense", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 6. Fetch the updated expense list for this event.
|
|
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: SaveExpense: fetch expenses: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to retrieve updated expenses", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 7. Render the expense_list.html fragment.
|
|
// Normalize ImagePath for old DB entries that may have storage/ prefix.
|
|
for i := range expenses {
|
|
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath)
|
|
}
|
|
listTmpl := getTemplate("expense_list.html")
|
|
|
|
var listBuf strings.Builder
|
|
if err := listTmpl.Execute(&listBuf, map[string]interface{}{
|
|
"Expenses": expenses,
|
|
}); err != nil {
|
|
log.Printf("ERROR [%s] handlers: SaveExpense: execute list template: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 8. Return HTMX multi-target response.
|
|
// - The receipt form is replaced with a success message (hx-swap-oob).
|
|
// - The expense list is replaced with the updated list (hx-swap-oob).
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"><div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">Expense saved successfully!</div></div>`)
|
|
fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, listBuf.String())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /expenses/{id}/edit — EditExpense
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// EditExpense returns the receipt form pre-filled with an existing expense's
|
|
// data, allowing the user to edit and re-save it.
|
|
func (h *ExpenseHandler) EditExpense(w http.ResponseWriter, r *http.Request) {
|
|
expenseID := chi.URLParam(r, "id")
|
|
if expenseID == "" {
|
|
http.Error(w, "Missing expense ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
expense, err := database.GetExpenseByID(h.DB, expenseID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: EditExpense: GetExpenseByID(%s): %v",
|
|
time.Now().Format(time.RFC3339), expenseID, err)
|
|
http.Error(w, "Failed to retrieve expense", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if expense == nil {
|
|
http.Error(w, "Expense not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Verify ownership: the expense's event must belong to the current user.
|
|
event, err := database.GetEventByID(h.DB, expense.EventID)
|
|
if err != nil || event == nil || event.UserID != getUserID(r) {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
tmpl := getTemplate("receipt_form.html")
|
|
|
|
data := map[string]interface{}{
|
|
"ImagePath": normalizeImagePath(expense.ImagePath),
|
|
"AIError": "",
|
|
"Amount": strconv.FormatFloat(expense.Amount, 'f', 2, 64),
|
|
"Currency": expense.Currency,
|
|
"Merchant": expense.Merchant,
|
|
"Category": expense.Category,
|
|
"Date": expense.Date,
|
|
"Description": expense.Description,
|
|
"BaseCurrency": expense.BaseCurrency,
|
|
"ExchangeRate": 0.0,
|
|
"ConvertedAmount": strconv.FormatFloat(expense.ConvertedAmount, 'f', 2, 64),
|
|
"EditID": expense.ID,
|
|
"ID": expense.ID,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmpl.Execute(w, data); err != nil {
|
|
log.Printf("ERROR [%s] handlers: EditExpense: execute template: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PUT /expenses/{id} — UpdateExpense
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// UpdateExpense updates an existing expense record with form data and returns
|
|
// the updated expense list via HTMX multi-target response.
|
|
func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
|
|
expenseID := chi.URLParam(r, "id")
|
|
if expenseID == "" {
|
|
http.Error(w, "Missing expense ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UpdateExpense: parse form: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
|
|
convertedAmount, _ := strconv.ParseFloat(r.FormValue("converted_amount"), 64)
|
|
baseCurrency := r.FormValue("base_currency")
|
|
if baseCurrency == "" {
|
|
baseCurrency = r.FormValue("currency")
|
|
convertedAmount = amount
|
|
}
|
|
|
|
// Fetch the existing expense to preserve the event_id and image_path.
|
|
existing, err := database.GetExpenseByID(h.DB, expenseID)
|
|
if err != nil || existing == nil {
|
|
log.Printf("ERROR [%s] handlers: UpdateExpense: get existing: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Expense not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Verify ownership: the expense's event must belong to the current user.
|
|
event, err := database.GetEventByID(h.DB, existing.EventID)
|
|
if err != nil || event == nil || event.UserID != getUserID(r) {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
expense := database.Expense{
|
|
ID: expenseID,
|
|
EventID: existing.EventID,
|
|
Amount: amount,
|
|
Currency: r.FormValue("currency"),
|
|
ConvertedAmount: convertedAmount,
|
|
BaseCurrency: baseCurrency,
|
|
Merchant: r.FormValue("merchant"),
|
|
Category: r.FormValue("category"),
|
|
Description: r.FormValue("description"),
|
|
Date: r.FormValue("date"),
|
|
ImagePath: existing.ImagePath,
|
|
}
|
|
|
|
if err := database.UpdateExpense(h.DB, expense); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UpdateExpense: %v", time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to update expense", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return updated expense list via HTMX.
|
|
expenses, err := database.GetExpensesByEvent(h.DB, existing.EventID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: UpdateExpense: fetch expenses: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to fetch expenses", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Normalize ImagePath for old DB entries.
|
|
for i := range expenses {
|
|
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath)
|
|
}
|
|
|
|
listTmpl := getTemplate("expense_list.html")
|
|
|
|
var listBuf strings.Builder
|
|
if err := listTmpl.Execute(&listBuf, map[string]interface{}{"Expenses": expenses}); err != nil {
|
|
log.Printf("ERROR [%s] handlers: UpdateExpense: execute template: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"><div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">Expense updated successfully!</div></div>`)
|
|
fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, listBuf.String())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DELETE /expenses/{id} — DeleteExpense
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// DeleteExpense removes an individual expense after verifying ownership via
|
|
// the expense's parent event. On success it returns the updated expense list
|
|
// fragment for HTMX replacement.
|
|
func (h *ExpenseHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
|
|
expenseID := chi.URLParam(r, "id")
|
|
if expenseID == "" {
|
|
http.Error(w, "Missing expense ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Fetch the existing expense to get its event_id.
|
|
existing, err := database.GetExpenseByID(h.DB, expenseID)
|
|
if err != nil || existing == nil {
|
|
log.Printf("ERROR [%s] handlers: DeleteExpense: get existing(%s): %v",
|
|
time.Now().Format(time.RFC3339), expenseID, err)
|
|
http.Error(w, "Expense not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Verify ownership: the expense's event must belong to the current user.
|
|
event, err := database.GetEventByID(h.DB, existing.EventID)
|
|
if err != nil || event == nil || event.UserID != getUserID(r) {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
eventID := existing.EventID
|
|
|
|
// Delete the expense from the database.
|
|
if err := database.DeleteExpense(h.DB, expenseID); err != nil {
|
|
log.Printf("ERROR [%s] handlers: DeleteExpense: %v", time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to delete expense", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Fetch the updated expense list for this event.
|
|
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
|
|
if err != nil {
|
|
log.Printf("ERROR [%s] handlers: DeleteExpense: fetch expenses: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Failed to fetch expenses", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Normalize ImagePath for old DB entries.
|
|
for i := range expenses {
|
|
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath)
|
|
}
|
|
|
|
listTmpl := getTemplate("expense_list.html")
|
|
|
|
var listBuf strings.Builder
|
|
if err := listTmpl.Execute(&listBuf, map[string]interface{}{"Expenses": expenses}); err != nil {
|
|
log.Printf("ERROR [%s] handlers: DeleteExpense: execute template: %v",
|
|
time.Now().Format(time.RFC3339), err)
|
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Return updated expense list + clear the receipt form (edit form may be open).
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"></div>`)
|
|
fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, listBuf.String())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cookie helpers (shared with events.go via package-level access)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// getCurrentEventID reads the "current_event_id" cookie from the request.
|
|
// Returns an empty string if the cookie is not set or cannot be read.
|
|
func getCurrentEventID(r *http.Request) string {
|
|
cookie, err := r.Cookie("current_event_id")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
// Validate UUID format to prevent cookie tampering.
|
|
if !uuidRe.MatchString(cookie.Value) {
|
|
return ""
|
|
}
|
|
return cookie.Value
|
|
}
|
|
|
|
// setCurrentEventID sets the "current_event_id" cookie on the response.
|
|
// The cookie has a 24-hour lifetime and is HttpOnly with SameSite=Lax.
|
|
// This helper is called from events.go when viewing an event.
|
|
func setCurrentEventID(w http.ResponseWriter, eventID string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "current_event_id",
|
|
Value: eventID,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: 86400, // 24 hours
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// renderUploadError writes an HTMX-compatible error fragment into the
|
|
// #receipt-form container (the upload target). Uses HTTP 200 so HTMX
|
|
// always swaps the content (HTMX skips 4xx/5xx by default).
|
|
func renderUploadError(w http.ResponseWriter, message string) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `<div id="receipt-form"><div class="error-message" style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div></div>`,
|
|
template.HTMLEscapeString(message))
|
|
}
|
|
|
|
// detectImageExtension examines the magic bytes of the provided data to
|
|
// determine its image format. Supports JPEG, PNG, WebP, GIF, BMP, TIFF,
|
|
// and HEIC/HEIF (common on iPhones). Returns the file extension (without
|
|
// dot) or an empty string if the format is not recognised.
|
|
func detectImageExtension(data []byte) string {
|
|
if len(data) < 4 {
|
|
return ""
|
|
}
|
|
|
|
// JPEG: FF D8 FF
|
|
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
|
return "jpg"
|
|
}
|
|
|
|
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E &&
|
|
data[3] == 0x47 && data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A {
|
|
return "png"
|
|
}
|
|
|
|
// WebP: 52 49 46 46 .... 57 45 42 50
|
|
if len(data) >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 &&
|
|
data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
|
|
return "webp"
|
|
}
|
|
|
|
// GIF: 47 49 46 38 (39 61 or 37 61)
|
|
if len(data) >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
|
|
data[3] == 0x38 && (data[4] == 0x39 || data[4] == 0x37) && data[5] == 0x61 {
|
|
return "gif"
|
|
}
|
|
|
|
// BMP: 42 4D
|
|
if data[0] == 0x42 && data[1] == 0x4D {
|
|
return "bmp"
|
|
}
|
|
|
|
// TIFF: 49 49 2A 00 or 4D 4D 00 2A
|
|
if (data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00) ||
|
|
(data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) {
|
|
return "tiff"
|
|
}
|
|
|
|
// PDF: 25 50 44 46 (%PDF)
|
|
if len(data) >= 4 && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 {
|
|
return "pdf"
|
|
}
|
|
|
|
// HEIC/HEIF/AVIF: .... 66 74 79 70 ... (ftyp box)
|
|
// The ftyp box starts at offset 4 with brand at offset 8.
|
|
if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 {
|
|
brand := string(data[8:12])
|
|
switch brand {
|
|
case "heic", "heix", "hevc", "hevx", "mif1", "msf1":
|
|
return "heic"
|
|
case "avif":
|
|
return "avif"
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Image processing helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// resizeImage resizes image data to a maximum of 2048 pixels on the longest
|
|
// side while maintaining aspect ratio. Output is always JPEG at 85% quality.
|
|
// Returns the original data unchanged if the image is already smaller, or if
|
|
// decoding/resizing fails (e.g. unsupported format like HEIC).
|
|
func resizeImage(data []byte) ([]byte, error) {
|
|
img, _, err := image.Decode(bytes.NewReader(data))
|
|
if err != nil {
|
|
return data, err
|
|
}
|
|
|
|
bounds := img.Bounds()
|
|
w, h := bounds.Dx(), bounds.Dy()
|
|
|
|
const maxDim = 2048
|
|
if w <= maxDim && h <= maxDim {
|
|
return data, nil // already small enough
|
|
}
|
|
|
|
// Maintain aspect ratio.
|
|
var newW, newH int
|
|
if w > h {
|
|
newW = maxDim
|
|
newH = h * maxDim / w
|
|
} else {
|
|
newH = maxDim
|
|
newW = w * maxDim / h
|
|
}
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
|
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
|
|
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil {
|
|
return data, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|