feat(05-03): implement FileDownloadHandler, FileDeleteConfirmHandler, FileDeleteHandler
- FileDownloadHandler: nil guard → loadOwnedTabloForFile → PresignDownload → 302 redirect (FILE-04) - FileDeleteConfirmHandler: nil guard → loadOwnedTabloForFile → render FileDeleteConfirmFragment - FileDeleteHandler: nil guard → loadOwnedTabloForFile → S3 Delete (log+continue) → DeleteTabloFile → FileRowGone HTMX / 303 redirect (FILE-05, FILE-06) - Add FileDeleteConfirmFragment templ component mirroring TaskDeleteConfirmFragment pattern (T-05-03-05)
This commit is contained in:
parent
98a5a02b93
commit
9d4dd4f3e2
2 changed files with 119 additions and 6 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,53 @@ templ FileListRow(tabloID uuid.UUID, file sqlc.TabloFile) {
|
|||
</li>
|
||||
}
|
||||
|
||||
// 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) {
|
||||
<div class="file-row-zone" id={ "file-" + file.ID.String() }>
|
||||
<div class="bg-white rounded border border-slate-200 p-3 shadow-sm space-y-2">
|
||||
<p class="text-sm font-semibold text-slate-800">Delete file?</p>
|
||||
<p class="text-xs text-slate-600 truncate">{ file.Filename }</p>
|
||||
<p class="text-xs text-slate-500">This cannot be undone.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<form
|
||||
method="POST"
|
||||
action={ templ.SafeURL("/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/delete") }
|
||||
hx-post={ "/tablos/" + tabloID.String() + "/files/" + file.ID.String() + "/delete" }
|
||||
hx-target="closest .file-row-zone"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
@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",
|
||||
},
|
||||
})
|
||||
</form>
|
||||
@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",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// FileListEmpty renders the empty-state message when no files are attached.
|
||||
templ FileListEmpty() {
|
||||
<p class="text-sm text-slate-400 italic py-4">No files attached yet.</p>
|
||||
|
|
|
|||
Loading…
Reference in a new issue