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