---
phase: 05-files
plan: 02
type: execute
wave: 2
depends_on:
  - "05-01"
files_modified:
  - backend/internal/web/handlers_files.go
  - backend/internal/web/handlers_files_test.go
  - backend/internal/web/router.go
  - backend/templates/tablos.templ
  - backend/templates/files.templ
  - backend/internal/web/handlers_tablos.go
  - backend/cmd/web/main.go
autonomous: true
requirements:
  - FILE-01
  - FILE-02
  - FILE-03
  - FILE-06

must_haves:
  truths:
    - "POST /tablos/{id}/files accepts a multipart file, stores bytes in S3, inserts a tablo_files row, and re-renders the file list"
    - "GET /tablos/{id}/files renders the file list (filename, size in human-readable form, uploaded-at) with an upload form above"
    - "Only the tablo owner can see or POST to the files tab (non-owner gets 404)"
    - "Tab bar on tablo detail page shows Overview / Tasks / Files with hx-push-url; URL updates on tab switch"
    - "Uploading a file >25MB returns a friendly error message above the upload form (not a 500)"
    - "D-01: FileUploadHandler receives multipart POST and streams file body directly to S3 via files.Store.Upload (server-proxied, no temp file)"
    - "D-02: main.go reads S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, S3_USE_PATH_STYLE env vars and passes them to files.NewStore"
    - "D-04: FileUploadHandler constructs S3 key as files/{tablo_id}/{uuid} before calling files.Store.Upload"
    - "D-06: FileUploadHandler generates a new UUID per upload regardless of filename; no dedup check; duplicate filenames each get their own DB row"
    - "D-07: TabloDetailPage signature includes activeTab string param; tab bar renders Overview, Tasks, Files links"
    - "D-08: Tab bar links carry hx-push-url attribute matching their href so browser URL updates on HTMX tab switch"
    - "D-09: FileUploadHandler sets r.Body = http.MaxBytesReader(w, r.Body, maxBytes) before ParseMultipartForm; max size read from MAX_UPLOAD_SIZE_MB env var (default 25)"
    - "D-10: FileUploadHandler has no file-type allowlist check; all MIME types accepted after http.DetectContentType detection"
  artifacts:
    - path: "backend/internal/web/handlers_files.go"
      provides: "FilesDeps, TabloFilesTabHandler, FileUploadHandler, TabloTasksTabHandler"
      exports: ["FilesDeps", "TabloFilesTabHandler", "FileUploadHandler", "TabloTasksTabHandler"]
    - path: "backend/templates/files.templ"
      provides: "FilesTab, FilesTabFragment, FileListRow, FileUploadForm, UploadErrorFragment"
      contains: "hx-encoding=\"multipart/form-data\""
    - path: "backend/templates/tablos.templ"
      provides: "TabloDetailPage with 3-tab layout"
      contains: "hx-push-url"
    - path: "backend/internal/web/router.go"
      provides: "File + tab routes wired"
      contains: "TabloFilesTabHandler"
    - path: "backend/cmd/web/main.go"
      provides: "S3 client constructed + FilesDeps wired into NewRouter"
      contains: "files.NewStore"
  key_links:
    - from: "backend/internal/web/handlers_files.go"
      to: "backend/internal/files/store.go"
      via: "FilesDeps.Files FileStorer"
      pattern: "deps\\.Files\\.Upload"
    - from: "backend/templates/tablos.templ"
      to: "backend/templates/files.templ"
      via: "templ component call in tab-content div"
      pattern: "@FilesTabFragment"
    - from: "backend/cmd/web/main.go"
      to: "backend/internal/files/store.go"
      via: "files.NewStore(ctx, s3Endpoint, ...)"
      pattern: "files\\.NewStore"
---

<objective>
Vertical slice 1: upload + list + tab navigation. After this plan, a user can visit /tablos/{id}/files, see the upload form and any existing files, and upload a new file that is stored in S3 and listed immediately. The tab bar (Overview / Tasks / Files) is wired with hx-push-url across the full tablo detail page.

Purpose: Delivers FILE-01 (upload to S3 + DB row), FILE-02 (server-proxied upload), FILE-03 (file list with filename/size/date), and FILE-06 (ownership via loadOwnedTablo). The tab restructuring is necessary for D-07/D-08.
Output: handlers_files.go (FilesDeps + FileUploadHandler + TabloFilesTabHandler + TabloTasksTabHandler), files.templ (upload form + list), tablos.templ restructured with 3-tab layout, router and main.go wired.
</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-01-SUMMARY.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From backend/internal/web/handlers_tasks.go (TasksDeps — exact pattern to mirror for FilesDeps):
  type TasksDeps struct {
    Queries *sqlc.Queries
  }
  func loadOwnedTabloForTask(w, r, deps TasksDeps) (sqlc.Tablo, sqlc.Task, *auth.User, bool)
  func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { r.Body wrapping is NOT done here }

