---
phase: 05-files
plan: 03
type: execute
wave: 3
depends_on:
  - "05-02"
files_modified:
  - backend/internal/web/handlers_files.go
  - backend/internal/web/handlers_files_test.go
  - backend/templates/files.templ
  - backend/templates/files_templ.go
autonomous: true
requirements:
  - FILE-04
  - FILE-05
  - FILE-06

must_haves:
  truths:
    - "GET /tablos/{id}/files/{file_id}/download generates a 5-minute presigned URL and returns a 302 redirect to it"
    - "GET /tablos/{id}/files/{file_id}/delete-confirm renders an inline confirmation (same pattern as task delete)"
    - "POST /tablos/{id}/files/{file_id}/delete removes the S3 object (logs failure) then removes the DB row; file disappears from the list"
    - "Non-owner gets 404 on download, delete-confirm, and delete routes"
    - "TestFileDownload, TestFileDelete, TestFileOwnership pass with stubbed FileStorer"
  artifacts:
    - path: "backend/internal/web/handlers_files.go"
      provides: "FileDownloadHandler, FileDeleteConfirmHandler, FileDeleteHandler (replaces 501 stubs)"
      exports: ["FileDownloadHandler", "FileDeleteConfirmHandler", "FileDeleteHandler"]
    - path: "backend/templates/files.templ"
      provides: "FileDeleteConfirmFragment templ component"
      contains: "FileDeleteConfirmFragment"
  key_links:
    - from: "backend/internal/web/handlers_files.go FileDownloadHandler"
      to: "backend/internal/files/store.go PresignDownload"
      via: "deps.Files.PresignDownload(ctx, file.S3Key)"
      pattern: "deps\\.Files\\.PresignDownload"
    - from: "backend/internal/web/handlers_files.go FileDeleteHandler"
      to: "backend/internal/files/store.go Delete"
      via: "deps.Files.Delete(ctx, file.S3Key) — log error, always delete DB row"
      pattern: "deps\\.Files\\.Delete"
    - from: "backend/templates/files.templ FileListRow"
      to: "GET /tablos/{id}/files/{file_id}/delete-confirm"
      via: "hx-get on delete button in FileListRow component"
      pattern: "delete-confirm"
---

<objective>
Vertical slice 2: download and delete. After this plan, the file list has a working download link (302 to presigned S3 URL with 5-minute TTL) and an inline delete confirmation (same pattern as Phase 3 tablo delete). FileDeleteHandler deletes S3 first, logs failures, always deletes DB row. All six FILE requirements are now implemented.

Purpose: Closes FILE-04 (signed download URL), FILE-05 (delete removes DB row + S3 object), and FILE-06 (ownership on download/delete routes).
Output: Three handler functions replacing the 501 stubs from Plan 02, one new templ component (FileDeleteConfirmFragment), activated test assertions for TestFileDownload + TestFileDelete + TestFileOwnership.
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/PROJECT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-CONTEXT.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-RESEARCH.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-PATTERNS.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-02-SUMMARY.md

<interfaces>
<!-- Extracted from Plan 02 outputs. -->

From backend/internal/web/handlers_files.go (after Plan 02):
  type FilesDeps struct {
    Queries     *sqlc.Queries
    Files       FileStorer
    MaxUploadMB int
  }
  func loadOwnedTabloForFile(w, r, deps FilesDeps) (sqlc.Tablo, sqlc.TabloFile, *auth.User, bool)
  // Stub handlers replaced in this plan:
  func FileDownloadHandler(deps FilesDeps) http.HandlerFunc  // currently returns 501
  func FileDeleteConfirmHandler(deps FilesDeps) http.HandlerFunc  // currently returns 501
  func FileDeleteHandler(deps FilesDeps) http.HandlerFunc  // currently returns 501

From backend/internal/files/store.go (FileStorer interface from Plan 01):
  type FileStorer interface {
    Upload(ctx context.Context, key string, file io.Reader) (contentType string, bytesWritten int64, err error)
    Delete(ctx context.Context, key string) error
    PresignDownload(ctx context.Context, key string) (string, error)
  }

From backend/internal/db/sqlc/files.sql.go (from Plan 01 generation):
  type GetTabloFileByIDParams struct { ID pgtype.UUID; TabloID pgtype.UUID }
  type DeleteTabloFileParams struct { ID pgtype.UUID; TabloID pgtype.UUID }
  func (q *Queries) GetTabloFileByID(ctx, arg GetTabloFileByIDParams) (TabloFile, error)
  func (q *Queries) DeleteTabloFile(ctx, arg DeleteTabloFileParams) error

