@@ -1,13 +1,15 @@ |
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | 2 | |
| 3 | | -// Package web boots the shithub HTTP server. S00 stands up only the bare |
| 4 | | -// shell — the hello page, static assets, and /healthz. S02 (web shell) |
| 5 | | -// fleshes out the middleware stack, sessions, error pages, and Primer-themed |
| 6 | | -// base templates. Every later sprint adds routes via internal/web/handlers. |
| 3 | +// Package web boots the shithub HTTP server. S02 lights up the full |
| 4 | +// middleware stack (recover, request_id, logging, real-IP, timeout, |
| 5 | +// compress, secure headers, CSRF, session, CORS), the chi router, the |
| 6 | +// session store, and the styled error pages. Every later sprint adds |
| 7 | +// routes via internal/web/handlers. |
| 7 | 8 | package web |
| 8 | 9 | |
| 9 | 10 | import ( |
| 10 | 11 | "context" |
| 12 | + "encoding/base64" |
| 11 | 13 | "errors" |
| 12 | 14 | "fmt" |
| 13 | 15 | "log/slog" |
@@ -17,8 +19,13 @@ import ( |
| 17 | 19 | "syscall" |
| 18 | 20 | "time" |
| 19 | 21 | |
| 22 | + "github.com/go-chi/chi/v5" |
| 23 | + "golang.org/x/crypto/chacha20poly1305" |
| 24 | + |
| 25 | + "github.com/tenseleyFlow/shithub/internal/auth/session" |
| 20 | 26 | "github.com/tenseleyFlow/shithub/internal/infra/db" |
| 21 | 27 | "github.com/tenseleyFlow/shithub/internal/web/handlers" |
| 28 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 22 | 29 | ) |
| 23 | 30 | |
| 24 | 31 | // Options configures the web server. |
@@ -29,8 +36,8 @@ type Options struct { |
| 29 | 36 | // Run boots the web server and blocks until shutdown. |
| 30 | 37 | // |
| 31 | 38 | // It listens for SIGINT/SIGTERM and gracefully drains in-flight requests on |
| 32 | | -// exit. The S00 surface is intentionally minimal; later sprints add the full |
| 33 | | -// middleware stack, session store, and rendering pipeline. |
| 39 | +// exit. The full middleware stack is composed here; handlers register their |
| 40 | +// routes via internal/web/handlers.RegisterChi. |
| 34 | 41 | func Run(ctx context.Context, opts Options) error { |
| 35 | 42 | if opts.Addr == "" { |
| 36 | 43 | opts.Addr = ":8080" |
@@ -45,9 +52,12 @@ func Run(ctx context.Context, opts Options) error { |
| 45 | 52 | return fmt.Errorf("load logo: %w", err) |
| 46 | 53 | } |
| 47 | 54 | |
| 48 | | - // DB pool is optional in S01: the server boots without one (the hello |
| 49 | | - // page works), but /readyz reports 503 if a DB is configured but |
| 50 | | - // unreachable. S02+ will make a pool effectively required. |
| 55 | + sessionStore, err := buildSessionStore(logger) |
| 56 | + if err != nil { |
| 57 | + return err |
| 58 | + } |
| 59 | + |
| 60 | + // Optional DB pool (carried over from S01). |
| 51 | 61 | var pool *pgxpoolHandle |
| 52 | 62 | if cfg := db.Defaults().Resolve(); cfg.URL != "" { |
| 53 | 63 | p, err := db.Open(ctx, cfg) |
@@ -59,23 +69,40 @@ func Run(ctx context.Context, opts Options) error { |
| 59 | 69 | } |
| 60 | 70 | } |
| 61 | 71 | |
| 62 | | - mux := http.NewServeMux() |
| 72 | + r := chi.NewRouter() |
| 73 | + |
| 74 | + // Middleware stack — outermost first. Recover wraps the whole pipeline |
| 75 | + // AFTER routes register so its panic handler has a renderer ready. |
| 76 | + r.Use(middleware.RequestID) |
| 77 | + r.Use(middleware.RealIP(middleware.RealIPConfig{})) |
| 78 | + r.Use(middleware.AccessLog(logger)) |
| 79 | + r.Use(middleware.SecureHeaders(middleware.DefaultSecureHeaders())) |
| 80 | + r.Use(middleware.Compress) |
| 81 | + r.Use(middleware.Timeout(30 * time.Second)) |
| 82 | + r.Use(middleware.SessionLoader(sessionStore, logger)) |
| 83 | + |
| 63 | 84 | deps := handlers.Deps{ |
| 64 | | - Logger: logger, |
| 65 | | - TemplatesFS: TemplatesFS(), |
| 66 | | - StaticFS: StaticFS(), |
| 67 | | - LogoSVG: string(logoBytes), |
| 85 | + Logger: logger, |
| 86 | + TemplatesFS: TemplatesFS(), |
| 87 | + StaticFS: StaticFS(), |
| 88 | + LogoSVG: string(logoBytes), |
| 89 | + SessionStore: sessionStore, |
| 68 | 90 | } |
| 69 | 91 | if pool != nil { |
| 70 | 92 | deps.ReadyCheck = pool.healthcheck |
| 71 | 93 | } |
| 72 | | - if err := handlers.Register(mux, deps); err != nil { |
| 94 | + |
| 95 | + _, panicHandler, notFoundHandler, err := handlers.RegisterChi(r, deps) |
| 96 | + if err != nil { |
| 73 | 97 | return fmt.Errorf("register handlers: %w", err) |
| 74 | 98 | } |
| 99 | + r.NotFound(notFoundHandler) |
| 100 | + |
| 101 | + rootHandler := middleware.Recover(logger, panicHandler)(r) |
| 75 | 102 | |
| 76 | 103 | srv := &http.Server{ |
| 77 | 104 | Addr: opts.Addr, |
| 78 | | - Handler: mux, |
| 105 | + Handler: rootHandler, |
| 79 | 106 | ReadHeaderTimeout: 10 * time.Second, |
| 80 | 107 | ReadTimeout: 30 * time.Second, |
| 81 | 108 | WriteTimeout: 30 * time.Second, |
@@ -115,9 +142,46 @@ func Run(ctx context.Context, opts Options) error { |
| 115 | 142 | return nil |
| 116 | 143 | } |
| 117 | 144 | |
| 118 | | -// pgxpoolHandle is an internal wrapper that converts the pool into the |
| 119 | | -// callback-shape the handlers package expects, without exposing pgx types |
| 120 | | -// to internal/web/handlers. It also lets us pass a nil pool through cleanly. |
| 145 | +// buildSessionStore constructs the cookie session store. The key comes from |
| 146 | +// SHITHUB_SESSION_KEY (base64 32-byte). When unset (dev), a random key is |
| 147 | +// generated and the operator is warned — sessions don't survive restart. |
| 148 | +func buildSessionStore(logger *slog.Logger) (session.Store, error) { |
| 149 | + keyB64 := os.Getenv("SHITHUB_SESSION_KEY") |
| 150 | + var key []byte |
| 151 | + if keyB64 != "" { |
| 152 | + decoded, err := base64.StdEncoding.DecodeString(keyB64) |
| 153 | + if err != nil { |
| 154 | + return nil, fmt.Errorf("session key: invalid base64: %w", err) |
| 155 | + } |
| 156 | + if len(decoded) != chacha20poly1305.KeySize { |
| 157 | + return nil, fmt.Errorf("session key: must be %d bytes, got %d", |
| 158 | + chacha20poly1305.KeySize, len(decoded)) |
| 159 | + } |
| 160 | + key = decoded |
| 161 | + } else { |
| 162 | + generated, err := session.GenerateKey() |
| 163 | + if err != nil { |
| 164 | + return nil, fmt.Errorf("session key: generate: %w", err) |
| 165 | + } |
| 166 | + key = generated |
| 167 | + logger.Warn( |
| 168 | + "session: SHITHUB_SESSION_KEY not set; generated an ephemeral key (sessions will not survive restart)", |
| 169 | + "hint", "set SHITHUB_SESSION_KEY=<base64 32-byte key> in production", |
| 170 | + ) |
| 171 | + } |
| 172 | + store, err := session.NewCookieStore(session.CookieStoreConfig{ |
| 173 | + Key: key, |
| 174 | + Secure: false, // S37 deploy enables this under TLS |
| 175 | + }) |
| 176 | + if err != nil { |
| 177 | + return nil, fmt.Errorf("session: build store: %w", err) |
| 178 | + } |
| 179 | + return store, nil |
| 180 | +} |
| 181 | + |
| 182 | +// pgxpoolHandle adapts *pgxpool.Pool's lifecycle to the small interface |
| 183 | +// /readyz needs. Defined here (not in the db package) so internal/web stays |
| 184 | +// the boundary that owns runtime wiring. |
| 121 | 185 | type pgxpoolHandle struct { |
| 122 | 186 | p interface { |
| 123 | 187 | Close() |
@@ -125,7 +189,6 @@ type pgxpoolHandle struct { |
| 125 | 189 | } |
| 126 | 190 | |
| 127 | 191 | func (h *pgxpoolHandle) healthcheck(ctx context.Context) error { |
| 128 | | - // Re-open the type via the db package's typed helper. |
| 129 | 192 | type pinger interface { |
| 130 | 193 | Ping(context.Context) error |
| 131 | 194 | } |