From backend/internal/web/handlers_tablos.go (loadOwnedTablo exact signature):
  func loadOwnedTablo(w http.ResponseWriter, r *http.Request, deps TablosDeps) (sqlc.Tablo, *auth.User, bool)
  // Templates call site (TWO instances — both must be updated in this plan):
  line 205: templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks)
  line 311: templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks)

From backend/internal/web/router.go (current NewRouter signature):
  func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler
  // Route block to extend (lines 93-105) — insert r.Get("/tablos/{id}/tasks",...) BEFORE line 96

From backend/templates/tablos.templ (current TabloDetailPage signature — line 173):
  templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task)

From backend/cmd/web/main.go (deps wiring block — lines 78-82):
  deps := web.AuthDeps{...}
  tabloDeps := web.TablosDeps{Queries: q}
  taskDeps := web.TasksDeps{Queries: q}
  router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, csrfKey, env)

From backend/internal/db/sqlc/files.sql.go (generated in Plan 01 — key types):
  type InsertTabloFileParams struct { TabloID pgtype.UUID; S3Key string; Filename string; ContentType string; SizeBytes int64 }
  type TabloFile struct { ID pgtype.UUID; TabloID pgtype.UUID; S3Key string; Filename string; ContentType string; SizeBytes int64; CreatedAt pgtype.Timestamptz }
  func (q *Queries) InsertTabloFile(ctx context.Context, arg InsertTabloFileParams) (TabloFile, error)
  func (q *Queries) ListFilesByTablo(ctx context.Context, tabloID pgtype.UUID) ([]TabloFile, error)
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: handlers_files.go — FilesDeps + FileUploadHandler + TabloFilesTabHandler + TabloTasksTabHandler</name>
  <files>
    backend/internal/web/handlers_files.go
    backend/internal/web/handlers_files_test.go
  </files>
  <read_first>
    backend/internal/web/handlers_tasks.go — read fully; copy TasksDeps, loadOwnedTabloForTask, HX-Request detection, and delete handler patterns exactly for File equivalents
    backend/internal/web/handlers_tablos.go — read lines 150-210 for loadOwnedTablo and TabloDetailHandler patterns
    backend/internal/files/store.go — FileStorer interface methods (Upload, Delete, PresignDownload) from Plan 01 output
    backend/internal/db/sqlc/files.sql.go — InsertTabloFileParams, TabloFile struct, query method signatures
    .planning/phases/05-files/05-RESEARCH.md §Pattern 4 (MaxBytesReader in handler) and §Pattern 5 (TabloFilesTabHandler)
    .planning/phases/05-files/05-PATTERNS.md §handlers_files.go — FilesDeps struct, loadOwnedTabloForFile helper pattern
  </read_first>
  <behavior>
    - TestFileUpload: POST /tablos/{id}/files with valid multipart file and valid CSRF → 303 redirect to /tablos/{id}/files (non-HTMX) or 200+fragment (HTMX); tablo_files row exists in DB with correct filename and content_type; stubbed FileStorer records Upload call with key matching "files/{tablo_id}/"
    - TestFileUploadTooLarge: POST with file body > MaxUploadMB bytes → 422 with error message containing "too large" in response body (not 500)
    - TestFilesTab: GET /tablos/{id}/files with HX-Request: true → 200 + HTML containing FilesTabFragment; without HX-Request header → 200 + full Layout HTML
    - TestFileOwnership (partial — upload+list routes): non-owner user GET and POST /tablos/{id}/files → 404
  </behavior>
  <action>
    Create backend/internal/web/handlers_files.go in package web. Follow handlers_tasks.go conventions exactly.

    FilesDeps struct:
      Queries *sqlc.Queries
      Files   FileStorer   (use the interface from internal/files/store.go, imported as files "backend/internal/files")
      MaxUploadMB int

    loadOwnedTabloForFile helper — same shape as loadOwnedTabloForTask but for file_id URL param and GetTabloFileByID query. Signature: func loadOwnedTabloForFile(w, r, deps FilesDeps) (sqlc.Tablo, sqlc.TabloFile, *auth.User, bool).

    TabloFilesTabHandler (GET /tablos/{id}/files):
      0. if deps.Files == nil { http.Error(w, "storage not configured", http.StatusServiceUnavailable); return } — MUST be the very first statement before any other logic
      1. loadOwnedTablo → 404 on failure
      2. deps.Queries.ListFilesByTablo → log error + empty slice on failure
      3. Set Content-Type: text/html; charset=utf-8
      4. If HX-Request == "true": render templates.FilesTabFragment(tablo, files, csrf.Token(r))
      5. Else: render templates.TabloDetailPage(user, csrf.Token(r), tablo, nil, files, "files") — note: tasks nil since we're on Files tab

    TabloTasksTabHandler (GET /tablos/{id}/tasks):
      1. loadOwnedTablo using TablosDeps{Queries: deps.Queries} — reuse same helper
      2. deps.Queries.ListTasksByTablo → log error + empty slice on failure
      3. If HX-Request == "true": render templates.TasksTabFragment(tablo, tasks, csrf.Token(r)) — this component is created in Task 2 of this plan
      4. Else: render templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, "tasks")
    File placement: TabloTasksTabHandler lives in handlers_files.go (it is part of the tab wiring introduced this phase). TasksTabFragment (the templ component wrapping KanbanBoard) goes in tablos.templ (it is a tablo-level concern). TabloTasksTabHandler does NOT go in handlers_tasks.go.

    FileUploadHandler (POST /tablos/{id}/files):
      0. if deps.Files == nil { http.Error(w, "storage not configured", http.StatusServiceUnavailable); return } — MUST be the very first statement before reading the request body
      1. loadOwnedTablo → 404 on failure
      2. maxBytes := int64(deps.MaxUploadMB) * 1024 * 1024
      3. r.Body = http.MaxBytesReader(w, r.Body, maxBytes) — MUST be before ParseMultipartForm (Pitfall 2 in RESEARCH)
      4. if err := r.ParseMultipartForm(2 << 20); err != nil: check errors.As(err, &mbErr) for *http.MaxBytesError; if yes, render upload error fragment with "File too large (max {MaxUploadMB} MB)." and return 422; else http.Error 400
      5. file, header, err := r.FormFile("file") — 400 on error
      6. defer file.Close()
      7. fileUUID := uuid.New()
      8. s3Key := "files/" + tablo.ID.String() + "/" + fileUUID.String() (per D-04)
      9. contentType, bytesWritten, err := deps.Files.Upload(r.Context(), s3Key, file) — 500 on error; log with slog.Default().Error("files upload: Upload failed", "tablo_id", tablo.ID, "err", err)
      10. _, err = deps.Queries.InsertTabloFile(r.Context(), sqlc.InsertTabloFileParams{ TabloID: tablo.ID, S3Key: s3Key, Filename: header.Filename, ContentType: contentType, SizeBytes: bytesWritten }) — 500 on error
      11. List updated files: deps.Queries.ListFilesByTablo(r.Context(), tablo.ID)
      12. HTMX path (HX-Request == "true"): 200 + render templates.FilesTabFragment(tablo, updatedFiles, csrf.Token(r)) with HX-Retarget: "#tab-content" and HX-Reswap: "innerHTML"
      13. Non-HTMX path: http.Redirect to "/tablos/"+tablo.ID.String()+"/files" with 303

    Update handlers_files_test.go: un-skip TestFileUpload and TestFilesList and add the behavior from the <behavior> block above. Keep TestFileDownload and TestFileDelete and TestFileOwnership (for download/delete routes) as skipped stubs — those are wired in Plan 03.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./... && go test ./internal/web/ -run "TestFileUpload|TestFilesTab" -v -count=1 2>&1 | tail -20</automated>
  </verify>
  <done>
    go build ./... exits 0. TestFileUpload and TestFilesTab pass (or are explicitly skipped with reason). handlers_files.go contains FilesDeps, TabloFilesTabHandler, FileUploadHandler, TabloTasksTabHandler.
  </done>
  <acceptance_criteria>
    - go build ./... exits 0
    - backend/internal/web/handlers_files.go contains "type FilesDeps struct" and "func TabloFilesTabHandler(" and "func FileUploadHandler(" and "func TabloTasksTabHandler("
    - FileUploadHandler first statement is "deps.Files == nil" nil guard returning 503 before any body read
    - TabloFilesTabHandler first statement is "deps.Files == nil" nil guard returning 503
    - FileUploadHandler contains "http.MaxBytesReader" before "ParseMultipartForm" (Pitfall 2 guard)
    - FileUploadHandler contains "files/" string for S3 key construction (D-04)
    - FileUploadHandler contains "http.MaxBytesError" for size violation detection
    - go test ./internal/web/ -run "TestFileUpload|TestFilesTab" exits 0 (PASS or SKIP, no FAIL)
  </acceptance_criteria>
