fix: receipt images missing from filed event ZIP + feat: delete individual expenses
- Fix createReceiptZip path traversal guard blocking new ImagePath entries
(UploadReceipt stores bare filenames, guard required 'storage/' prefix)
- Add DeleteExpense DB function, handler with ownership verification,
DELETE /expenses/{id} route, and 🗑️ button in both expense templates
- Uses existing htmx.trigger confirm pattern for delete UX
This commit is contained in:
parent
09b2ddfc79
commit
90c9df6cce
6 changed files with 97 additions and 9 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -481,6 +481,75 @@ func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
|
|||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
1
main.go
1
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)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@
|
|||
hx-target="#receipt-form"
|
||||
hx-swap="innerHTML"
|
||||
title="Edit receipt">✏️</button>
|
||||
<button class="btn btn-sm" style="background: none; border: 1px solid #7f1d1d; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: #fca5a5;"
|
||||
onclick="if(confirm('Delete this receipt?')) htmx.trigger('#del-{{.ID}}','click')"
|
||||
title="Delete receipt">🗑️</button>
|
||||
<div hx-delete="/expenses/{{.ID}}" hx-target="#expense-list" hx-swap="outerHTML" id="del-{{.ID}}" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .ImagePath}}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@
|
|||
title="Edit expense">
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn btn-sm" style="background: none; border: 1px solid #7f1d1d; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; color: #fca5a5;"
|
||||
onclick="if(confirm('Delete this expense?')) htmx.trigger('#del-{{.ID}}','click')"
|
||||
title="Delete expense">
|
||||
🗑️
|
||||
</button>
|
||||
<div hx-delete="/expenses/{{.ID}}" hx-target="#expense-list" hx-swap="outerHTML" id="del-{{.ID}}" style="display:none"></div>
|
||||
</div>
|
||||
{{if .ImagePath}}
|
||||
<div id="img-{{.ID}}" class="hidden" style="position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 999; align-items: center; justify-content: center; cursor: pointer; padding: 1rem;"
|
||||
|
|
|
|||
Loading…
Reference in a new issue