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.
|
// 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 {
|
func FileDownloadHandler(deps FilesDeps) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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.
|
// 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 {
|
func FileDeleteConfirmHandler(deps FilesDeps) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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.
|
// 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 {
|
func FileDeleteHandler(deps FilesDeps) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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>
|
</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.
|
// FileListEmpty renders the empty-state message when no files are attached.
|
||||||
templ FileListEmpty() {
|
templ FileListEmpty() {
|
||||||
<p class="text-sm text-slate-400 italic py-4">No files attached yet.</p>
|
<p class="text-sm text-slate-400 italic py-4">No files attached yet.</p>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue