tenseleyflow/shithub / df90732

Browse files

Wire chi router, full middleware stack, panic-to-500, and CSRF protection

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
df9073274f7e39f770619f43aa72301eb4e479b0
Parents
81a3583
Tree
8419a55

2 changed files

StatusFile+-
M internal/web/handlers/handlers.go 80 18
M internal/web/server.go 83 20
internal/web/handlers/handlers.gomodified
@@ -2,8 +2,8 @@
22
 
33
 // Package handlers registers HTTP handlers on the web server's mux.
44
 //
5
-// S00 ships only the hello page, static asset server, and health endpoints.
6
-// Each future sprint adds its own routes via this package.
5
+// S02 ships the full chi-routed surface plus error pages. Each future
6
+// sprint adds its own routes via this package.
77
 package handlers
88
 
99
 import (
@@ -13,6 +13,10 @@ import (
1313
 	"log/slog"
1414
 	"net/http"
1515
 
16
+	"github.com/go-chi/chi/v5"
17
+
18
+	"github.com/tenseleyFlow/shithub/internal/auth/session"
19
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
1620
 	"github.com/tenseleyFlow/shithub/internal/web/render"
1721
 )
1822
 
@@ -20,37 +24,95 @@ import (
2024
 // embedded filesystems and constructs Deps; this package stays decoupled
2125
 // from the embed.FS instances so it remains testable.
2226
 type Deps struct {
23
-	Logger      *slog.Logger
24
-	TemplatesFS fs.FS
25
-	StaticFS    fs.FS
26
-	LogoSVG     string
27
+	Logger       *slog.Logger
28
+	TemplatesFS  fs.FS
29
+	StaticFS     fs.FS
30
+	LogoSVG      string
31
+	SessionStore session.Store
2732
 	// ReadyCheck is optionally invoked by /readyz. Returning a non-nil
2833
 	// error makes /readyz report 503. If nil, /readyz always reports ready.
2934
 	ReadyCheck func(context.Context) error
3035
 }
3136
 
32
-// Register wires every S00 route into mux. Later sprints' Register entrypoints
33
-// are called from here; for S00 the surface is small.
34
-func Register(mux *http.ServeMux, deps Deps) error {
37
+// panicHandler implements middleware.PanicHandler. The recover middleware
38
+// invokes it when a downstream handler panics; we render the styled 500
39
+// page through the registered renderer.
40
+type panicHandler struct {
41
+	render *render.Renderer
42
+}
43
+
44
+func (h *panicHandler) HandlePanic(w http.ResponseWriter, r *http.Request, _ string, _ any) {
45
+	h.render.HTTPError(w, r, http.StatusInternalServerError, "")
46
+}
47
+
48
+// RegisterChi wires every S02 route into r. Returns the chi.Router (for
49
+// further wiring), a panic handler that the caller installs in the
50
+// recover middleware, and a NotFound handler for the catch-all.
51
+func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http.HandlerFunc, error) {
3552
 	if deps.Logger == nil {
36
-		return fmt.Errorf("handlers.Register: nil Logger")
53
+		return nil, nil, nil, fmt.Errorf("handlers.RegisterChi: nil Logger")
3754
 	}
3855
 	if deps.TemplatesFS == nil {
39
-		return fmt.Errorf("handlers.Register: nil TemplatesFS")
56
+		return nil, nil, nil, fmt.Errorf("handlers.RegisterChi: nil TemplatesFS")
4057
 	}
4158
 	if deps.StaticFS == nil {
42
-		return fmt.Errorf("handlers.Register: nil StaticFS")
59
+		return nil, nil, nil, fmt.Errorf("handlers.RegisterChi: nil StaticFS")
4360
 	}
4461
 
45
-	r, err := render.New(deps.TemplatesFS)
62
+	rr, err := render.New(deps.TemplatesFS, render.Options{
63
+		Octicons: render.BuiltinOcticons(),
64
+	})
4665
 	if err != nil {
47
-		return fmt.Errorf("renderer: %w", err)
66
+		return nil, nil, nil, fmt.Errorf("renderer: %w", err)
4867
 	}
4968
 
50
-	mux.Handle("GET /static/", http.StripPrefix("/static/", staticFileServer(deps.StaticFS)))
51
-	mux.HandleFunc("GET /healthz", healthz)
52
-	mux.Handle("GET /readyz", readinessHandler(deps.ReadyCheck, deps.Logger))
53
-	mux.Handle("GET /{$}", helloHandler{render: r, logoSVG: deps.LogoSVG, logger: deps.Logger})
69
+	csrf := middleware.CSRF(middleware.CSRFConfig{
70
+		Secure: false, // S37 enables under TLS
71
+		FailureHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72
+			rr.HTTPError(w, r, http.StatusForbidden, "csrf")
73
+		}),
74
+	})
75
+
76
+	// Static and health endpoints are CSRF-exempt; everything else passes
77
+	// through the CSRF wrapper for state-changing methods.
78
+	r.Group(func(r chi.Router) {
79
+		r.Handle("/static/*", http.StripPrefix("/static/", staticFileServer(deps.StaticFS)))
80
+		r.Get("/healthz", healthz)
81
+		r.Handle("/readyz", readinessHandler(deps.ReadyCheck, deps.Logger))
82
+	})
5483
 
84
+	// Application routes — CSRF protected.
85
+	r.Group(func(r chi.Router) {
86
+		r.Use(csrf)
87
+		r.Get("/", helloHandler{render: rr, logoSVG: deps.LogoSVG, logger: deps.Logger}.ServeHTTP)
88
+		// /internal/panic is a dev affordance: GET it to trigger the
89
+		// panic-recovery path so an operator can confirm the styled 500
90
+		// page renders. S35 will gate this behind a dev flag.
91
+		r.Get("/internal/panic", panicTrigger)
92
+	})
93
+
94
+	notFound := func(w http.ResponseWriter, r *http.Request) {
95
+		rr.HTTPError(w, r, http.StatusNotFound, r.URL.Path)
96
+	}
97
+
98
+	return r, &panicHandler{render: rr}, notFound, nil
99
+}
100
+
101
+// Register is preserved for the existing test suite that exercises the
102
+// surface without bringing up the full server. Internally it wraps
103
+// RegisterChi and mounts the chi router on mux.
104
+func Register(mux *http.ServeMux, deps Deps) error {
105
+	r := chi.NewRouter()
106
+	_, _, notFound, err := RegisterChi(r, deps)
107
+	if err != nil {
108
+		return err
109
+	}
110
+	r.NotFound(notFound)
111
+	mux.Handle("/", r)
55112
 	return nil
56113
 }
