diff --git a/backend/cmd/web/main.go b/backend/cmd/web/main.go index 7986571..7d164c6 100644 --- a/backend/cmd/web/main.go +++ b/backend/cmd/web/main.go @@ -14,12 +14,14 @@ import ( "net/http" "os" "os/signal" + "strconv" "syscall" "time" "backend/internal/auth" "backend/internal/db" "backend/internal/db/sqlc" + "backend/internal/files" "backend/internal/web" ) @@ -79,7 +81,39 @@ func main() { tabloDeps := web.TablosDeps{Queries: q} taskDeps := web.TasksDeps{Queries: q} - router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, csrfKey, env) + // S3 / files store (D-02: read env vars and pass to files.NewStore). + s3Endpoint := os.Getenv("S3_ENDPOINT") + s3Bucket := os.Getenv("S3_BUCKET") + s3AccessKey := os.Getenv("S3_ACCESS_KEY") + s3SecretKey := os.Getenv("S3_SECRET_KEY") + s3Region := os.Getenv("S3_REGION") + if s3Region == "" { + s3Region = "us-east-1" + } + s3UsePathStyle := os.Getenv("S3_USE_PATH_STYLE") == "true" + + maxUploadMB := 25 + if v := os.Getenv("MAX_UPLOAD_SIZE_MB"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + maxUploadMB = n + } + } + + var filesStore web.FileStorer + if s3Endpoint != "" && s3Bucket != "" { + var fsErr error + filesStore, fsErr = files.NewStore(ctx, s3Endpoint, s3Bucket, s3Region, s3AccessKey, s3SecretKey, s3UsePathStyle) + if fsErr != nil { + slog.Error("s3 client init failed", "err", fsErr) + os.Exit(1) + } + } else { + slog.Warn("S3_ENDPOINT or S3_BUCKET unset — file upload routes will return 503") + } + + fileDeps := web.FilesDeps{Queries: q, Files: filesStore, MaxUploadMB: maxUploadMB} + + router := web.NewRouter(pool, "./static", deps, tabloDeps, taskDeps, fileDeps, csrfKey, env) srv := &http.Server{ Addr: ":" + port, diff --git a/backend/internal/web/csrf_test.go b/backend/internal/web/csrf_test.go index c81f712..184974f 100644 --- a/backend/internal/web/csrf_test.go +++ b/backend/internal/web/csrf_test.go @@ -22,7 +22,7 @@ func newTestRouterWithCSRF(q *sqlc.Queries, store *auth.Store) http.Handler { csrfKey[i] = byte(i + 1) } deps := AuthDeps{Queries: q, Store: store, Secure: false} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, csrfKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, csrfKey, "dev", "localhost") } // extractCSRFToken performs a GET request and extracts the _csrf token from the diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 7479597..87e2e50 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -33,14 +33,14 @@ var testCSRFKey = func() []byte { // Referer header are accepted. func newTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // newTestRouterWithLimiter builds a router with an injected LimiterStore, // enabling rate-limit tests to use a fake clock. func newTestRouterWithLimiter(q *sqlc.Queries, store *auth.Store, rl *auth.LimiterStore) http.Handler { deps := AuthDeps{Queries: q, Store: store, Secure: false, Limiter: rl} - return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", deps, TablosDeps{Queries: q}, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // getCSRFToken performs a GET request to path and extracts the CSRF token diff --git a/backend/internal/web/handlers_tablos.go b/backend/internal/web/handlers_tablos.go index 86799b0..21d2a25 100644 --- a/backend/internal/web/handlers_tablos.go +++ b/backend/internal/web/handlers_tablos.go @@ -202,7 +202,7 @@ func TabloDetailHandler(deps TablosDeps) http.HandlerFunc { tasks = []sqlc.Task{} } w.Header().Set("Content-Type", "text/html; charset=utf-8") - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks).Render(r.Context(), w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, "overview").Render(r.Context(), w) } } @@ -308,7 +308,7 @@ func TabloUpdateHandler(deps TablosDeps) http.HandlerFunc { if tasks == nil { tasks = []sqlc.Task{} } - _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks).Render(ctx, w) + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo, tasks, nil, "overview").Render(ctx, w) return } diff --git a/backend/internal/web/handlers_tablos_test.go b/backend/internal/web/handlers_tablos_test.go index 2727b89..0e86425 100644 --- a/backend/internal/web/handlers_tablos_test.go +++ b/backend/internal/web/handlers_tablos_test.go @@ -26,7 +26,7 @@ import ( func newTabloTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} - return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, TasksDeps{Queries: q}, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, TasksDeps{Queries: q}, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // loginUser signs up a user and returns the session cookie set after signup. diff --git a/backend/internal/web/handlers_tasks_test.go b/backend/internal/web/handlers_tasks_test.go index 516c194..989f49f 100644 --- a/backend/internal/web/handlers_tasks_test.go +++ b/backend/internal/web/handlers_tasks_test.go @@ -29,7 +29,7 @@ func newTaskTestRouter(q *sqlc.Queries, store *auth.Store) http.Handler { authDeps := AuthDeps{Queries: q, Store: store, Secure: false} tabloDeps := TablosDeps{Queries: q} taskDeps := TasksDeps{Queries: q} - return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, testCSRFKey, "dev", "localhost") + return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, FilesDeps{Queries: q}, testCSRFKey, "dev", "localhost") } // ---- TestTasksKanbanRenders (TASK-01) ---- diff --git a/backend/internal/web/handlers_test.go b/backend/internal/web/handlers_test.go index fdbc32c..54cf20d 100644 --- a/backend/internal/web/handlers_test.go +++ b/backend/internal/web/handlers_test.go @@ -66,7 +66,7 @@ func TestHealthz_Down(t *testing.T) { // was public. The HTMX demo content is tested by // TestProtected_HomeAuthRendersUserEmail in handlers_auth_test.go. func TestIndex_UnauthRedirects(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -81,7 +81,7 @@ func TestIndex_UnauthRedirects(t *testing.T) { } func TestDemoTime_Fragment(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/demo/time", nil) @@ -104,7 +104,7 @@ func TestDemoTime_Fragment(t *testing.T) { } func TestRequestID_HeaderSet(t *testing.T) { - router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, testCSRFKey, "dev") + router := NewRouter(stubPinger{}, "./static", AuthDeps{}, TablosDeps{}, TasksDeps{}, FilesDeps{}, testCSRFKey, "dev") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index fa56d78..4baa004 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -44,7 +44,7 @@ type Pinger interface { // trustedOrigins is an optional list of additional origins for the CSRF // referer check (used in integration tests to allow localhost requests without // a Referer header). In production, pass no extra args — leave empty. -func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { +func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler { r := chi.NewRouter() r.Use(RequestIDMiddleware) r.Use(chimw.RealIP) @@ -90,6 +90,8 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps)) r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps)) r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps)) + // Tasks tab entry point — must be BEFORE static task sub-routes (Pitfall 1). + r.Get("/tablos/{id}/tasks", TabloTasksTabHandler(fileDeps)) // Task routes — static segments BEFORE parametric (Pitfall 1). // /tablos/{id}/tasks/new and /tablos/{id}/tasks/cancel-new are static // segments relative to /tablos/{id}/tasks/* and must come first. @@ -103,6 +105,13 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD r.Post("/tablos/{id}/tasks/{task_id}", TaskUpdateHandler(taskDeps)) r.Get("/tablos/{id}/tasks/{task_id}/delete-confirm", TaskDeleteConfirmHandler(taskDeps)) r.Post("/tablos/{id}/tasks/{task_id}/delete", TaskDeleteHandler(taskDeps)) + // File routes — static segments BEFORE parametric (Pitfall 6 in RESEARCH). + r.Get("/tablos/{id}/files", TabloFilesTabHandler(fileDeps)) + r.Post("/tablos/{id}/files", FileUploadHandler(fileDeps)) + // Parametric file routes — AFTER static file segment. + r.Get("/tablos/{id}/files/{file_id}/download", FileDownloadHandler(fileDeps)) + r.Get("/tablos/{id}/files/{file_id}/delete-confirm", FileDeleteConfirmHandler(fileDeps)) + r.Post("/tablos/{id}/files/{file_id}/delete", FileDeleteHandler(fileDeps)) }) r.Get("/healthz", HealthzHandler(pinger)) diff --git a/backend/templates/files.templ b/backend/templates/files.templ new file mode 100644 index 0000000..d35c8d6 --- /dev/null +++ b/backend/templates/files.templ @@ -0,0 +1,130 @@ +package templates + +import ( + "backend/internal/db/sqlc" + "backend/internal/web/ui" + "github.com/google/uuid" +) + +// FilesTabFragment renders the upload form followed by the file list. +// Called by TabloFilesTabHandler for HTMX requests and embedded in TabloDetailPage +// #tab-content for the "files" tab. +templ FilesTabFragment(tablo sqlc.Tablo, files []sqlc.TabloFile, csrfToken string) { +
+ @FileUploadForm(tablo.ID, csrfToken, "") +
+ if len(files) == 0 { + @FileListEmpty() + } else { + + } +
+
+} + +// FileUploadForm renders the multipart upload form. +// hx-encoding="multipart/form-data" is required for HTMX multipart submissions. +// If uploadErr is non-empty, a red error message is shown above the form. +templ FileUploadForm(tabloID uuid.UUID, csrfToken string, uploadErr string) { +
+ @ui.CSRFField(csrfToken) + if uploadErr != "" { +
+ { uploadErr } +
+ } +
+ + +
+ @ui.Button(ui.ButtonProps{ + Label: "Upload", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + Type: "submit", + }) +
+} + +// FileListRow renders a single file row showing filename, human-readable size, and date. +// Download link points to GET /tablos/{tabloID}/files/{file.ID}/download (wired in Plan 03). +// Delete confirm link uses HTMX outerHTML swap on .file-row-zone (Plan 03). +templ FileListRow(tabloID uuid.UUID, file sqlc.TabloFile) { +
  • +
    +
    +

    { file.Filename }

    +

    + { formatBytes(file.SizeBytes) } + if file.CreatedAt.Valid { + { file.CreatedAt.Time.Format("2006-01-02 15:04") } + } +

    +
    +
    + Download + +
    +
    +
  • +} + +// FileListEmpty renders the empty-state message when no files are attached. +templ FileListEmpty() { +

    No files attached yet.

    +} + +// FileRowGone renders an empty zone div with the file's id so HTMX outerHTML +// swap removes the row from the DOM after a successful delete (Plan 03, FILE-05). +// Same pattern as TaskCardGone. +templ FileRowGone(fileID uuid.UUID) { +
    +} + +// UploadErrorFragment re-renders FilesTabFragment with the error message set. +// Used by FileUploadHandler on size violation (returns 422). +templ UploadErrorFragment(tablo sqlc.Tablo, files []sqlc.TabloFile, csrfToken string, errMsg string) { +
    + @FileUploadForm(tablo.ID, csrfToken, errMsg) +
    + if len(files) == 0 { + @FileListEmpty() + } else { + + } +
    +
    +} diff --git a/backend/templates/files_helpers.go b/backend/templates/files_helpers.go new file mode 100644 index 0000000..42a4684 --- /dev/null +++ b/backend/templates/files_helpers.go @@ -0,0 +1,23 @@ +package templates + +import "fmt" + +// formatBytes converts a byte count to a human-readable string. +// Examples: 512 → "512 B", 1536 → "1.5 KB", 3670016 → "3.5 MB". +func formatBytes(n int64) string { + const ( + kb = 1024 + mb = 1024 * kb + gb = 1024 * mb + ) + switch { + case n < kb: + return fmt.Sprintf("%d B", n) + case n < mb: + return fmt.Sprintf("%.1f KB", float64(n)/kb) + case n < gb: + return fmt.Sprintf("%.1f MB", float64(n)/mb) + default: + return fmt.Sprintf("%.1f GB", float64(n)/gb) + } +} diff --git a/backend/templates/tablos.templ b/backend/templates/tablos.templ index 17e9a9e..596ee57 100644 --- a/backend/templates/tablos.templ +++ b/backend/templates/tablos.templ @@ -166,11 +166,12 @@ templ TabloCardWithOOBFormClear(tablo sqlc.Tablo, csrfToken string) {
    } -// TabloDetailPage renders the full detail page for a single tablo. -// Includes title zone, description zone, delete zone, and the kanban board. +// TabloDetailPage renders the full detail page for a single tablo with a 3-tab layout. +// Tabs: Overview / Tasks / Files. activeTab selects the initially rendered tab content. +// files and tasks are pre-fetched slices for the active tab (may be nil for inactive tabs). // UI-SPEC §3 Interaction Contract — GET /tablos/{id}. -// tasks is the pre-fetched list of tasks for this tablo (may be empty slice). -templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task) { +// D-07: signature includes activeTab string param; D-08: tab bar links carry hx-push-url. +templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks []sqlc.Task, files []sqlc.TabloFile, activeTab string) { @Layout("Tablos — Xtablo", user, csrfToken) {
    ← Back to tablos @@ -184,12 +185,77 @@ templ TabloDetailPage(user *auth.User, csrfToken string, tablo sqlc.Tablo, tasks
    @TabloDeleteButtonFragment(tablo, csrfToken)
    -
    - @KanbanBoard(tablo.ID, csrfToken, tasks) + + + +
    + if activeTab == "tasks" { + @TasksTabFragment(tablo, tasks, csrfToken) + } else if activeTab == "files" { + @FilesTabFragment(tablo, files, csrfToken) + } else { + @TabloOverviewTabFragment(tablo, csrfToken) + }
    } } +// TabloOverviewTabFragment renders the overview tab content — description display. +// Returned as a standalone fragment for HTMX tab-switch responses. +templ TabloOverviewTabFragment(tablo sqlc.Tablo, csrfToken string) { +
    + if tablo.Description.Valid && tablo.Description.String != "" { +

    { tablo.Description.String }

    + } else { +

    No description.

    + } +
    +} + +// TasksTabFragment wraps the KanbanBoard for use as a standalone HTMX tab fragment. +// Returned by TabloTasksTabHandler on HX-Request == "true". +// Lives in tablos.templ (tablo-level concern) per plan D-07. +templ TasksTabFragment(tablo sqlc.Tablo, tasks []sqlc.Task, csrfToken string) { + @KanbanBoard(tablo.ID, csrfToken, tasks) +} + // TabloTitleDisplay renders the tablo title as a clickable element that swaps // to the edit form on click. The outermost element carries class tablo-title-zone // so hx-target="closest .tablo-title-zone" + hx-swap="outerHTML" round-trips cleanly.