diff --git a/internal/database/db.go b/internal/database/db.go
index d4d9f40..ea93f67 100644
--- a/internal/database/db.go
+++ b/internal/database/db.go
@@ -411,3 +411,13 @@ func UpdateExpense(db *sql.DB, expense Expense) error {
}
return err
}
+
+// DeleteExpense removes a single expense by its ID.
+func DeleteExpense(db *sql.DB, id string) error {
+ _, err := db.Exec("DELETE FROM expenses WHERE id = ?", id)
+ if err != nil {
+ log.Printf("ERROR [%s] database: DeleteExpense(%s): %v",
+ time.Now().Format(time.RFC3339), id, err)
+ }
+ return err
+}
diff --git a/internal/handlers/expenses.go b/internal/handlers/expenses.go
index 8b2bba6..8b9d165 100644
--- a/internal/handlers/expenses.go
+++ b/internal/handlers/expenses.go
@@ -481,6 +481,75 @@ func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `
%s
`, 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, ``)
+ fmt.Fprintf(w, `%s
`, listBuf.String())
+}
+
// ---------------------------------------------------------------------------
// Cookie helpers (shared with events.go via package-level access)
// ---------------------------------------------------------------------------
diff --git a/internal/handlers/file.go b/internal/handlers/file.go
index 822f49b..85de176 100644
--- a/internal/handlers/file.go
+++ b/internal/handlers/file.go
@@ -376,19 +376,17 @@ func createReceiptZip(eventName string, expenses []database.Expense) (*email.Att
continue
}
- // Prevent path traversal — only allow files within the storage directory.
- cleanPath := filepath.Clean(exp.ImagePath)
- if !strings.HasPrefix(cleanPath, "storage") && !strings.HasPrefix(cleanPath, "./storage") {
- log.Printf("WARN [%s] handlers: createReceiptZip: blocked path traversal attempt: %q",
- time.Now().Format(time.RFC3339), 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(cleanPath)
+ data, err := os.ReadFile(safePath)
if err != nil {
log.Printf("WARN [%s] handlers: createReceiptZip: reading %q: %v",
- time.Now().Format(time.RFC3339), cleanPath, err)
+ time.Now().Format(time.RFC3339), safePath, err)
continue
}
diff --git a/main.go b/main.go
index 5eb8b75..78e388d 100644
--- a/main.go
+++ b/main.go
@@ -216,6 +216,7 @@ func main() {
r.Post("/expenses", expenseHandler.SaveExpense)
r.Get("/expenses/{id}/edit", expenseHandler.EditExpense)
r.Put("/expenses/{id}", expenseHandler.UpdateExpense)
+ r.Delete("/expenses/{id}", expenseHandler.DeleteExpense)
// Filing.
r.Post("/events/{id}/file", fileHandler.FileEvent)
diff --git a/templates/event_expenses.html b/templates/event_expenses.html
index b59b0c3..b25bea8 100644
--- a/templates/event_expenses.html
+++ b/templates/event_expenses.html
@@ -66,6 +66,10 @@
hx-target="#receipt-form"
hx-swap="innerHTML"
title="Edit receipt">✏️
+
+
{{if .ImagePath}}
diff --git a/templates/expense_list.html b/templates/expense_list.html
index 7c14866..5a7fcbc 100644
--- a/templates/expense_list.html
+++ b/templates/expense_list.html
@@ -43,6 +43,12 @@
title="Edit expense">
✏️
+
+
{{if .ImagePath}}