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>
This commit is contained in:
parent
261c85ae73
commit
f115082bd5
5 changed files with 1057 additions and 0 deletions
|
|
@ -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*
|
||||
|
|
|
|||
282
.planning/phases/05-files/05-01-PLAN.md
Normal file
282
.planning/phases/05-files/05-01-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/PROJECT.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-CONTEXT.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-RESEARCH.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-PATTERNS.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types the executor needs. Extracted from codebase. -->
|
||||
|
||||
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
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: aws-sdk-go-v2 modules + migration + sqlc queries + files.Store</name>
|
||||
<files>
|
||||
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
|
||||
</files>
|
||||
<read_first>
|
||||
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
|
||||
</read_first>
|
||||
<action>
|
||||
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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./internal/files/... && go build ./internal/db/... && echo "BUILD OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
<acceptance_criteria>
|
||||
- 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)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: RED test scaffold + MinIO in compose.yaml + .env.example additions</name>
|
||||
<files>
|
||||
backend/internal/web/handlers_files_test.go
|
||||
backend/compose.yaml
|
||||
backend/internal/files/store_test.go
|
||||
</files>
|
||||
<read_first>
|
||||
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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
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:.
|
||||
</done>
|
||||
<acceptance_criteria>
|
||||
- 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)"
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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"
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create .planning/phases/05-files/05-01-SUMMARY.md
|
||||
</output>
|
||||
343
.planning/phases/05-files/05-02-PLAN.md
Normal file
343
.planning/phases/05-files/05-02-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Vertical slice 1: upload + list + tab navigation. After this plan, a user can visit /tablos/{id}/files, see the upload form and any existing files, and upload a new file that is stored in S3 and listed immediately. The tab bar (Overview / Tasks / Files) is wired with hx-push-url across the full tablo detail page.
|
||||
|
||||
Purpose: Delivers FILE-01 (upload to S3 + DB row), FILE-02 (server-proxied upload), FILE-03 (file list with filename/size/date), and FILE-06 (ownership via loadOwnedTablo). The tab restructuring is necessary for D-07/D-08.
|
||||
Output: handlers_files.go (FilesDeps + FileUploadHandler + TabloFilesTabHandler + TabloTasksTabHandler), files.templ (upload form + list), tablos.templ restructured with 3-tab layout, router and main.go wired.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/PROJECT.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-CONTEXT.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-RESEARCH.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-PATTERNS.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From backend/internal/web/handlers_tasks.go (TasksDeps — exact pattern to mirror for FilesDeps):
|
||||
type TasksDeps struct {
|
||||
Queries *sqlc.Queries
|
||||
}
|
||||
func loadOwnedTabloForTask(w, r, deps TasksDeps) (sqlc.Tablo, sqlc.Task, *auth.User, bool)
|
||||
func TaskCreateHandler(deps TasksDeps) http.HandlerFunc { r.Body wrapping is NOT done here }
|
||||
|
||||
From backend/internal/web/handlers_tablos.go (loadOwnedTablo exact signature):
|
||||
func loadOwnedTablo(w http.ResponseWriter, r *http.Request, deps TablosDeps) (sqlc.Tablo, *auth.User, bool)
|
||||
// Templates call site (TWO instances — both must be updated in this plan):
|
||||
line 205: templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks)
|
||||
line 311: templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks)
|
||||
|
||||
From backend/internal/web/router.go (current NewRouter signature):
|
||||
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler
|
||||
// Route block to extend (lines 93-105) — insert r.Get("/tablos/{id}/tasks",...) BEFORE line 96
|
||||
|
||||
From backend/templates/tablos.templ (current TabloDetailPage signature — line 173):
|
||||
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task)
|
||||
|
||||
From backend/cmd/web/main.go (deps wiring block — lines 78-82):
|
||||
deps := web.AuthDeps{...}
|
||||
tabloDeps := web.TablosDeps{Queries: q}
|
||||
taskDeps := web.TasksDeps{Queries: q}
|
||||
router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, csrfKey, env)
|
||||
|
||||
From backend/internal/db/sqlc/files.sql.go (generated in Plan 01 — key types):
|
||||
type InsertTabloFileParams struct { TabloID pgtype.UUID; S3Key string; Filename string; ContentType string; SizeBytes int64 }
|
||||
type TabloFile struct { ID pgtype.UUID; TabloID pgtype.UUID; S3Key string; Filename string; ContentType string; SizeBytes int64; CreatedAt pgtype.Timestamptz }
|
||||
func (q *Queries) InsertTabloFile(ctx context.Context, arg InsertTabloFileParams) (TabloFile, error)
|
||||
func (q *Queries) ListFilesByTablo(ctx context.Context, tabloID pgtype.UUID) ([]TabloFile, error)
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: handlers_files.go — FilesDeps + FileUploadHandler + TabloFilesTabHandler + TabloTasksTabHandler</name>
|
||||
<files>
|
||||
backend/internal/web/handlers_files.go
|
||||
backend/internal/web/handlers_files_test.go
|
||||
</files>
|
||||
<read_first>
|
||||
backend/internal/web/handlers_tasks.go — read fully; copy TasksDeps, loadOwnedTabloForTask, HX-Request detection, and delete handler patterns exactly for File equivalents
|
||||
backend/internal/web/handlers_tablos.go — read lines 150-210 for loadOwnedTablo and TabloDetailHandler patterns
|
||||
backend/internal/files/store.go — FileStorer interface methods (Upload, Delete, PresignDownload) from Plan 01 output
|
||||
backend/internal/db/sqlc/files.sql.go — InsertTabloFileParams, TabloFile struct, query method signatures
|
||||
.planning/phases/05-files/05-RESEARCH.md §Pattern 4 (MaxBytesReader in handler) and §Pattern 5 (TabloFilesTabHandler)
|
||||
.planning/phases/05-files/05-PATTERNS.md §handlers_files.go — FilesDeps struct, loadOwnedTabloForFile helper pattern
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestFileUpload: POST /tablos/{id}/files with valid multipart file and valid CSRF → 303 redirect to /tablos/{id}/files (non-HTMX) or 200+fragment (HTMX); tablo_files row exists in DB with correct filename and content_type; stubbed FileStorer records Upload call with key matching "files/{tablo_id}/"
|
||||
- TestFileUploadTooLarge: POST with file body > MaxUploadMB bytes → 422 with error message containing "too large" in response body (not 500)
|
||||
- TestFilesTab: GET /tablos/{id}/files with HX-Request: true → 200 + HTML containing FilesTabFragment; without HX-Request header → 200 + full Layout HTML
|
||||
- TestFileOwnership (partial — upload+list routes): non-owner user GET and POST /tablos/{id}/files → 404
|
||||
</behavior>
|
||||
<action>
|
||||
Create backend/internal/web/handlers_files.go in package web. Follow handlers_tasks.go conventions exactly.
|
||||
|
||||
FilesDeps struct:
|
||||
Queries *sqlc.Queries
|
||||
Files FileStorer (use the interface from internal/files/store.go, imported as files "backend/internal/files")
|
||||
MaxUploadMB int
|
||||
|
||||
loadOwnedTabloForFile helper — same shape as loadOwnedTabloForTask but for file_id URL param and GetTabloFileByID query. Signature: func loadOwnedTabloForFile(w, r, deps FilesDeps) (sqlc.Tablo, sqlc.TabloFile, *auth.User, bool).
|
||||
|
||||
TabloFilesTabHandler (GET /tablos/{id}/files):
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./... && go test ./internal/web/ -run "TestFileUpload|TestFilesTab" -v -count=1 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>
|
||||
go build ./... exits 0. TestFileUpload and TestFilesTab pass (or are explicitly skipped with reason). handlers_files.go contains FilesDeps, TabloFilesTabHandler, FileUploadHandler, TabloTasksTabHandler.
|
||||
</done>
|
||||
<acceptance_criteria>
|
||||
- go build ./... exits 0
|
||||
- backend/internal/web/handlers_files.go contains "type FilesDeps struct" and "func TabloFilesTabHandler(" and "func FileUploadHandler(" and "func TabloTasksTabHandler("
|
||||
- FileUploadHandler 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)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Tab layout in tablos.templ + files.templ components + router + main.go wiring</name>
|
||||
<files>
|
||||
backend/templates/tablos.templ
|
||||
backend/templates/tablos_templ.go
|
||||
backend/templates/files.templ
|
||||
backend/templates/files_templ.go
|
||||
backend/internal/web/router.go
|
||||
backend/cmd/web/main.go
|
||||
backend/internal/web/handlers_tablos.go
|
||||
</files>
|
||||
<read_first>
|
||||
backend/templates/tablos.templ — read fully; lines 173-191 are TabloDetailPage to restructure; lines 285-350 are Button component usage patterns; note ALL call sites of TabloDetailPage (currently line 205 and line 311 in handlers_tablos.go)
|
||||
backend/templates/tasks.templ — read lines 1-50 (package/imports pattern) and lines 285-345 (TaskDeleteConfirmFragment + TaskCardGone OOB pattern) for files.templ analog
|
||||
backend/internal/web/router.go — full file; understand current route ordering; determine exact insertion points for new routes
|
||||
backend/cmd/web/main.go — full file; lines 78-82 are the deps wiring block to extend
|
||||
backend/internal/web/handlers_tablos.go — lines 195-215 and 300-315 — two TabloDetailPage call sites that must gain files+activeTab args
|
||||
.planning/phases/05-files/05-PATTERNS.md §tablos.templ and §files.templ and §router.go and §main.go — pattern assignments with exact code shapes
|
||||
</read_first>
|
||||
<action>
|
||||
Step 1 — Update TabloDetailPage in backend/templates/tablos.templ.
|
||||
Change signature from:
|
||||
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task)
|
||||
to:
|
||||
templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, files []sqlc.TabloFile, activeTab string)
|
||||
|
||||
Restructure the body: keep the back link, title-zone, desc-zone, delete-zone zones unchanged. Below delete-zone, insert a tab navigation bar with three links using hx-get + hx-target="#tab-content" + hx-push-url. Each tab link:
|
||||
- href attribute = the full URL (templ.SafeURL)
|
||||
- hx-get = same URL string
|
||||
- hx-target = "#tab-content"
|
||||
- hx-swap = "innerHTML"
|
||||
- hx-push-url = same URL string
|
||||
- Tailwind classes: active tab gets visual emphasis (e.g. border-b-2 border-slate-800 font-semibold) based on activeTab == "overview"|"tasks"|"files"
|
||||
Below the nav, a div id="tab-content" dispatches on activeTab:
|
||||
- "overview" or "": render @TabloOverviewTabFragment(tablo, csrfToken) (just the description display — tablo-desc-zone already handled above, so overview tab can show a placeholder or just the description again)
|
||||
- "tasks": render @TasksTabFragment(tablo, tasks, csrfToken)
|
||||
- "files": render @FilesTabFragment(tablo, files, csrfToken)
|
||||
|
||||
Also add stand-alone fragment components that can be returned by HTMX tab-switch responses:
|
||||
templ TabloOverviewTabFragment(tablo sqlc.Tablo, csrfToken string) — minimal overview content
|
||||
templ TasksTabFragment(tablo sqlc.Tablo, tasks []sqlc.Task, csrfToken string) — wraps existing @KanbanBoard(tablo.ID, csrfToken, tasks) call
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go build ./... && go test ./... -count=1 -timeout 60s 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
go build ./... exits 0. go test ./... exits 0 (all existing tests pass, new TestFile* still SKIP or PASS). TabloDetailPage has 6 parameters. files.templ exists with FilesTabFragment and FileUploadForm. router.go passes fileDeps to NewRouter. main.go constructs files.Store from env vars.
|
||||
</done>
|
||||
<acceptance_criteria>
|
||||
- go build ./... exits 0
|
||||
- go test ./... exits 0 (no regressions in existing test suite; TestTask* and TestTablo* remain PASS)
|
||||
- backend/templates/tablos.templ contains "activeTab string" in TabloDetailPage signature and "hx-push-url" and "tab-content"
|
||||
- backend/templates/files.templ contains "FilesTabFragment" and "FileUploadForm" and "hx-encoding" (required for HTMX multipart) and "FileRowGone"
|
||||
- backend/internal/web/router.go contains "fileDeps FilesDeps" in NewRouter signature and "TabloFilesTabHandler" and "TabloTasksTabHandler"
|
||||
- backend/cmd/web/main.go contains "files.NewStore" and "fileDeps := web.FilesDeps"
|
||||
- backend/internal/web/handlers_tablos.go at both TabloDetailPage call sites: last two args are "nil, \"overview\""
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser → FileUploadHandler | Multipart form body; attacker controls filename, file content, MIME hints, and Content-Length |
|
||||
| FilesDeps.Files (FileStorer) | Nil-checked 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>
|
||||
|
||||
<verification>
|
||||
After both tasks:
|
||||
- cd backend && go build ./... exits 0
|
||||
- cd backend && go test ./... -count=1 -timeout 60s exits 0 (no regressions; TestTask* and TestTablo* PASS)
|
||||
- cd backend && go test ./internal/web/ -run TestFileUpload -v shows PASS or SKIP (not FAIL)
|
||||
- backend/templates/files.templ exists and contains FilesTabFragment, FileUploadForm, FileRowGone
|
||||
- backend/templates/tablos.templ TabloDetailPage signature has 6 args (including files []sqlc.TabloFile and activeTab string)
|
||||
- router.go NewRouter has fileDeps FilesDeps parameter
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. File upload end-to-end works: POST /tablos/{id}/files with valid multipart → S3 object created + DB row inserted + 303 redirect to files tab
|
||||
2. Files list renders at GET /tablos/{id}/files with filename, human-readable size, and upload date
|
||||
3. Oversize upload returns 422 with "too large" message above the form (not 500)
|
||||
4. Tab bar on tablo detail page has Overview / Tasks / Files links with hx-push-url; URL updates on click
|
||||
5. All existing tests (TestTask*, TestTablo*) continue to PASS
|
||||
6. Non-owner gets 404 on file routes (ownership enforced by loadOwnedTablo)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create .planning/phases/05-files/05-02-SUMMARY.md
|
||||
</output>
|
||||
267
.planning/phases/05-files/05-03-PLAN.md
Normal file
267
.planning/phases/05-files/05-03-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Vertical slice 2: download and delete. After this plan, the file list has a working download link (302 to presigned S3 URL with 5-minute TTL) and an inline delete confirmation (same pattern as Phase 3 tablo delete). FileDeleteHandler deletes S3 first, logs failures, always deletes DB row. All six FILE requirements are now implemented.
|
||||
|
||||
Purpose: Closes FILE-04 (signed download URL), FILE-05 (delete removes DB row + S3 object), and FILE-06 (ownership on download/delete routes).
|
||||
Output: Three handler functions replacing the 501 stubs from Plan 02, one new templ component (FileDeleteConfirmFragment), activated test assertions for TestFileDownload + TestFileDelete + TestFileOwnership.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/PROJECT.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-CONTEXT.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-RESEARCH.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-PATTERNS.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-02-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Extracted from Plan 02 outputs. -->
|
||||
|
||||
From backend/internal/web/handlers_files.go (after Plan 02):
|
||||
type FilesDeps struct {
|
||||
Queries *sqlc.Queries
|
||||
Files FileStorer
|
||||
MaxUploadMB int
|
||||
}
|
||||
func loadOwnedTabloForFile(w, r, deps FilesDeps) (sqlc.Tablo, sqlc.TabloFile, *auth.User, bool)
|
||||
// Stub handlers replaced in this plan:
|
||||
func FileDownloadHandler(deps FilesDeps) http.HandlerFunc // currently returns 501
|
||||
func FileDeleteConfirmHandler(deps FilesDeps) http.HandlerFunc // currently returns 501
|
||||
func FileDeleteHandler(deps FilesDeps) http.HandlerFunc // currently returns 501
|
||||
|
||||
From backend/internal/files/store.go (FileStorer interface from Plan 01):
|
||||
type FileStorer interface {
|
||||
Upload(ctx context.Context, key string, file io.Reader) (contentType string, bytesWritten int64, err error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
PresignDownload(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
From backend/internal/db/sqlc/files.sql.go (from Plan 01 generation):
|
||||
type GetTabloFileByIDParams struct { ID pgtype.UUID; TabloID pgtype.UUID }
|
||||
type DeleteTabloFileParams struct { ID pgtype.UUID; TabloID pgtype.UUID }
|
||||
func (q *Queries) GetTabloFileByID(ctx, arg GetTabloFileByIDParams) (TabloFile, error)
|
||||
func (q *Queries) DeleteTabloFile(ctx, arg DeleteTabloFileParams) error
|
||||
|
||||
From backend/templates/tasks.templ (TaskDeleteConfirmFragment pattern — exact template to mirror):
|
||||
templ TaskDeleteConfirmFragment(tabloID uuid.UUID, task sqlc.Task, csrfToken string) {
|
||||
<div class="task-card-zone" id={"task-"+task.ID.String()}>
|
||||
<form method="POST" action={templ.SafeURL("...")} hx-post={...} hx-target="closest .task-card-zone" hx-swap="outerHTML">
|
||||
@ui.CSRFField(csrfToken)
|
||||
@ui.Button(ui.ButtonProps{Label:"Yes, delete", Variant: ui.ButtonVariantDanger, ...})
|
||||
</form>
|
||||
// cancel button that re-fetches the display fragment
|
||||
</div>
|
||||
}
|
||||
|
||||
From backend/internal/web/handlers_tasks.go (delete handler pattern lines 285-309):
|
||||
func TaskDeleteHandler(deps TasksDeps) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tablo, task, _, ok := loadOwnedTabloForTask(w, r, deps)
|
||||
if !ok { return }
|
||||
if err := deps.Queries.DeleteTask(r.Context(), ...); err != nil {
|
||||
slog.Default().Error("tasks delete: DeleteTask failed", "id", task.ID, "err", err)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
_ = templates.TaskCardGone(task.ID).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: FileDownloadHandler + FileDeleteConfirmHandler + FileDeleteHandler (replace 501 stubs)</name>
|
||||
<files>
|
||||
backend/internal/web/handlers_files.go
|
||||
backend/internal/web/handlers_files_test.go
|
||||
</files>
|
||||
<read_first>
|
||||
backend/internal/web/handlers_files.go — read fully; locate the three 501 stub functions to replace; confirm loadOwnedTabloForFile helper is present from Plan 02
|
||||
backend/internal/web/handlers_tasks.go — lines 285-320 — TaskDeleteHandler and TaskDeleteConfirmHandler exact patterns
|
||||
backend/internal/db/sqlc/files.sql.go — GetTabloFileByID and DeleteTabloFile method signatures and param types
|
||||
.planning/phases/05-files/05-RESEARCH.md §Pattern 3 (PresignDownload), §Pattern 6 (FileDeleteHandler delete S3 first + always delete DB row)
|
||||
.planning/phases/05-files/05-PATTERNS.md §handlers_files.go §FileDeleteHandler deviation paragraph
|
||||
</read_first>
|
||||
<behavior>
|
||||
- TestFileDownload: GET /tablos/{id}/files/{file_id}/download by owner → stubbed PresignDownload returns "https://example.com/signed?key=foo"; response is 302 with Location header == "https://example.com/signed?key=foo"
|
||||
- TestFileDownload_NonOwner: non-owner (different user) GET /tablos/{other_id}/files/{file_id}/download → 404
|
||||
- TestFileDelete: POST /tablos/{id}/files/{file_id}/delete by owner with HTMX → 200 + response body contains file-row-zone id div (FileRowGone); stubbed Delete records the S3Key call; DB row for that file no longer exists
|
||||
- TestFileDelete_S3Failure: POST /tablos/{id}/files/{file_id}/delete where stub Delete returns error → DB row still deleted (S3 failure does not abort DB delete); response is 200 (not 500) for HTMX path
|
||||
- TestFileOwnership (full): complete the skipped assertions — non-owner on download + delete routes → 404; non-owner on delete-confirm → 404
|
||||
</behavior>
|
||||
<action>
|
||||
Replace FileDownloadHandler stub (currently returns 501) with full implementation:
|
||||
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 <behavior> block above using the stubbedFileStorer declared in Plan 01's test file (or adjust its methods to support tracking calls and returning errors). Add a stubbedFileStorer field "deleteErr error" so TestFileDelete_S3Failure can inject an error.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./internal/web/ -run "TestFileDownload|TestFileDelete|TestFileOwnership" -v -count=1 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
TestFileDownload, TestFileDelete, TestFileOwnership all PASS. FileDownloadHandler returns 302. FileDeleteHandler deletes DB row even when S3 delete fails.
|
||||
</done>
|
||||
<acceptance_criteria>
|
||||
- go test ./internal/web/ -run "TestFileDownload|TestFileDelete|TestFileOwnership" exits 0 with "--- PASS" for each test (no SKIP, no FAIL)
|
||||
- backend/internal/web/handlers_files.go FileDownloadHandler contains "http.StatusFound" (302 redirect)
|
||||
- backend/internal/web/handlers_files.go FileDeleteHandler contains "deps.Files.Delete" on a line before "deps.Queries.DeleteTabloFile" (S3 first per RESEARCH Pattern 6)
|
||||
- backend/internal/web/handlers_files.go FileDeleteHandler: the slog.Error for S3 failure does NOT have an early return; DB delete is always attempted
|
||||
- go test ./internal/web/ -run TestFile -v exits 0 (all six TestFile* tests PASS)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: FileDeleteConfirmFragment templ component + FileListRow download/delete links + full suite green</name>
|
||||
<files>
|
||||
backend/templates/files.templ
|
||||
backend/templates/files_templ.go
|
||||
</files>
|
||||
<read_first>
|
||||
backend/templates/files.templ — read fully (from Plan 02 output); identify FileListRow component; the download and delete-confirm links were left as placeholders pointing to the routes wired in Plan 02 stubs
|
||||
backend/templates/tasks.templ — lines 285-345 — TaskDeleteConfirmFragment exact structure (wrapper div with zone id, confirm form, cancel link)
|
||||
backend/internal/web/handlers_files.go — confirm exact URL paths: /tablos/{id}/files/{file_id}/download and /tablos/{id}/files/{file_id}/delete-confirm and /tablos/{id}/files/{file_id}/delete
|
||||
.planning/phases/05-files/05-PATTERNS.md §files.templ — TaskDeleteConfirmFragment analog, OOB removal pattern
|
||||
</read_first>
|
||||
<action>
|
||||
Step 1 — Add FileDeleteConfirmFragment to files.templ. Signature:
|
||||
templ FileDeleteConfirmFragment(tabloID uuid.UUID, file sqlc.TabloFile, csrfToken string)
|
||||
Structure mirrors TaskDeleteConfirmFragment exactly:
|
||||
Outer div with class="file-row-zone" and id={"file-"+file.ID.String()} — same zone ID as FileListRow uses for hx-target
|
||||
Inner: confirm message ("Delete file?"), filename display, "This cannot be undone." warning
|
||||
Confirm form: method="POST", action=templ.SafeURL("/tablos/"+tabloID.String()+"/files/"+file.ID.String()+"/delete"), hx-post same, hx-target="closest .file-row-zone", hx-swap="outerHTML"; @ui.CSRFField(csrfToken); @ui.Button Yes-delete with ButtonVariantDanger ButtonToneSolid
|
||||
Cancel: an anchor or button with hx-get="/tablos/"+tabloID.String()+"/files/"+file.ID.String() (if a show-file route exists) or — simpler — hx-get="/tablos/"+tabloID.String()+"/files" with hx-target="#tab-content" to reload the whole files tab and dismiss confirm; use @ui.Button Neutral Soft "Keep file"
|
||||
|
||||
Step 2 — Ensure FileListRow (from Plan 02) has the download and delete-confirm links wired to real URLs. Verify:
|
||||
Download link: anchor href="/tablos/"+tabloID.String()+"/files/"+file.ID.String()+"/download" — simple anchor, no HTMX (302 redirect is handled by browser directly)
|
||||
Delete confirm button: hx-get="/tablos/"+tabloID.String()+"/files/"+file.ID.String()+"/delete-confirm", hx-target="closest .file-row-zone", hx-swap="outerHTML" — replaces the row with confirmation inline
|
||||
|
||||
Step 3 — Run templ generate: cd backend && templ generate. Verify files_templ.go and tablos_templ.go regenerated without errors.
|
||||
|
||||
Step 4 — Run full test suite: cd backend && go test ./... -count=1 -timeout 60s. All tests must PASS. Fix any compile errors from templ regeneration or signature mismatches.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1 -timeout 60s 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
<done>
|
||||
go test ./... exits 0. All TestFile* tests PASS. FileDeleteConfirmFragment component exists in files.templ. FileListRow download and delete links point to real routes. templ generate produces no errors.
|
||||
</done>
|
||||
<acceptance_criteria>
|
||||
- go test ./... exits 0 with 0 FAIL across all packages
|
||||
- backend/templates/files.templ contains "FileDeleteConfirmFragment" with "file-row-zone" class and "ButtonVariantDanger" and "templ.SafeURL" for delete action
|
||||
- backend/templates/files.templ FileListRow contains "/download" in a link href and "/delete-confirm" in hx-get attribute
|
||||
- go test ./internal/web/ -run TestFile -v shows "--- PASS" for all six TestFile* tests
|
||||
- templ generate produces no errors (files_templ.go timestamp updated)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| FileDownloadHandler → S3 presigned URL | URL is generated server-side and not stored; TTL is 5 minutes |
|
||||
| FileDeleteHandler → dual-system delete | S3 and Postgres are separate; partial failure leaves orphan objects |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-05-03-01 | Information Disclosure | FileDownloadHandler — presigned URL exposure | mitigate | TTL set to 5 minutes via s3.PresignOptions{Expires: 5 * time.Minute} (RESEARCH Pattern 3); URL not stored in DB; generated per-request |
|
||||
| T-05-03-02 | Elevation of Privilege | FileDownloadHandler — IDOR | mitigate | loadOwnedTabloForFile verifies tablo ownership via user_id filter in GetTabloByID query; GetTabloFileByID also includes tablo_id = $2 filter — two-layer ownership check |
|
||||
| T-05-03-03 | Elevation of Privilege | FileDeleteHandler — IDOR | mitigate | Same loadOwnedTabloForFile preamble; DeleteTabloFile parameterized with both file ID AND tablo_id — non-owner cannot delete even with a valid file UUID |
|
||||
| T-05-03-04 | Denial of Service | FileDeleteHandler — partial S3 failure loop | accept | S3 delete failure is logged but does not abort; DB row is always deleted; orphan objects accumulate and are cleaned by Phase 6 worker (explicit design decision from CONTEXT.md deferred items) |
|
||||
| T-05-03-05 | Tampering | FileDeleteConfirmFragment — CSRF on delete form | mitigate | @ui.CSRFField(csrfToken) present in FileDeleteConfirmFragment form; gorilla/csrf middleware rejects POST /files/{id}/delete without valid token |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After both tasks:
|
||||
- cd backend && go test ./... -count=1 -timeout 60s exits 0 (all TestFile* PASS, all TestTask* and TestTablo* still PASS)
|
||||
- cd backend && go test ./internal/web/ -run TestFile -v shows 6x "--- PASS"
|
||||
- backend/internal/web/handlers_files.go contains no "501" (all stubs replaced)
|
||||
- backend/templates/files.templ contains FileDeleteConfirmFragment with ui.CSRFField
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. All six TestFile* tests PASS (FILE-01..06 covered)
|
||||
2. File download returns 302 to presigned URL with 5-minute TTL
|
||||
3. File delete removes DB row even when S3 delete fails; S3 failure is logged
|
||||
4. Delete confirmation uses inline pattern (same as Phase 3/4) with Cancel option
|
||||
5. Full test suite (go test ./...) exits 0 with zero failures
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create .planning/phases/05-files/05-03-SUMMARY.md
|
||||
</output>
|
||||
157
.planning/phases/05-files/05-04-PLAN.md
Normal file
157
.planning/phases/05-files/05-04-PLAN.md
Normal file
|
|
@ -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: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/ROADMAP.md §Phase 5 Success Criteria
|
||||
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.planning/phases/05-files/05-CONTEXT.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Automated pre-flight before browser session</name>
|
||||
<files></files>
|
||||
<read_first>
|
||||
backend/.env — confirm S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY, MAX_UPLOAD_SIZE_MB are set (or .env.local)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend && go test ./... -count=1 -timeout 60s && go build ./cmd/web/. && echo "PRE-FLIGHT OK"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
go test ./... exits 0. go build ./cmd/web/. exits 0. Server starts without errors.
|
||||
</done>
|
||||
<acceptance_criteria>
|
||||
- go test ./... exits 0
|
||||
- go build ./cmd/web/. exits 0
|
||||
- Server starts (no panic on startup)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<what-built>
|
||||
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:
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
**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
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if all steps pass. Describe any issues found (reference step numbers) if anything fails.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create .planning/phases/05-files/05-04-SUMMARY.md
|
||||
</output>
|
||||
Loading…
Reference in a new issue