- auth.Mount(env, key) wraps csrf.Protect with locked D-14/D-24 options - auth.LoadKeyFromEnv() reads SESSION_SECRET, hex-decodes, validates 32 bytes; fails fast on error - ui.CSRFField(token) templ component renders hidden _csrf input - Layout, LoginPage/Fragment, SignupPage/Fragment, Index all embed @ui.CSRFField(csrfToken) - Handlers thread csrf.Token(r) into every page/fragment render call - NewRouter mounts auth.Mount after ResolveSession, before all route groups (D-24) - main.go calls auth.LoadKeyFromEnv(); logs.Fatalf on missing/invalid SESSION_SECRET - SESSION_SECRET documented in .env.example with openssl rand -hex 32 instruction - go.mod: gorilla/csrf v1.7.3 (direct); prior tests updated with getCSRFToken helper - All Plan 04/05/06 tests updated to acquire and submit valid _csrf tokens
115 lines
3.2 KiB
Go
115 lines
3.2 KiB
Go
// Command web is the Phase 1 walking-skeleton HTTP server. It loads env,
|
|
// builds a slog handler, opens a pgxpool, mounts the chi router, and serves
|
|
// /, /healthz, /demo/time, and /static/* with graceful shutdown on
|
|
// SIGINT/SIGTERM (CONTEXT D-19).
|
|
//
|
|
// No .env parser lives here — `.env` is exported into the process
|
|
// environment by `just dev`; production injects real env vars (D-15).
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"backend/internal/auth"
|
|
"backend/internal/db"
|
|
"backend/internal/db/sqlc"
|
|
"backend/internal/web"
|
|
)
|
|
|
|
func main() {
|
|
env := os.Getenv("ENV")
|
|
if env == "" {
|
|
env = "development"
|
|
}
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
dsn := os.Getenv("DATABASE_URL")
|
|
|
|
// Logger first so even fatal-on-missing-DSN paths produce structured
|
|
// output. Per Pattern 3: JSON in production, text everywhere else.
|
|
slog.SetDefault(slog.New(web.NewSlogHandler(env, os.Stdout)))
|
|
|
|
if dsn == "" {
|
|
slog.Error("DATABASE_URL is required but unset")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Load the CSRF authentication key from SESSION_SECRET env var (D-15).
|
|
// Fails fast with a clear message if missing or wrong length — the server
|
|
// cannot operate without a valid CSRF key (AUTH-06).
|
|
csrfKey, err := auth.LoadKeyFromEnv()
|
|
if err != nil {
|
|
slog.Error("invalid SESSION_SECRET", "err", err,
|
|
"hint", "generate with: openssl rand -hex 32")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// signal.NotifyContext (Go 1.21+) is the canonical idiom — equivalent
|
|
// to signal.Notify + a channel but the resulting ctx propagates the
|
|
// cancellation through to handlers, pgxpool dialing, etc.
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
pool, err := db.NewPool(ctx, dsn)
|
|
if err != nil {
|
|
// T-01-12: never log the DSN — only the error type/message.
|
|
slog.Error("db connect failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
q := sqlc.New(pool)
|
|
store := auth.NewStore(q)
|
|
secure := env != "development" && env != "dev"
|
|
|
|
// Rate limiter for POST /login (D-16, AUTH-07).
|
|
rl := auth.NewLimiterStore()
|
|
stopJanitor := make(chan struct{})
|
|
rl.StartJanitor(time.Minute, stopJanitor)
|
|
|
|
deps := web.AuthDeps{Queries: q, Store: store, Secure: secure, Limiter: rl}
|
|
|
|
router := web.NewRouter(pool, "./static", deps, csrfKey, env)
|
|
|
|
srv := &http.Server{
|
|
Addr: ":" + port,
|
|
Handler: router,
|
|
// T-01-10 slow-client mitigation per RESEARCH Security Domain.
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 15 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
slog.Info("listening", "addr", srv.Addr, "env", env)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
slog.Error("server error", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
slog.Info("shutting down")
|
|
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
slog.Error("shutdown error", "err", err)
|
|
}
|
|
|
|
// Stop the rate-limiter janitor goroutine after HTTP server is fully shut down.
|
|
close(stopJanitor)
|
|
|
|
// Pitfall 4: close the pool AFTER Shutdown returns, NOT via defer in
|
|
// main — defer ordering is unreliable on fatal-exit paths.
|
|
pool.Close()
|
|
slog.Info("shutdown complete")
|
|
}
|