</task>

<task type="auto">
  <name>Task 2: Tab layout in tablos.templ + files.templ components + router + main.go wiring</name>
  <files>
    backend/templates/tablos.templ
    backend/templates/tablos_templ.go
    backend/templates/files.templ
    backend/templates/files_templ.go
    backend/internal/web/router.go
    backend/cmd/web/main.go
    backend/internal/web/handlers_tablos.go
  </files>
  <read_first>
    backend/templates/tablos.templ — read fully; lines 173-191 are TabloDetailPage to restructure; lines 285-350 are Button component usage patterns; note ALL call sites of TabloDetailPage (currently line 205 and line 311 in handlers_tablos.go)
    backend/templates/tasks.templ — read lines 1-50 (package/imports pattern) and lines 285-345 (TaskDeleteConfirmFragment + TaskCardGone OOB pattern) for files.templ analog
    backend/internal/web/router.go — full file; understand current route ordering; determine exact insertion points for new routes
    backend/cmd/web/main.go — full file; lines 78-82 are the deps wiring block to extend
    backend/internal/web/handlers_tablos.go — lines 195-215 and 300-315 — two TabloDetailPage call sites that must gain files+activeTab args
    .planning/phases/05-files/05-PATTERNS.md §tablos.templ and §files.templ and §router.go and §main.go — pattern assignments with exact code shapes
  </read_first>
  <action>
    Step 1 — Update TabloDetailPage in backend/templates/tablos.templ.
    Change signature from:
      templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task)
    to:
      templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, files []sqlc.TabloFile, activeTab string)

    Restructure the body: keep the back link, title-zone, desc-zone, delete-zone zones unchanged. Below delete-zone, insert a tab navigation bar with three links using hx-get + hx-target="#tab-content" + hx-push-url. Each tab link:
      - href attribute = the full URL (templ.SafeURL)
      - hx-get = same URL string
      - hx-target = "#tab-content"
      - hx-swap = "innerHTML"
      - hx-push-url = same URL string
      - Tailwind classes: active tab gets visual emphasis (e.g. border-b-2 border-slate-800 font-semibold) based on activeTab == "overview"|"tasks"|"files"
    Below the nav, a div id="tab-content" dispatches on activeTab:
      - "overview" or "": render @TabloOverviewTabFragment(tablo, csrfToken) (just the description display — tablo-desc-zone already handled above, so overview tab can show a placeholder or just the description again)
      - "tasks": render @TasksTabFragment(tablo, tasks, csrfToken)
      - "files": render @FilesTabFragment(tablo, files, csrfToken)

    Also add stand-alone fragment components that can be returned by HTMX tab-switch responses:
      templ TabloOverviewTabFragment(tablo sqlc.Tablo, csrfToken string) — minimal overview content
      templ TasksTabFragment(tablo sqlc.Tablo, tasks []sqlc.Task, csrfToken string) — wraps existing @KanbanBoard(tablo.ID, csrfToken, tasks) call; lives in tablos.templ (tablo-level concern)
      These are called by TabloTasksTabHandler when HX-Request == "true" (Plan 02 Task 1).

    Step 2 — Update TWO call sites in backend/internal/web/handlers_tablos.go:
      Line ~205: templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks) → templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, "overview")
      Line ~311: same update → templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, "overview")
    Also update TabloUpdateHandler (around line 311) if it also calls TabloDetailPage — check handlers_tablos.go for any other call site.

    Step 3 — Create backend/templates/files.templ. Package templates. Import sqlc, ui, uuid from existing templates. Components needed:
      FilesTabFragment(tablo sqlc.Tablo, files []sqlc.TabloFile, csrfToken string) — renders the upload form followed by the file list; called by TabloFilesTabHandler for HTMX requests and embedded in TabloDetailPage #tab-content for "files" tab
      FileUploadForm(tabloID uuid.UUID, csrfToken string, uploadErr string) — multipart upload form with method="POST", action=templ.SafeURL("/tablos/"+tabloID.String()+"/files"), enctype="multipart/form-data", hx-post, hx-encoding="multipart/form-data", hx-target="#tab-content", hx-swap="innerHTML"; input type="file" name="file"; @ui.CSRFField(csrfToken); @ui.Button submit; if uploadErr != "" show error message above the form in a red-tinted div
      FileListRow(tabloID uuid.UUID, file sqlc.TabloFile) — one row per file showing filename, human-readable size (e.g. "1.2 MB"), formatted created_at; download link points to GET /tablos/{tabloID}/files/{file.ID}/download (wired in Plan 03); delete confirm link points to GET /tablos/{tabloID}/files/{file.ID}/delete-confirm using hx-get + hx-target="closest .file-row-zone" + hx-swap="outerHTML"
      FileListEmpty — small empty-state message when files slice is empty
      FileRowGone(fileID uuid.UUID) — OOB removal: <div id={"file-"+fileID.String()} class="file-row-zone"></div> (same pattern as TaskCardGone)
      UploadErrorFragment(tabloID uuid.UUID, files []sqlc.TabloFile, csrfToken string, errMsg string) — re-renders FilesTabFragment with error message set; used by FileUploadHandler on size violation

    Size display helper: write a Go function formatBytes(n int64) string in a non-templ Go file (templates/files_helpers.go or inline as a private func in files.templ's go: block) that converts bytes to human-readable string (e.g. "512 B", "1.2 KB", "3.5 MB").

    Step 4 — Run templ generate to produce files_templ.go and update tablos_templ.go:
      cd backend && templ generate
    Verify no templ errors. Fix any import issues.

    Step 5 — Update backend/internal/web/router.go NewRouter signature to add fileDeps FilesDeps before csrfKey:
      func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler
    Inside the RequireAuth group, add new routes in correct order (static before parametric — Pitfall 6 in RESEARCH):
      After line 93 (comment for task routes) and BEFORE the existing r.Get("/tablos/{id}/tasks/new",...):
        r.Get("/tablos/{id}/tasks", TabloTasksTabHandler(taskDepsAdapter)) — TabloTasksTabHandler takes FilesDeps but needs Queries from taskDeps; either pass fileDeps (which has same Queries) or adapt. Since FilesDeps.Queries and TasksDeps.Queries are both *sqlc.Queries pointing to same pool, pass fileDeps.
      After all task parametric routes (after line 105), add file routes:
        r.Get("/tablos/{id}/files", TabloFilesTabHandler(fileDeps))
        r.Post("/tablos/{id}/files", FileUploadHandler(fileDeps))
        r.Get("/tablos/{id}/files/{file_id}/download", FileDownloadHandler(fileDeps))           // stub — will 501 until Plan 03
        r.Get("/tablos/{id}/files/{file_id}/delete-confirm", FileDeleteConfirmHandler(fileDeps)) // stub — will 501 until Plan 03
        r.Post("/tablos/{id}/files/{file_id}/delete", FileDeleteHandler(fileDeps))               // stub — will 501 until Plan 03
    Declare FileDownloadHandler, FileDeleteConfirmHandler, FileDeleteHandler as stub functions in handlers_files.go returning http.Error(w, "not implemented", 501) — these are implemented fully in Plan 03.

    Step 6 — Update backend/cmd/web/main.go. After taskDeps declaration and before router construction, add:
      Read env vars: s3Endpoint (S3_ENDPOINT), s3Bucket (S3_BUCKET), s3AccessKey (S3_ACCESS_KEY), s3SecretKey (S3_SECRET_KEY), s3Region (S3_REGION, default "us-east-1"), s3UsePathStyle (S3_USE_PATH_STYLE == "true")
      maxUploadMB int: parse MAX_UPLOAD_SIZE_MB env var with strconv.Atoi, default 25 on empty/error
      If s3Endpoint == "" || s3Bucket == "": log slog.Warn (not Error + Exit — allow server to start without S3 for non-file routes in dev)
      filesStore, err := files.NewStore(ctx, s3Endpoint, s3Bucket, s3Region, s3AccessKey, s3SecretKey, s3UsePathStyle) — if err != nil: log slog.Error + os.Exit(1)
      Only construct filesStore if s3Endpoint != "" — else set filesStore = nil and fileDeps.Files = nil. File handlers check for nil Files as their FIRST statement and return 503 "storage not configured".
      fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB}
      Update NewRouter call to pass fileDeps before csrfKey: web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env)

    Update any test files that call NewRouter directly (check handlers_tablos_test.go, handlers_tasks_test.go, handlers_files_test.go) to pass an empty web.FilesDeps{Queries: q} in the new position.
  </action>
  <verify>
    <automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./... && go test ./... -count=1 -timeout 60s 2>&1 | tail -30</automated>
  </verify>
  <done>
    go build ./... exits 0. go test ./... exits 0 (all existing tests pass, new TestFile* still SKIP or PASS). TabloDetailPage has 6 parameters. files.templ exists with FilesTabFragment and FileUploadForm. router.go passes fileDeps to NewRouter. main.go constructs files.Store from env vars.
  </done>
  <acceptance_criteria>
    - go build ./... exits 0
    - go test ./... exits 0 (no regressions in existing test suite; TestTask* and TestTablo* remain PASS)
    - backend/templates/tablos.templ contains "activeTab string" in TabloDetailPage signature and "hx-push-url" and "tab-content"
    - backend/templates/tablos.templ contains "TasksTabFragment" component (wrapping KanbanBoard)
    - backend/templates/files.templ contains "FilesTabFragment" and "FileUploadForm" and "hx-encoding" (required for HTMX multipart) and "FileRowGone"
    - backend/internal/web/router.go contains "fileDeps FilesDeps" in NewRouter signature and "TabloFilesTabHandler" and "TabloTasksTabHandler"
    - backend/cmd/web/main.go contains "files.NewStore" and "fileDeps := web.FilesDeps"
    - backend/internal/web/handlers_tablos.go at both TabloDetailPage call sites: last two args are "nil, \"overview\""
  </acceptance_criteria>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser → FileUploadHandler | Multipart form body; attacker controls filename, file content, MIME hints, and Content-Length |
