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>
25 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 05-files | 02 | execute | 2 |
|
|
true |
|
|
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.mdFrom 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> |
<success_criteria>
- File upload end-to-end works: POST /tablos/{id}/files with valid multipart → S3 object created + DB row inserted + 303 redirect to files tab
- Files list renders at GET /tablos/{id}/files with filename, human-readable size, and upload date
- Oversize upload returns 422 with "too large" message above the form (not 500)
- Tab bar on tablo detail page has Overview / Tasks / Files links with hx-push-url; URL updates on click
- All existing tests (TestTask*, TestTablo*) continue to PASS
- Non-owner gets 404 on file routes (ownership enforced by loadOwnedTablo) </success_criteria>