xtablo-source/backend/internal/web/router.go
Arthur Belleville 181ae79369
feat(04-02): TasksDeps, task handlers, router task routes
- Add handlers_tasks.go: TasksDeps, TaskNewFormHandler, TaskCancelNewHandler, TaskCreateHandler, TaskShowHandler, TaskDeleteConfirmHandler, TaskDeleteHandler, plus stub Edit/Update/Reorder handlers
- Add task routes to router.go (static before parametric per Pitfall 1)
- Add TasksDeps param to NewRouter; update main.go and all test callers
- Move TaskColumns/TaskColumnLabels to templates package to avoid import cycle
2026-05-15 09:31:59 +02:00

115 lines
5.4 KiB
Go

package web
import (
"context"
"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 /healthz 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 · GET /demo/time · GET /static/*
// GET /signup (auth pages, behind RedirectIfAuthed) · POST /signup.
// staticDir is the on-disk path served at /static/*; path traversal is
// blocked by http.Dir's default behavior (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, staticDir string, deps AuthDeps, tabloDeps TablosDeps, taskDeps TasksDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler {
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())
r.Get("/login", LoginPageHandler())
})
// 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))
// 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))
// 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))
// 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))
// 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))
})
r.Get("/healthz", HealthzHandler(pinger))
r.Get("/demo/time", DemoTimeHandler(func() time.Time { return time.Now() }))
fs := http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir)))
r.Get("/static/*", fs.ServeHTTP)
return r
}