| FilesDeps.Files (FileStorer) | Nil-checked as the first statement of every file handler; returns 503 before touching the request body |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-02-01 | Tampering | FileUploadHandler — filename | mitigate | header.Filename stored in DB as display string only; S3 key is "files/{uuid}" — filename never reaches S3 key (D-04) |
| T-05-02-02 | Denial of Service | FileUploadHandler — upload size | mitigate | http.MaxBytesReader(w, r.Body, maxBytes) wraps r.Body BEFORE ParseMultipartForm (Pitfall 2 guard); MaxBytesError detected and returns 422 with friendly message (D-09) |
| T-05-02-03 | Spoofing | FileUploadHandler — content-type | mitigate | Browser Content-Type header ignored; server calls http.DetectContentType on first 512 bytes via files.Store.Upload (D-05) |
| T-05-02-04 | Elevation of Privilege | TabloFilesTabHandler — IDOR | mitigate | loadOwnedTablo called as first step in every handler; GetTabloByID query includes UserID filter; non-owner gets 404 (FILE-06) |
| T-05-02-05 | Tampering | File routes — CSRF | mitigate | gorilla/csrf middleware already in stack (Phase 2); all state-changing POSTs require valid CSRF token; @ui.CSRFField(csrfToken) present in upload form and future delete form |
| T-05-02-06 | Denial of Service | TabloFilesTabHandler + FileUploadHandler — nil FileStorer | mitigate | deps.Files == nil guard is the FIRST statement in TabloFilesTabHandler and FileUploadHandler; returns 503 "storage not configured" before any body read or DB call; main.go sets fileDeps.Files = nil when S3_ENDPOINT is unset |
</threat_model>

<verification>
After both tasks:
- cd backend && go build ./... exits 0
- cd backend && go test ./... -count=1 -timeout 60s exits 0 (no regressions; TestTask* and TestTablo* PASS)
- cd backend && go test ./internal/web/ -run TestFileUpload -v shows PASS or SKIP (not FAIL)
- backend/templates/files.templ exists and contains FilesTabFragment, FileUploadForm, FileRowGone
- backend/templates/tablos.templ TabloDetailPage signature has 6 args (including files []sqlc.TabloFile and activeTab string)
- backend/templates/tablos.templ contains TasksTabFragment component
- router.go NewRouter has fileDeps FilesDeps parameter
</verification>

<success_criteria>
1. File upload end-to-end works: POST /tablos/{id}/files with valid multipart → S3 object created + DB row inserted + 303 redirect to files tab
2. Files list renders at GET /tablos/{id}/files with filename, human-readable size, and upload date
3. Oversize upload returns 422 with "too large" message above the form (not 500)
4. Tab bar on tablo detail page has Overview / Tasks / Files links with hx-push-url; URL updates on click
5. All existing tests (TestTask*, TestTablo*) continue to PASS
6. Non-owner gets 404 on file routes (ownership enforced by loadOwnedTablo)
</success_criteria>

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