diff --git a/backend/internal/web/handlers_files.go b/backend/internal/web/handlers_files.go index 8bce45e..5834d22 100644 --- a/backend/internal/web/handlers_files.go +++ b/backend/internal/web/handlers_files.go @@ -242,25 +242,91 @@ func itoa(n int) string { } // FileDownloadHandler handles GET /tablos/{id}/files/{file_id}/download. -// Stub — returns 501 until Plan 03. +// Generates a 5-minute presigned URL and returns a 302 redirect to it (FILE-04). +// +// Security invariants: +// - deps.Files nil guard is the FIRST statement (T-05-03-06) +// - Ownership enforced by loadOwnedTabloForFile (T-05-03-02, FILE-06) +// - Presigned URL has 5-minute TTL set inside files.Store.PresignDownload (T-05-03-01) func FileDownloadHandler(deps FilesDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "not implemented", http.StatusNotImplemented) + if deps.Files == nil { + http.Error(w, "storage not configured", http.StatusServiceUnavailable) + return + } + tablo, file, _, ok := loadOwnedTabloForFile(w, r, deps) + if !ok { + return + } + _ = tablo + url, err := deps.Files.PresignDownload(r.Context(), file.S3Key) + if err != nil { + slog.Default().Error("files download: PresignDownload failed", "file_id", file.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, url, http.StatusFound) } } // FileDeleteConfirmHandler handles GET /tablos/{id}/files/{file_id}/delete-confirm. -// Stub — returns 501 until Plan 03. +// Renders an inline deletion confirmation fragment (same HTMX pattern as task delete). +// +// Security invariants: +// - deps.Files nil guard is the FIRST statement (T-05-03-06) +// - Ownership enforced by loadOwnedTabloForFile (T-05-03-02, FILE-06) func FileDeleteConfirmHandler(deps FilesDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "not implemented", http.StatusNotImplemented) + if deps.Files == nil { + http.Error(w, "storage not configured", http.StatusServiceUnavailable) + return + } + tablo, file, _, ok := loadOwnedTabloForFile(w, r, deps) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.FileDeleteConfirmFragment(tablo.ID, file, csrf.Token(r)).Render(r.Context(), w) } } // FileDeleteHandler handles POST /tablos/{id}/files/{file_id}/delete. -// Stub — returns 501 until Plan 03. +// Deletes S3 object first (logs failure but continues), then removes DB row (FILE-05). +// +// Security invariants: +// - deps.Files nil guard is the FIRST statement (T-05-03-06) +// - Ownership enforced by loadOwnedTabloForFile (T-05-03-03, FILE-06) +// - CSRF enforced by gorilla/csrf middleware on the router (T-05-03-05) +// - S3 failure is logged but does NOT abort DB delete — orphan objects are Phase 6 worker concern (T-05-03-04) func FileDeleteHandler(deps FilesDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "not implemented", http.StatusNotImplemented) + if deps.Files == nil { + http.Error(w, "storage not configured", http.StatusServiceUnavailable) + return + } + tablo, file, _, ok := loadOwnedTabloForFile(w, r, deps) + if !ok { + return + } + // Step 1: Delete from S3. Log failure but always continue to DB delete. + if err := deps.Files.Delete(r.Context(), file.S3Key); err != nil { + slog.Default().Error("files delete: S3 Delete failed", "key", file.S3Key, "err", err) + // Do NOT return — orphan S3 objects are reconciled by Phase 6 worker. + } + // Step 2: Delete DB row. This is the authoritative state. + if err := deps.Queries.DeleteTabloFile(r.Context(), sqlc.DeleteTabloFileParams{ + ID: file.ID, + TabloID: tablo.ID, + }); err != nil { + slog.Default().Error("files delete: DeleteTabloFile failed", "id", file.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.FileRowGone(file.ID).Render(r.Context(), w) + return + } + http.Redirect(w, r, "/tablos/"+tablo.ID.String()+"/files", http.StatusSeeOther) } } diff --git a/backend/templates/files.templ b/backend/templates/files.templ index d35c8d6..9bc02bb 100644 --- a/backend/templates/files.templ +++ b/backend/templates/files.templ @@ -98,6 +98,53 @@ templ FileListRow(tabloID uuid.UUID, file sqlc.TabloFile) { } +// FileDeleteConfirmFragment renders the inline delete confirmation for a single file row. +// Mirrors TaskDeleteConfirmFragment exactly: outer wrapper reuses the .file-row-zone id +// so HTMX outerHTML swap replaces the row in place (T-05-03-05, FILE-05). +templ FileDeleteConfirmFragment(tabloID uuid.UUID, file sqlc.TabloFile, csrfToken string) { +
+
+

Delete file?

+

{ file.Filename }

+

This cannot be undone.

+
+
+ @ui.CSRFField(csrfToken) + @ui.Button(ui.ButtonProps{ + Label: "Yes, delete", + Variant: ui.ButtonVariantDanger, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", + Attrs: templ.Attributes{ + "aria-label": "Confirm delete file", + }, + }) +
+ @ui.Button(ui.ButtonProps{ + Label: "Keep file", + Variant: ui.ButtonVariantNeutral, + Tone: ui.ButtonToneSoft, + Size: ui.SizeMD, + Type: "button", + Attrs: templ.Attributes{ + "hx-get": "/tablos/" + tabloID.String() + "/files", + "hx-target": "#tab-content", + "hx-swap": "innerHTML", + "aria-label": "Keep file", + }, + }) +
+
+
+} + // FileListEmpty renders the empty-state message when no files are attached. templ FileListEmpty() {

No files attached yet.