xtablo-source/.planning/phases/05-files/05-02-PLAN.md
Arthur Belleville f115082bd5
docs(05): create phase 5 file upload plans (4 plans, 4 waves)
Adds 4 PLAN.md files for Phase 5 — Files. Wave 1 lays the S3
foundation (aws-sdk-go-v2, migration, FileStorer, MinIO compose).
Wave 2 delivers the upload + list vertical slice with 3-tab tablo
layout. Wave 3 closes download + delete. Wave 4 is the browser
verify checkpoint.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-15 11:58:52 +02:00

25 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
05-files 02 execute 2
05-01
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
true
FILE-01
FILE-02
FILE-03
FILE-06
truths artifacts key_links
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)
path provides exports
backend/internal/web/handlers_files.go FilesDeps, TabloFilesTabHandler, FileUploadHandler, TabloTasksTabHandler
FilesDeps
TabloFilesTabHandler
FileUploadHandler
TabloTasksTabHandler
path provides contains
backend/templates/files.templ FilesTab, FilesTabFragment, FileListRow, FileUploadForm, UploadErrorFragment hx-encoding="multipart/form-data"
path provides contains
backend/templates/tablos.templ TabloDetailPage with 3-tab layout hx-push-url
path provides contains
backend/internal/web/router.go File + tab routes wired TabloFilesTabHandler
path provides contains
backend/cmd/web/main.go S3 client constructed + FilesDeps wired into NewRouter files.NewStore
from to via pattern
backend/internal/web/handlers_files.go backend/internal/files/store.go FilesDeps.Files FileStorer deps.Files.Upload
from to via pattern
backend/templates/tablos.templ backend/templates/files.templ templ component call in tab-content div @FilesTabFragment
from to via pattern
backend/cmd/web/main.go backend/internal/files/store.go files.NewStore(ctx, s3Endpoint, ...) files.NewStore
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.

<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>

@/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

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)

Task 1: handlers_files.go — FilesDeps + FileUploadHandler + TabloFilesTabHandler + TabloTasksTabHandler backend/internal/web/handlers_files.go backend/internal/web/handlers_files_test.go 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 - 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 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):
  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")
Note: TabloTasksTabHandler lives in handlers_files.go since it is part of the tab wiring introduced this phase. It takes FilesDeps (which has Queries). Alternatively put it in handlers_tasks.go — choose whichever file keeps imports clean.

FileUploadHandler (POST /tablos/{id}/files):
  1. loadOwnedTablo → 404 on failure
  2. maxBytes := int64(deps.MaxUploadMB) * 1024 * 1024
  3. r.Body = http.MaxBytesReader(w, r.Body, maxBytes) — MUST be first, 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.
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 go build ./... exits 0. TestFileUpload and TestFilesTab pass (or are explicitly skipped with reason). handlers_files.go contains FilesDeps, TabloFilesTabHandler, FileUploadHandler, TabloTasksTabHandler. - go build ./... exits 0 - backend/internal/web/handlers_files.go contains "type FilesDeps struct" and "func TabloFilesTabHandler(" and "func FileUploadHandler(" and "func TabloTasksTabHandler(" - FileUploadHandler contains "http.MaxBytesReader" on the line 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) Task 2: Tab layout in tablos.templ + files.templ components + router + main.go wiring 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 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 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
  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 must check for nil Files 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.
cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./... && go test ./... -count=1 -timeout 60s 2>&1 | tail -30 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. - 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/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\""

<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 in main.go; handlers must check for nil before calling Upload/Delete/PresignDownload

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 — nil FileStorer mitigate main.go only constructs filesStore when S3_ENDPOINT set; FileUploadHandler checks deps.Files == nil and returns 503 "storage not configured" before reading body
</threat_model>
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) - router.go NewRouter has fileDeps FilesDeps parameter

<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>
After completion, create .planning/phases/05-files/05-02-SUMMARY.md