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) { +
{ file.Filename }
++ { formatBytes(file.SizeBytes) } + if file.CreatedAt.Valid { + { file.CreatedAt.Time.Format("2006-01-02 15:04") } + } +
+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) { +{ tablo.Description.String }
+ } else { +No description.
+ } +