From backend/templates/tasks.templ (TaskDeleteConfirmFragment pattern — exact template to mirror):
  templ TaskDeleteConfirmFragment(tabloID uuid.UUID, task sqlc.Task, csrfToken string) {
    <div class="task-card-zone" id={"task-"+task.ID.String()}>
      <form method="POST" action={templ.SafeURL("...")} hx-post={...} hx-target="closest .task-card-zone" hx-swap="outerHTML">
        @ui.CSRFField(csrfToken)
        @ui.Button(ui.ButtonProps{Label:"Yes, delete", Variant: ui.ButtonVariantDanger, ...})
      </form>
      // cancel button that re-fetches the display fragment
    </div>
  }

From backend/internal/web/handlers_tasks.go (delete handler pattern lines 285-309):
  func TaskDeleteHandler(deps TasksDeps) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
      tablo, task, _, ok := loadOwnedTabloForTask(w, r, deps)
      if !ok { return }
      if err := deps.Queries.DeleteTask(r.Context(), ...); err != nil {
        slog.Default().Error("tasks delete: DeleteTask failed", "id", task.ID, "err", err)
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
      }
      if r.Header.Get("HX-Request") == "true" {
        _ = templates.TaskCardGone(task.ID).Render(r.Context(), w)
        return
      }
      http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther)
    }
  }
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: FileDownloadHandler + FileDeleteConfirmHandler + FileDeleteHandler (replace 501 stubs)</name>
  <files>
    backend/internal/web/handlers_files.go
    backend/internal/web/handlers_files_test.go
  </files>
  <read_first>
    backend/internal/web/handlers_files.go — read fully; locate the three 501 stub functions to replace; confirm loadOwnedTabloForFile helper is present from Plan 02
    backend/internal/web/handlers_tasks.go — lines 285-320 — TaskDeleteHandler and TaskDeleteConfirmHandler exact patterns
    backend/internal/db/sqlc/files.sql.go — GetTabloFileByID and DeleteTabloFile method signatures and param types
    .planning/phases/05-files/05-RESEARCH.md §Pattern 3 (PresignDownload), §Pattern 6 (FileDeleteHandler delete S3 first + always delete DB row)
    .planning/phases/05-files/05-PATTERNS.md §handlers_files.go §FileDeleteHandler deviation paragraph
  </read_first>
  <behavior>
    - TestFileDownload: GET /tablos/{id}/files/{file_id}/download by owner → stubbed PresignDownload returns "https://example.com/signed?key=foo"; response is 302 with Location header == "https://example.com/signed?key=foo"
    - TestFileDownload_NonOwner: non-owner (different user) GET /tablos/{other_id}/files/{file_id}/download → 404
    - TestFileDelete: POST /tablos/{id}/files/{file_id}/delete by owner with HTMX → 200 + response body contains file-row-zone id div (FileRowGone); stubbed Delete records the S3Key call; DB row for that file no longer exists
    - TestFileDelete_S3Failure: POST /tablos/{id}/files/{file_id}/delete where stub Delete returns error → DB row still deleted (S3 failure does not abort DB delete); response is 200 (not 500) for HTMX path
    - TestFileOwnership (full): complete the skipped assertions — non-owner on download + delete routes → 404; non-owner on delete-confirm → 404
  </behavior>
  <action>
    Replace FileDownloadHandler stub (currently returns 501) with full implementation:
      0. if deps.Files == nil { http.Error(w, "storage not configured", http.StatusServiceUnavailable); return } — MUST be the very first statement
      1. tablo, file, _, ok := loadOwnedTabloForFile(w, r, deps) — 404 on failure (FILE-06)
      2. url, err := deps.Files.PresignDownload(r.Context(), file.S3Key) — 500 on error; log with slog.Default().Error("files download: PresignDownload failed", "file_id", file.ID, "err", err)
      3. http.Redirect(w, r, url, http.StatusFound) — 302 redirect to signed URL (per CONTEXT.md discretion item: download redirects to signed URL)

    Replace FileDeleteConfirmHandler stub with implementation (inline confirm pattern from Phase 3):
      0. if deps.Files == nil { http.Error(w, "storage not configured", http.StatusServiceUnavailable); return } — MUST be the very first statement
      1. tablo, file, _, ok := loadOwnedTabloForFile(w, r, deps) — 404 on failure
      2. Set Content-Type: text/html; charset=utf-8
      3. Render templates.FileDeleteConfirmFragment(tablo.ID, file, csrf.Token(r)) — new component created in Task 2

    Replace FileDeleteHandler stub with implementation (S3-first, always-delete-DB per RESEARCH.md Pattern 6):
      0. if deps.Files == nil { http.Error(w, "storage not configured", http.StatusServiceUnavailable); return } — MUST be the very first statement
      1. tablo, file, _, ok := loadOwnedTabloForFile(w, r, deps) — 404 on failure
      2. Delete from S3: if err := deps.Files.Delete(r.Context(), file.S3Key); err != nil: slog.Default().Error("files delete: S3 Delete failed", "key", file.S3Key, "err", err) — log but do NOT return; continue to DB delete (orphan S3 objects are Phase 6 worker's problem per CONTEXT.md deferred items)
      3. if err := deps.Queries.DeleteTabloFile(r.Context(), sqlc.DeleteTabloFileParams{ID: file.ID, TabloID: tablo.ID}); err != nil: log + http.Error 500 + return
      4. HTMX path (HX-Request == "true"): w.Header().Set("Content-Type", "text/html; charset=utf-8"); render templates.FileRowGone(file.ID)
      5. Non-HTMX path: http.Redirect to "/tablos/"+tablo.ID.String()+"/files" with 303

    Un-skip TestFileDownload, TestFileDelete, TestFileDelete_S3Failure, TestFileOwnership in handlers_files_test.go. Implement the test bodies per the <behavior> block above using the stubbedFileStorer declared in Plan 01's test file (or adjust its methods to support tracking calls and returning errors). Add a stubbedFileStorer field "deleteErr error" so TestFileDelete_S3Failure can inject an error.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web/ -run "TestFileDownload|TestFileDelete|TestFileOwnership" -v -count=1 2>&1 | tail -30</automated>
  </verify>
  <done>
    TestFileDownload, TestFileDelete, TestFileOwnership all PASS. FileDownloadHandler returns 302. FileDeleteHandler deletes DB row even when S3 delete fails.
  </done>
  <acceptance_criteria>
    - go test ./internal/web/ -run "TestFileDownload|TestFileDelete|TestFileOwnership" exits 0 with "--- PASS" for each test (no SKIP, no FAIL)
    - backend/internal/web/handlers_files.go FileDownloadHandler first statement is "deps.Files == nil" nil guard returning 503
    - backend/internal/web/handlers_files.go FileDeleteConfirmHandler first statement is "deps.Files == nil" nil guard returning 503
    - backend/internal/web/handlers_files.go FileDeleteHandler first statement is "deps.Files == nil" nil guard returning 503
    - backend/internal/web/handlers_files.go FileDownloadHandler contains "http.StatusFound" (302 redirect)
    - backend/internal/web/handlers_files.go FileDeleteHandler contains "deps.Files.Delete" on a line before "deps.Queries.DeleteTabloFile" (S3 first per RESEARCH Pattern 6)
    - backend/internal/web/handlers_files.go FileDeleteHandler: the slog.Error for S3 failure does NOT have an early return; DB delete is always attempted
    - go test ./internal/web/ -run TestFile -v exits 0 (all six TestFile* tests PASS)
  </acceptance_criteria>
</task>

<task type="auto">
  <name>Task 2: FileDeleteConfirmFragment templ component + FileListRow download/delete links + full suite green</name>
  <files>
    backend/templates/files.templ
    backend/templates/files_templ.go
  </files>
  <read_first>
    backend/templates/files.templ — read fully (from Plan 02 output); identify FileListRow component; the download and delete-confirm links were left as placeholders pointing to the routes wired in Plan 02 stubs
    backend/templates/tasks.templ — lines 285-345 — TaskDeleteConfirmFragment exact structure (wrapper div with zone id, confirm form, cancel link)
    backend/internal/web/handlers_files.go — confirm exact URL paths: /tablos/{id}/files/{file_id}/download and /tablos/{id}/files/{file_id}/delete-confirm and /tablos/{id}/files/{file_id}/delete
    .planning/phases/05-files/05-PATTERNS.md §files.templ — TaskDeleteConfirmFragment analog, OOB removal pattern
  </read_first>
  <action>
    Step 1 — Add FileDeleteConfirmFragment to files.templ. Signature:
      templ FileDeleteConfirmFragment(tabloID uuid.UUID, file sqlc.TabloFile, csrfToken string)
    Structure mirrors TaskDeleteConfirmFragment exactly:
      Outer div with class="file-row-zone" and id={"file-"+file.ID.String()} — same zone ID as FileListRow uses for hx-target
      Inner: confirm message ("Delete file?"), filename display, "This cannot be undone." warning
      Confirm form: method="POST", action=templ.SafeURL("/tablos/"+tabloID.String()+"/files/"+file.ID.String()+"/delete"), hx-post same, hx-target="closest .file-row-zone", hx-swap="outerHTML"; @ui.CSRFField(csrfToken); @ui.Button Yes-delete with ButtonVariantDanger ButtonToneSolid
      Cancel: an anchor or button with hx-get="/tablos/"+tabloID.String()+"/files/"+file.ID.String() (if a show-file route exists) or — simpler — hx-get="/tablos/"+tabloID.String()+"/files" with hx-target="#tab-content" to reload the whole files tab and dismiss confirm; use @ui.Button Neutral Soft "Keep file"

    Step 2 — Ensure FileListRow (from Plan 02) has the download and delete-confirm links wired to real URLs. Verify:
      Download link: anchor href="/tablos/"+tabloID.String()+"/files/"+file.ID.String()+"/download" — simple anchor, no HTMX (302 redirect is handled by browser directly)
      Delete confirm button: hx-get="/tablos/"+tabloID.String()+"/files/"+file.ID.String()+"/delete-confirm", hx-target="closest .file-row-zone", hx-swap="outerHTML" — replaces the row with confirmation inline

    Step 3 — Run templ generate: cd backend && templ generate. Verify files_templ.go and tablos_templ.go regenerated without errors.

    Step 4 — Run full test suite: cd backend && go test ./... -count=1 -timeout 60s. All tests must PASS. Fix any compile errors from templ regeneration or signature mismatches.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1 -timeout 60s 2>&1 | tail -20</automated>
  </verify>
  <done>
    go test ./... exits 0. All TestFile* tests PASS. FileDeleteConfirmFragment component exists in files.templ. FileListRow download and delete links point to real routes. templ generate produces no errors.
  </done>
  <acceptance_criteria>
    - go test ./... exits 0 with 0 FAIL across all packages
    - backend/templates/files.templ contains "FileDeleteConfirmFragment" with "file-row-zone" class and "ButtonVariantDanger" and "templ.SafeURL" for delete action
    - backend/templates/files.templ FileListRow contains "/download" in a link href and "/delete-confirm" in hx-get attribute
    - go test ./internal/web/ -run TestFile -v shows "--- PASS" for all six TestFile* tests
    - templ generate produces no errors (files_templ.go timestamp updated)
  </acceptance_criteria>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| FileDownloadHandler → S3 presigned URL | URL is generated server-side and not stored; TTL is 5 minutes |
| FileDeleteHandler → dual-system delete | S3 and Postgres are separate; partial failure leaves orphan objects |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-03-01 | Information Disclosure | FileDownloadHandler — presigned URL exposure | mitigate | TTL set to 5 minutes via s3.PresignOptions{Expires: 5 * time.Minute} (RESEARCH Pattern 3); URL not stored in DB; generated per-request |
| T-05-03-02 | Elevation of Privilege | FileDownloadHandler — IDOR | mitigate | loadOwnedTabloForFile verifies tablo ownership via user_id filter in GetTabloByID query; GetTabloFileByID also includes tablo_id = $2 filter — two-layer ownership check |
| T-05-03-03 | Elevation of Privilege | FileDeleteHandler — IDOR | mitigate | Same loadOwnedTabloForFile preamble; DeleteTabloFile parameterized with both file ID AND tablo_id — non-owner cannot delete even with a valid file UUID |
| T-05-03-04 | Denial of Service | FileDeleteHandler — partial S3 failure loop | accept | S3 delete failure is logged but does not abort; DB row is always deleted; orphan objects accumulate and are cleaned by Phase 6 worker (explicit design decision from CONTEXT.md deferred items) |
| T-05-03-05 | Tampering | FileDeleteConfirmFragment — CSRF on delete form | mitigate | @ui.CSRFField(csrfToken) present in FileDeleteConfirmFragment form; gorilla/csrf middleware rejects POST /files/{id}/delete without valid token |
| T-05-03-06 | Denial of Service | FileDownloadHandler + FileDeleteConfirmHandler + FileDeleteHandler — nil FileStorer | mitigate | deps.Files == nil guard is the FIRST statement in all three handlers; returns 503 "storage not configured" before any S3 or DB call |
</threat_model>

<verification>
After both tasks:
- cd backend && go test ./... -count=1 -timeout 60s exits 0 (all TestFile* PASS, all TestTask* and TestTablo* still PASS)
- cd backend && go test ./internal/web/ -run TestFile -v shows 6x "--- PASS"
- backend/internal/web/handlers_files.go contains no "501" (all stubs replaced)
- backend/internal/web/handlers_files.go FileDownloadHandler, FileDeleteConfirmHandler, FileDeleteHandler each have "deps.Files == nil" as the first guard
- backend/templates/files.templ contains FileDeleteConfirmFragment with ui.CSRFField
</verification>

<success_criteria>
1. All six TestFile* tests PASS (FILE-01..06 covered)
2. File download returns 302 to presigned URL with 5-minute TTL
3. File delete removes DB row even when S3 delete fails; S3 failure is logged
4. Delete confirmation uses inline pattern (same as Phase 3/4) with Cancel option
5. Full test suite (go test ./...) exits 0 with zero failures
</success_criteria>

<output>
After completion, create .planning/phases/05-files/05-03-SUMMARY.md
</output>
