diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 63bb841..a2d4b23 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -124,6 +124,13 @@ Plans: **User-in-loop:** Approve the `tablo_files` schema (key strategy, content-type handling, dedup). Approve upload method (direct PUT vs server-proxied). +**Plans:** 4 plans +Plans: +- [ ] 05-01-PLAN.md — Wave 1: go get aws-sdk-go-v2 + migration 0005_files + sqlc queries + files.Store + FileStorer interface + RED test scaffold + MinIO in compose.yaml +- [ ] 05-02-PLAN.md — Wave 2: handlers_files.go (FilesDeps + FileUploadHandler + TabloFilesTabHandler + TabloTasksTabHandler) + tablos.templ 3-tab layout + files.templ (upload form + list) + router + main.go wiring (FILE-01, FILE-02, FILE-03, FILE-06) +- [ ] 05-03-PLAN.md — Wave 3: FileDownloadHandler (302 → presigned URL) + FileDeleteConfirmHandler + FileDeleteHandler (S3-first delete) + FileDeleteConfirmFragment + full TestFile* green (FILE-04, FILE-05, FILE-06) +- [ ] 05-04-PLAN.md — Wave 4: Human-verify checkpoint: tab navigation + upload/list/download/delete end-to-end browser walkthrough + ### Phase 6: Background Worker **Goal:** A second binary (`cmd/worker`) runs against the same Postgres, processes jobs from a queue, and proves end-to-end with at least one real job. **Mode:** mvp @@ -169,3 +176,4 @@ Plans: *Roadmap created: 2026-05-14* *Phase 3 plans added: 2026-05-15* *Phase 4 plans added: 2026-05-15* +*Phase 5 plans added: 2026-05-15* diff --git a/.planning/phases/05-files/05-01-PLAN.md b/.planning/phases/05-files/05-01-PLAN.md new file mode 100644 index 0000000..84ab803 --- /dev/null +++ b/.planning/phases/05-files/05-01-PLAN.md @@ -0,0 +1,282 @@ +--- +phase: 05-files +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/go.mod + - backend/go.sum + - backend/migrations/0005_files.sql + - backend/internal/db/queries/files.sql + - backend/internal/db/sqlc/files.sql.go + - backend/internal/db/sqlc/models.go + - backend/internal/files/store.go + - backend/internal/web/handlers_files_test.go + - backend/compose.yaml + - backend/internal/files/doc.go +autonomous: true +requirements: + - FILE-01 + - FILE-02 + - FILE-03 + - FILE-04 + - FILE-05 + - FILE-06 + +must_haves: + truths: + - "go build ./... compiles after aws-sdk-go-v2 modules added" + - "tablo_files table exists after goose migrate up" + - "files.Store has Upload, Delete, PresignDownload methods satisfying FileStorer interface" + - "handlers_files_test.go contains RED test stubs for FILE-01..FILE-06 (compile but skip)" + - "MinIO service runs in compose.yaml with mc init container creating xtablo-dev bucket" + artifacts: + - path: "backend/migrations/0005_files.sql" + provides: "tablo_files schema" + contains: "CREATE TABLE tablo_files" + - path: "backend/internal/db/queries/files.sql" + provides: "sqlc query definitions" + contains: "InsertTabloFile" + - path: "backend/internal/files/store.go" + provides: "S3 client + FileStorer interface" + exports: ["NewStore", "FileStorer", "Store"] + - path: "backend/internal/web/handlers_files_test.go" + provides: "RED test scaffold" + contains: "TestFileUpload" + - path: "backend/compose.yaml" + provides: "MinIO local dev service" + contains: "minio/minio" + key_links: + - from: "backend/internal/files/store.go" + to: "aws-sdk-go-v2/service/s3" + via: "go get + import" + pattern: "s3\\.NewFromConfig" + - from: "backend/internal/db/sqlc/files.sql.go" + to: "backend/migrations/0005_files.sql" + via: "sqlc generate" + pattern: "InsertTabloFile" +--- + + +Wave 0 foundation for Phase 5. Adds the four aws-sdk-go-v2 modules, the 0005_files.sql migration, sqlc query file + regeneration, the internal/files package (Store + FileStorer interface), a RED test scaffold, and MinIO in compose.yaml. + +Purpose: All later plans in this phase depend on these artifacts compiling. Nothing is wired to the HTTP server yet — that is Wave 1 and 2. +Output: Compiling S3 client package, sqlc-generated models with TabloFile struct, RED test stubs for FILE-01..06, MinIO available via compose. + + + +@/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 + + + +@/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 + + + + +From backend/internal/db/queries/tasks.sql (sqlc style reference): + -- name: InsertTask :one + INSERT INTO tasks (tablo_id, title, ...) VALUES ($1, $2, ...) RETURNING id, ...; + -- name: ListTasksByTablo :many + SELECT ... FROM tasks WHERE tablo_id = $1 ORDER BY status, position, created_at; + -- name: GetTaskByID :one + SELECT ... FROM tasks WHERE id = $1 AND tablo_id = $2; + -- name: DeleteTask :exec + DELETE FROM tasks WHERE id = $1 AND tablo_id = $2; + +From backend/migrations/0004_tasks.sql (migration style reference): + -- +goose Up + CREATE TABLE tasks ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tablo_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, ... ); + CREATE INDEX tasks_tablo_id_status_idx ON tasks(tablo_id, status, position); + -- +goose Down + DROP TABLE IF EXISTS tasks; + +From backend/internal/files/doc.go (current placeholder): + // Package files is a Phase 1 placeholder; the upload/storage implementation lands in Phase 5. + package files + +From backend/internal/web/handlers_tasks.go (TasksDeps pattern to mirror): + type TasksDeps struct { + Queries *sqlc.Queries + } + +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 + + + + + + + Task 1: aws-sdk-go-v2 modules + migration + sqlc queries + files.Store + + backend/go.mod + backend/go.sum + backend/migrations/0005_files.sql + backend/internal/db/queries/files.sql + backend/internal/db/sqlc/files.sql.go + backend/internal/db/sqlc/models.go + backend/internal/files/store.go + backend/internal/files/doc.go + + + backend/go.mod — current module deps; confirm aws-sdk-go-v2 absent before go get + backend/migrations/0004_tasks.sql — exact migration format to replicate (goose markers, CASCADE, timestamptz, IF NOT EXISTS) + backend/internal/db/queries/tasks.sql — sqlc annotation format (:one, :many, :exec) and column list style + backend/internal/files/doc.go — placeholder to replace with real package body + .planning/phases/05-files/05-RESEARCH.md §Pattern 1, Pattern 2, Pattern 3 — Store struct, Upload (sniff+stream), PresignDownload + .planning/phases/05-files/05-PATTERNS.md §backend/internal/files/store.go — full method signatures including FileStorer interface + + + Step 1 — Add aws-sdk-go-v2 modules: run inside backend/ directory: + go get github.com/aws/aws-sdk-go-v2@latest + go get github.com/aws/aws-sdk-go-v2/config@latest + go get github.com/aws/aws-sdk-go-v2/credentials@latest + go get github.com/aws/aws-sdk-go-v2/service/s3@latest + Verify go.mod contains all four entries after go get. + + Step 2 — Write backend/migrations/0005_files.sql. Follow 0004_tasks.sql format exactly: + Header comment line: -- migrations/0005_files.sql + Phase description comment + -- +goose Up block with: + CREATE TABLE tablo_files ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + tablo_id uuid NOT NULL REFERENCES tablos(id) ON DELETE CASCADE, + s3_key text NOT NULL, + filename text NOT NULL, + content_type text NOT NULL DEFAULT 'application/octet-stream', + size_bytes bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() + ); + CREATE INDEX tablo_files_tablo_id_idx ON tablo_files(tablo_id, created_at DESC); + -- +goose Down block: DROP TABLE IF EXISTS tablo_files; (no DROP TYPE — no custom ENUM) + No updated_at column (files are immutable per D-06). bigint for size_bytes (not integer). + + Step 3 — Write backend/internal/db/queries/files.sql with four queries following tasks.sql annotation style: + InsertTabloFile :one — INSERT INTO tablo_files (tablo_id, s3_key, filename, content_type, size_bytes) VALUES ($1,$2,$3,$4,$5) RETURNING all columns + ListFilesByTablo :many — SELECT all columns FROM tablo_files WHERE tablo_id = $1 ORDER BY created_at DESC + GetTabloFileByID :one — SELECT all columns FROM tablo_files WHERE id = $1 AND tablo_id = $2 + DeleteTabloFile :exec — DELETE FROM tablo_files WHERE id = $1 AND tablo_id = $2 + + Step 4 — Run sqlc generate (from backend/ directory: sqlc generate or just sqlc). Confirm files.sql.go and updated models.go exist in backend/internal/db/sqlc/. models.go must contain TabloFile struct with fields matching migration columns. + + Step 5 — Replace backend/internal/files/doc.go body with full store.go implementation. Replace the placeholder doc.go content; the file stays as doc.go but gains the full package body OR create store.go alongside doc.go. Keep doc.go as the package doc comment only; create store.go for the implementation. store.go must contain: + - FileStorer interface with three methods: Upload(ctx, key string, file io.Reader) (contentType string, bytesWritten int64, err error), Delete(ctx, key string) error, PresignDownload(ctx, key string) (string, error) + - Store struct with client *s3.Client and bucket string fields + - NewStore(ctx context.Context, endpoint, bucket, region, accessKey, secretKey string, usePathStyle bool) (*Store, error) — uses config.LoadDefaultConfig with credentials.NewStaticCredentialsProvider; sets o.BaseEndpoint = aws.String(endpoint) and o.UsePathStyle = usePathStyle on client options (per D-02, Pitfall 1 in RESEARCH — MinIO requires UsePathStyle: true) + - Upload method: (1) r.Body = http.MaxBytesReader is NOT called here — that's the handler's job; (2) read first 512 bytes with io.ReadFull, accept io.ErrUnexpectedEOF as non-fatal (Pitfall 3); (3) http.DetectContentType(sniffBuf[:n]) for content-type (per D-05); (4) wrap sniff bytes + remaining reader in a byteCountReader (simple struct wrapping io.Reader, incrementing a counter on each Read call) + io.MultiReader(sniffBytes, file) (Pitfall 8 — header.Size unreliable); (5) call s.client.PutObject with Bucket, Key, Body (counting reader wrapping MultiReader), ContentType; (6) return contentType, bytesWritten from counter, err + - Delete method: calls s.client.DeleteObject with Bucket and Key, returns error + - PresignDownload method: creates s3.NewPresignClient(s.client), calls PresignGetObject with 5*time.Minute Expires option, returns req.URL + + Verify: cd backend && go build ./internal/files/... must succeed (no compile errors). + + + cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./internal/files/... && go build ./internal/db/... && echo "BUILD OK" + + + go build ./internal/files/... exits 0. go.mod contains four aws-sdk-go-v2 entries. backend/migrations/0005_files.sql contains CREATE TABLE tablo_files. backend/internal/db/sqlc/ contains files.sql.go with InsertTabloFile method. FileStorer interface exported from store.go with Upload, Delete, PresignDownload signatures. + + + - go build ./internal/files/... exits 0 (no compile errors) + - go.mod contains line matching "github.com/aws/aws-sdk-go-v2/service/s3" + - backend/migrations/0005_files.sql contains "CREATE TABLE tablo_files" and "ON DELETE CASCADE" and "-- +goose Up" and "-- +goose Down" + - backend/internal/db/sqlc/files.sql.go contains function "InsertTabloFile" and "ListFilesByTablo" and "GetTabloFileByID" and "DeleteTabloFile" + - backend/internal/files/store.go contains "type FileStorer interface" and "func NewStore(" and "func (s *Store) Upload(" and "func (s *Store) Delete(" and "func (s *Store) PresignDownload(" + - store.go contains "io.ErrUnexpectedEOF" (Pitfall 3 guard) and "UsePathStyle" (Pitfall 1 guard) + + + + + Task 2: RED test scaffold + MinIO in compose.yaml + .env.example additions + + backend/internal/web/handlers_files_test.go + backend/compose.yaml + backend/internal/files/store_test.go + + + backend/internal/web/handlers_tasks_test.go — exact test file structure, setupTestDB pattern, stubbed handler test shape, t.Skip pattern for missing infrastructure + backend/compose.yaml — current file content (postgres service + volumes block to extend) + .planning/phases/05-files/05-RESEARCH.md §Validation Architecture — test names FILE-01..06, skip pattern for missing S3_ENDPOINT + .planning/phases/05-files/05-PATTERNS.md §compose.yaml — MinIO services block (Pattern 7) verbatim + + + Step 1 — Create backend/internal/web/handlers_files_test.go with RED stubs. Follow handlers_tasks_test.go exactly for package declaration, imports, and setupTestDB reuse. Add test functions that t.Skip("FILE handler tests: not yet implemented") and will be un-skipped in Plan 02/03: + TestFileUpload (FILE-01, FILE-02) — stub + TestFilesList (FILE-03) — stub + TestFileDownload (FILE-04) — stub + TestFileDelete (FILE-05) — stub + TestFileOwnership (FILE-06) — stub; verifies non-owner gets 404 on GET/POST /tablos/{id}/files and file sub-routes + + Also declare FileStorer test stub at top of file (a stubbedFileStorer implementing FileStorer interface with no-op methods) so Plan 02/03 can inject it without creating a new file. + + Step 2 — Create backend/internal/files/store_test.go with placeholder that verifies store.go compiles and interface is satisfied: + TestStoreImplementsFileStorer — compile-time assertion: var _ FileStorer = (*Store)(nil) + TestNewStore_SkipIfNoEndpoint — reads S3_ENDPOINT env var; t.Skip if empty; if set, calls NewStore and verifies non-nil client returned + + Step 3 — Update backend/compose.yaml: add the minio and minio-init services after the postgres service, following Pattern 7 from RESEARCH.md exactly: + minio service: image minio/minio:latest, container_name xtablo-backend-minio, restart unless-stopped, environment MINIO_ROOT_USER/MINIO_ROOT_PASSWORD both "minioadmin", ports 9000:9000 and 9001:9001, command "server /data --console-address :9001", volumes minio_data:/data, healthcheck using "mc ready local" with interval 5s / timeout 5s / retries 10 + minio-init service: image minio/mc:latest, depends_on minio condition service_healthy, entrypoint runs mc alias set local http://minio:9000 minioadmin minioadmin then mc mb --ignore-existing local/xtablo-dev, restart "no" (NOT unless-stopped — Pitfall 7) + Add minio_data: entry to volumes block alongside postgres_data: + + Do NOT add .env.example in this task — that file does not yet exist in backend/. Check with ls backend/*.example before touching. If backend/.env.example exists, add S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, S3_USE_PATH_STYLE, MAX_UPLOAD_SIZE_MB entries per RESEARCH.md §.env.example additions. If it does not exist, skip. + + + cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./... && go test ./internal/web/ -run TestFile -v -count=1 2>&1 | grep -E "SKIP|PASS|FAIL|ok" + + + go build ./... exits 0. TestFile* tests compile and all SKIP (not FAIL). compose.yaml contains minio and minio-init services. minio-init has restart: "no". volumes block contains minio_data:. + + + - go build ./... exits 0 + - go test ./internal/web/ -run TestFile -v exits 0 with all tests showing "--- SKIP" (zero FAIL) + - backend/compose.yaml contains "minio/minio" and "minio/mc" and "minio_data:" and 'restart: "no"' + - backend/internal/web/handlers_files_test.go contains "TestFileUpload" and "TestFilesList" and "TestFileDownload" and "TestFileDelete" and "TestFileOwnership" + - backend/internal/files/store_test.go contains "TestStoreImplementsFileStorer" and "var _ FileStorer = (*Store)(nil)" + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Go module fetch | aws-sdk-go-v2 fetched from Go proxy — verify module paths match expected (no typosquatting) | +| compose.yaml MinIO credentials | minioadmin/minioadmin are dev-only credentials; production uses env-injected S3_ACCESS_KEY/S3_SECRET_KEY | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-05-W0-01 | Tampering | go.mod | mitigate | Use official module paths github.com/aws/aws-sdk-go-v2/{config,credentials,service/s3} — verify paths in go.mod after go get | +| T-05-W0-02 | Information Disclosure | compose.yaml MinIO credentials | accept | minioadmin/minioadmin are well-known dev defaults; no production secret exposure; production uses real env vars | +| T-05-W0-03 | Elevation of Privilege | FileStorer interface | mitigate | Interface defined in internal/files package; only web handlers in same module can construct the concrete Store — no external injection path | + + + +After both tasks: +- cd backend && go build ./... exits 0 +- cd backend && go test ./internal/files/... -v -count=1 exits 0 (TestStoreImplementsFileStorer PASS, TestNewStore_SkipIfNoEndpoint SKIP) +- cd backend && go test ./internal/web/ -run TestFile -v -count=1 exits 0 (all SKIP) +- compose.yaml contains both minio services +- backend/migrations/0005_files.sql exists and contains correct goose markers + + + +1. go build ./... succeeds from backend/ directory +2. Six TestFile* stubs exist in handlers_files_test.go, all SKIP when run +3. 0005_files.sql migration has correct schema (tablo_files table, ON DELETE CASCADE, timestamptz, bigint size_bytes, composite index) +4. files.Store implements FileStorer interface (compile-time verified in store_test.go) +5. compose.yaml has MinIO service on port 9000 + mc init container with restart: "no" + + + +After completion, create .planning/phases/05-files/05-01-SUMMARY.md + diff --git a/.planning/phases/05-files/05-02-PLAN.md b/.planning/phases/05-files/05-02-PLAN.md new file mode 100644 index 0000000..5b98ef8 --- /dev/null +++ b/.planning/phases/05-files/05-02-PLAN.md @@ -0,0 +1,343 @@ +--- +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)" + 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" +--- + + +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. + + + +@/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 + + + +@/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 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:
(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\"" + +
+ +
+ + +## 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 | + + + +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 + + + +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) + + + +After completion, create .planning/phases/05-files/05-02-SUMMARY.md + diff --git a/.planning/phases/05-files/05-03-PLAN.md b/.planning/phases/05-files/05-03-PLAN.md new file mode 100644 index 0000000..58d409c --- /dev/null +++ b/.planning/phases/05-files/05-03-PLAN.md @@ -0,0 +1,267 @@ +--- +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" +--- + + +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. + + + +@/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 + + + +@/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 + + + + +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) { +
+
+ @ui.CSRFField(csrfToken) + @ui.Button(ui.ButtonProps{Label:"Yes, delete", Variant: ui.ButtonVariantDanger, ...}) +
+ // cancel button that re-fetches the display fragment +
+ } + +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) + } + } +
+
+ + + + + Task 1: FileDownloadHandler + FileDeleteConfirmHandler + FileDeleteHandler (replace 501 stubs) + + backend/internal/web/handlers_files.go + backend/internal/web/handlers_files_test.go + + + 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 + + + - 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 + + + Replace FileDownloadHandler stub (currently returns 501) with full implementation: + 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): + 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): + 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 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. + + + 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 + + + TestFileDownload, TestFileDelete, TestFileOwnership all PASS. FileDownloadHandler returns 302. FileDeleteHandler deletes DB row even when S3 delete fails. + + + - 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 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) + + + + + Task 2: FileDeleteConfirmFragment templ component + FileListRow download/delete links + full suite green + + backend/templates/files.templ + backend/templates/files_templ.go + + + 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 + + + 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. + + + cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1 -timeout 60s 2>&1 | tail -20 + + + 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. + + + - 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) + + + + + + +## 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 | + + + +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/templates/files.templ contains FileDeleteConfirmFragment with ui.CSRFField + + + +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 + + + +After completion, create .planning/phases/05-files/05-03-SUMMARY.md + diff --git a/.planning/phases/05-files/05-04-PLAN.md b/.planning/phases/05-files/05-04-PLAN.md new file mode 100644 index 0000000..af56076 --- /dev/null +++ b/.planning/phases/05-files/05-04-PLAN.md @@ -0,0 +1,157 @@ +--- +phase: 05-files +plan: 04 +type: execute +wave: 4 +depends_on: + - "05-03" +files_modified: [] +autonomous: false +requirements: + - FILE-01 + - FILE-02 + - FILE-03 + - FILE-04 + - FILE-05 + - FILE-06 + +must_haves: + truths: + - "Tab navigation updates browser URL via hx-push-url when switching between Overview, Tasks, and Files tabs" + - "A file can be uploaded, appears in the list with correct filename and human-readable size, and the download link delivers the file" + - "Deleting a file removes it from the list immediately; refreshing the page confirms it is gone" + - "Uploading a file >25MB shows a friendly error message above the form without crashing" + - "Only the tablo owner can upload/list/download/delete files (verified by navigating to another user's tablo)" + artifacts: [] + key_links: [] +--- + + +Human-verify checkpoint: full end-to-end browser walkthrough of the Phase 5 file workflow. Covers all six FILE requirements through manual interaction using the local MinIO + Go server stack. + +Purpose: Phase 5 success criteria require browser-level verification of tab navigation URL updates, file upload/list/download/delete flow, size enforcement, and visual polish that cannot be asserted in unit tests. +Output: User approval or a list of issues for gap closure. + + + +@/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 + + + +@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md §Phase 5 Success Criteria +@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-CONTEXT.md + + + + + + Task 1: Automated pre-flight before browser session + + + backend/.env — confirm S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY, MAX_UPLOAD_SIZE_MB are set (or .env.local) + + + Run these commands and confirm all pass before handing off to the user: + + 1. Full test suite: cd backend && go test ./... -count=1 -timeout 60s — must exit 0 + 2. Migration check: cd backend && just migrate-status (or goose -dir migrations postgres $DATABASE_URL status) — confirm 0005_files appears in Applied list + 3. Build check: cd backend && go build ./cmd/web/. — must exit 0 + 4. Compose services: cd backend && podman-compose up -d (or docker compose up -d) — postgres and minio must reach "healthy" state + 5. Start server: cd backend && just dev (or equivalent) — server must start on :8080 without error + + If any step fails, fix the issue before proceeding to the human-verify checkpoint. + + + cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1 -timeout 60s && go build ./cmd/web/. && echo "PRE-FLIGHT OK" + + + go test ./... exits 0. go build ./cmd/web/. exits 0. Server starts without errors. + + + - go test ./... exits 0 + - go build ./cmd/web/. exits 0 + - Server starts (no panic on startup) + + + + + + Phase 5 complete: file upload (server-proxied, streaming to MinIO), file list (filename + size + date), signed-URL download (302 redirect, 5-minute TTL), inline-confirm delete, 3-tab tablo detail page with hx-push-url. + + Start the Go server with MinIO running (just dev or go run ./cmd/web/.) then complete these checks: + + + **Step 1 — Tab navigation (D-07, D-08)** + 1. Log in and open any tablo detail page: http://localhost:8080/tablos/{id} + 2. Verify a tab bar is visible with three tabs: Overview, Tasks, Files + 3. Click the Tasks tab — verify URL changes to /tablos/{id}/tasks and the kanban board loads + 4. Click the Files tab — verify URL changes to /tablos/{id}/files and the upload form appears + 5. Click Overview — verify URL changes back to /tablos/{id} and the tablo title/description appear + 6. Hit browser Back button from Tasks — verify URL and content revert correctly + + **Step 2 — File upload (FILE-01, FILE-02, D-05, D-09)** + 7. On the Files tab, attach any small file (PDF, image, or text) using the upload form and click Upload + 8. Verify the file appears in the list below the form with: original filename, human-readable size (e.g. "42.5 KB"), and an upload date + 9. Upload the same file again — verify it appears twice (D-06: no dedup, each gets its own row) + 10. Attempt to upload a file larger than 25MB (or rename a large file to test); verify a friendly error message appears above the form — NOT a blank page or 500 error + + **Step 3 — File download (FILE-04)** + 11. Click the download link for any uploaded file + 12. Verify the browser either downloads the file directly or opens it in a new tab (the presigned URL redirects to MinIO — may require allowing the MinIO redirect from localhost:9000) + 13. Verify the download completes successfully + + **Step 4 — File delete (FILE-05)** + 14. Click the delete button on a file row + 15. Verify an inline confirmation appears ("Delete file?", filename, Yes-delete + Keep file buttons) + 16. Click "Keep file" — verify the row is restored to normal + 17. Click delete again, then "Yes, delete" — verify the row disappears from the list immediately + 18. Refresh the page — verify the deleted file is still gone from the list + + **Step 5 — Authorization (FILE-06)** + 19. If possible, open the browser in an incognito window, log in as a different user (sign up a second account), and attempt to navigate to the first user's tablo files URL: http://localhost:8080/tablos/{first_user_tablo_id}/files + 20. Verify you get a 404 (not a file list) + + **Step 6 — Kanban regression check (TASK-01..07)** + 21. Click the Tasks tab on any tablo + 22. Create a task, move it to a different column, verify it persists after refresh + 23. This confirms tab restructuring has not broken the kanban board + + Type "approved" if all steps pass. Describe any issues found (reference step numbers) if anything fails. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Local MinIO → browser | 302 redirect from Go server to MinIO at localhost:9000; browser follows redirect | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-05-04-01 | Information Disclosure | MinIO console at localhost:9001 | accept | Dev-only; minioadmin/minioadmin credentials are known defaults; not exposed in production | + + + +Phase 5 is verified complete when: +- Checkpoint approved by user (all 6 browser steps pass) +- go test ./... exits 0 (confirmed in Task 1 pre-flight) +- All six FILE-01..06 requirements exercised and observed working in browser + + + +1. User explicitly types "approved" after completing all verification steps +2. No regressions in kanban board behavior (TASK-01..07 spot-checked in Step 6) +3. Tab navigation works with hx-push-url URL updates +4. Upload → list → download → delete full cycle works end-to-end +5. Oversize upload shows friendly error (not 500) +6. Authorization: non-owner gets 404 on file routes + + + +After completion, create .planning/phases/05-files/05-04-SUMMARY.md +