114
+
115
+// panicTrigger panics on demand to exercise the recover middleware.
116
+func panicTrigger(_ http.ResponseWriter, _ *http.Request) {
117
+	panic("S02 panic trigger: this is intentional")
118
+}
internal/web/server.gomodified
@@ -1,13 +1,15 @@
11
 // SPDX-License-Identifier: AGPL-3.0-or-later
22
 
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.
78
 package web
89
 
910
 import (
1011
 	"context"
12
+	"encoding/base64"
1113
 	"errors"
1214
 	"fmt"
1315
 	"log/slog"
@@ -17,8 +19,13 @@ import (
1719
 	"syscall"
1820
 	"time"
1921
 
22
+	"github.com/go-chi/chi/v5"
23
+	"golang.org/x/crypto/chacha20poly1305"
24
+
25
+	"github.com/tenseleyFlow/shithub/internal/auth/session"
2026
 	"github.com/tenseleyFlow/shithub/internal/infra/db"
2127
 	"github.com/tenseleyFlow/shithub/internal/web/handlers"
28
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
2229
 )
2330
 
2431
 // Options configures the web server.
@@ -29,8 +36,8 @@ type Options struct {
2936
 // Run boots the web server and blocks until shutdown.
3037
 //
3138
 // 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.
3441
 func Run(ctx context.Context, opts Options) error {
3542
 	if opts.Addr == "" {
3643
 		opts.Addr = ":8080"
@@ -45,9 +52,12 @@ func Run(ctx context.Context, opts Options) error {
4552
 		return fmt.Errorf("load logo: %w", err)
4653
 	}
4754
 
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).
5161
 	var pool *pgxpoolHandle
5262
 	if cfg := db.Defaults().Resolve(); cfg.URL != "" {
5363
 		p, err := db.Open(ctx, cfg)
@@ -59,23 +69,40 @@ func Run(ctx context.Context, opts Options) error {
5969
 		}
6070
 	}
6171
 
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
+
6384
 	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,
6890
 	}
6991
 	if pool != nil {
7092
 		deps.ReadyCheck = pool.healthcheck
7193
 	}
72
-	if err := handlers.Register(mux, deps); err != nil {
94
+
95
+	_, panicHandler, notFoundHandler, err := handlers.RegisterChi(r, deps)
96
+	if err != nil {
7397
 		return fmt.Errorf("register handlers: %w", err)
7498
 	}
99
+	r.NotFound(notFoundHandler)
100
+
101
+	rootHandler := middleware.Recover(logger, panicHandler)(r)
75102
 
76103
 	srv := &http.Server{
77104
 		Addr:              opts.Addr,
78
-		Handler:           mux,
105
+		Handler:           rootHandler,
79106
 		ReadHeaderTimeout: 10 * time.Second,
80107
 		ReadTimeout:       30 * time.Second,
81108
 		WriteTimeout:      30 * time.Second,
@@ -115,9 +142,46 @@ func Run(ctx context.Context, opts Options) error {
115142
 	return nil
116143
 }
117144
 
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.
121185
 type pgxpoolHandle struct {
122186
 	p interface {
123187
 		Close()
@@ -125,7 +189,6 @@ type pgxpoolHandle struct {
125189
 }
126190
 
127191
 func (h *pgxpoolHandle) healthcheck(ctx context.Context) error {
128
-	// Re-open the type via the db package's typed helper.
129192
 	type pinger interface {
130193
 		Ping(context.Context) error
131194
 	}