package web import ( "context" "fmt" "io/fs" "log/slog" "net/http" "time" "backend/internal/auth" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" ) // Pinger is the contract /readyz uses to probe the data plane. *pgxpool.Pool // satisfies this interface out of the box, which is why cmd/web passes the // pool directly to NewRouter (no adapter required). type Pinger interface { Ping(ctx context.Context) error } // NewRouter constructs the chi router with the middleware stack locked by // CONTEXT D-24: // // 1. RequestIDMiddleware (UUIDv4 — NOT chi's base32 RequestID) // 2. chi RealIP // 3. SlogLoggerMiddleware (REPLACES chi's middleware.Logger — Pitfall 6) // 4. chi Recoverer (after Logger so panics carry request_id) // 5. auth.ResolveSession (reads session cookie, attaches user to context) — D-24 // 6. auth.Mount (gorilla/csrf — MUST come after ResolveSession, before routes) — D-24, Pitfall 7 // // Routes: GET / · GET /healthz (liveness) · GET /readyz (readiness) · GET /demo/time · GET /static/* // // GET /signup (auth pages, behind RedirectIfAuthed) · POST /signup. // // staticFS is the embedded FS (or os.DirFS in tests) served at /static/*; the // embedded FS pattern blocks path traversal at the http.FS layer (T-01-08). // // deps.Store may be nil during unit tests for Phase 1 routes (those routes // never exercise session resolution). ResolveSession guards against nil Store. // // csrfKey is the 32-byte CSRF authentication key loaded from SESSION_SECRET. // env is the runtime environment string (e.g. "dev", "development", "production"). // When env == "dev", the CSRF cookie Secure flag is disabled for plain-HTTP // local development (D-15, D-24). // 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, staticFS fs.FS, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, etapeDeps EtapesDeps, fileDeps FilesDeps, csrfKey []byte, env string, trustedOrigins ...string) (http.Handler, error) { r := chi.NewRouter() r.Use(RequestIDMiddleware) r.Use(chimw.RealIP) r.Use(SlogLoggerMiddleware(slog.Default())) r.Use(chimw.Recoverer) // D-24 locked order: ResolveSession BEFORE csrf.Protect (auth.Mount). r.Use(auth.ResolveSession(deps.Store)) // D-24: gorilla/csrf runs after ResolveSession and before all route groups (Pitfall 7). r.Use(auth.Mount(env, csrfKey, trustedOrigins...)) // Auth pages — redirect to / if already authenticated. r.Group(func(r chi.Router) { r.Use(auth.RedirectIfAuthed) r.Get("/signup", SignupPageHandler(deps)) r.Get("/login", LoginPageHandler(deps)) }) // Signup and login POSTs are intentionally outside the RedirectIfAuthed group: // an authed user submitting the form directly should still get a useful // response; the GET guard handles the common case. r.Post("/signup", SignupPostHandler(deps)) r.Post("/login", LoginPostHandler(deps)) r.Get("/auth/google/start", GoogleStartHandler(deps)) r.Get("/auth/google/callback", GoogleCallbackHandler(deps)) // Protected routes — require an authenticated session (D-23, AUTH-05). // RequireAuth checks the context set by ResolveSession above and redirects // unauthenticated requests to /login (HTMX: HX-Redirect, plain: 303). // Route ordering: static segments (/tablos/new) declared BEFORE parametric // (/tablos/{id}) so chi v5 resolves them correctly (Pitfall 1). r.Group(func(r chi.Router) { r.Use(auth.RequireAuth) r.Get("/", TablosListHandler(tabloDeps)) r.Post("/logout", LogoutHandler(deps)) r.Get("/account/providers", AccountProvidersHandler(deps)) // Static segments BEFORE parametric (Pitfall 1 — chi v5 route resolution). r.Get("/tablos/new", TablosNewHandler(tabloDeps)) r.Post("/tablos", TablosCreateHandler(tabloDeps)) // Parametric routes — must come after /tablos/new and /tablos POST. r.Get("/tablos/{id}", TabloDetailHandler(tabloDeps)) r.Post("/tablos/{id}", TabloUpdateHandler(tabloDeps)) r.Get("/tablos/{id}/edit-title", TabloEditTitleHandler(tabloDeps)) r.Get("/tablos/{id}/show-title", TabloShowTitleHandler(tabloDeps)) r.Get("/tablos/{id}/edit-desc", TabloEditDescHandler(tabloDeps)) r.Get("/tablos/{id}/show-desc", TabloShowDescHandler(tabloDeps)) 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. r.Get("/tablos/{id}/tasks/new", TaskNewFormHandler(taskDeps)) r.Get("/tablos/{id}/tasks/cancel-new", TaskCancelNewHandler(taskDeps)) r.Post("/tablos/{id}/tasks", TaskCreateHandler(taskDeps)) r.Post("/tablos/{id}/tasks/reorder", TaskReorderHandler(taskDeps)) r.Get("/tablos/{id}/etapes/new", EtapeNewFormHandler(etapeDeps)) r.Get("/tablos/{id}/etapes/cancel-new", EtapeCancelNewHandler(etapeDeps)) r.Post("/tablos/{id}/etapes", EtapeCreateHandler(etapeDeps)) // Parametric task routes — must come after static task segments. r.Get("/tablos/{id}/tasks/{task_id}/show", TaskShowHandler(taskDeps)) r.Get("/tablos/{id}/tasks/{task_id}/edit", TaskEditHandler(taskDeps)) 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)) }) // Liveness probe (D-12): always 200, no DB contact. r.Get("/healthz", HealthzHandler()) // Readiness probe (D-13): probes DB; 200 when ready, 503 when degraded. r.Get("/readyz", ReadyzHandler(pinger)) r.Get("/demo/time", DemoTimeHandler(func() time.Time { return time.Now() })) // Serve embedded static assets. Sub to strip the "static/" prefix from the // embedded path so /static/tailwind.css maps to static/tailwind.css inside // the FS. The "fs" local name is avoided to prevent shadowing the "io/fs" import. sub, err := fs.Sub(staticFS, "static") if err != nil { return nil, fmt.Errorf("router: failed to sub static FS: %w", err) } fileHandler := http.StripPrefix("/static/", http.FileServer(http.FS(sub))) r.Get("/static/*", fileHandler.ServeHTTP) return r, nil }