Documentation
¶
Overview ¶
Package middleware provides shithub's HTTP middleware stack. Composable with chi/net.http; each middleware exposes its own constructor.
Index ¶
- Constants
- func AccessLog(logger *slog.Logger) func(http.Handler) http.Handler
- func CORS(cfg CORSConfig) func(http.Handler) http.Handler
- func CSRF(cfg CSRFConfig) func(http.Handler) http.Handler
- func CSRFTokenForRequest(r *http.Request) string
- func Compress(next http.Handler) http.Handler
- func CountQueries() func(http.Handler) http.Handler
- func MaxBodySize(maxBytes int64) func(http.Handler) http.Handler
- func Metrics(next http.Handler) http.Handler
- func OptionalUser(lookup UserLookup) func(http.Handler) http.Handler
- func PATAuthMiddleware(cfg PATConfig) func(http.Handler) http.Handler
- func PolicyCache() func(http.Handler) http.Handler
- func QueriesFor(r *http.Request) int64
- func RealIP(cfg RealIPConfig) func(http.Handler) http.Handler
- func RealIPFromContext(ctx context.Context, r *http.Request) string
- func Recover(logger *slog.Logger, handler PanicHandler) func(http.Handler) http.Handler
- func RequestID(next http.Handler) http.Handler
- func RequestIDFromContext(ctx context.Context) string
- func RequireScope(required pat.Scope) func(http.Handler) http.Handler
- func RequireSiteAdmin(notFound http.Handler) func(http.Handler) http.Handler
- func RequireUser(next http.Handler) http.Handler
- func SecureHeaders(cfg SecureHeadersConfig) func(http.Handler) http.Handler
- func SessionFromContext(ctx context.Context) *session.Session
- func SessionLoader(store session.Store, logger *slog.Logger) func(http.Handler) http.Handler
- func SessionStoreFromContext(ctx context.Context) session.Store
- func Timeout(d time.Duration) func(http.Handler) http.Handler
- func WithCurrentUserForTest(ctx context.Context, u CurrentUser) context.Context
- type CORSConfig
- type CSRFConfig
- type CurrentUser
- type PATAuth
- type PATConfig
- type PanicHandler
- type RealIPConfig
- type SecureHeadersConfig
- type UserLookup
- type UserLookupResult
Constants ¶
const RequestIDHeader = "X-Request-Id"
RequestIDHeader is the canonical incoming/outgoing request-ID header.
Variables ¶
This section is empty.
Functions ¶
func AccessLog ¶
AccessLog returns middleware that emits one structured log line per request after the response is written. Includes request_id when present.
func CORS ¶
func CORS(cfg CORSConfig) func(http.Handler) http.Handler
CORS returns middleware that enforces the policy. Requests with an `Origin` header are checked against AllowedOrigins; OPTIONS preflights short-circuit with the appropriate response headers.
func CSRF ¶
func CSRF(cfg CSRFConfig) func(http.Handler) http.Handler
CSRF returns middleware that enforces nosurf's double-submit-cookie protection. State-changing methods without a valid token are rejected.
func CSRFTokenForRequest ¶
CSRFTokenForRequest exposes the per-request token to templates.
func Compress ¶
Compress returns middleware that gzip-encodes responses for clients that advertise gzip support, bypassing already-compressed responses (images, pre-compressed git pack data, etc.). The middleware is intentionally modest — chi/Negroni-grade compression suites are overkill here.
Routes that stream (git protocol in S12) must skip this middleware via a dedicated route group, since wrapping ResponseWriter breaks Flusher guarantees the streaming pack relies on.
func CountQueries ¶
CountQueries returns middleware that wraps every request context with db.WithCounter so the per-request query count is observable downstream.
Production: install at the router root in test builds only — it adds one context.WithValue per request; cheap, but conceptually reserved for the test surface.
Test usage:
r.Use(middleware.CountQueries())
rec := httptest.NewRecorder()
req := httptest.NewRequest(...)
r.ServeHTTP(rec, req)
if got := middleware.QueriesFor(req); got > 8 {
t.Fatalf("issuesList ran %d queries; threshold 8", got)
}
The threshold is per-route; callers document it in the test.
func MaxBodySize ¶
MaxBodySize returns middleware that caps r.Body at maxBytes via http.MaxBytesReader. Use this on auth POST endpoints (signup, login, password reset) so a misbehaving client can't ship a 10 MB password to weaponize the argon2 hashing path.
func Metrics ¶
Metrics returns middleware that records HTTP-level metrics via the project-wide Prometheus registry.
Route labels are extracted from chi when available so we get "/owner/{repo}" instead of the per-repo concrete path — keeping cardinality bounded.
func OptionalUser ¶
func OptionalUser(lookup UserLookup) func(http.Handler) http.Handler
OptionalUser populates CurrentUser into context from the loaded session when present. Does not redirect or reject — pages that don't need a user (homepage, public repo views) just ignore an empty CurrentUser.
When lookup returns successfully and the user's current session_epoch does NOT match the session's recorded epoch, the binding is skipped so the request looks anonymous. The stale cookie itself isn't actively cleared — RequireUser will redirect the next protected hit to /login, at which point a fresh session is minted with the current epoch.
Pass nil to skip the lookup entirely (CurrentUser.Username will be empty and epoch checks are bypassed).
func PATAuthMiddleware ¶
PATAuthMiddleware returns middleware that resolves a `Authorization: token ...`, `Authorization: Bearer ...`, or HTTP Basic credential into a populated PATAuth on the request context.
Behavior:
- Missing Authorization header → next handler runs with an empty PATAuth (the route may still allow session auth).
- Malformed credentials, unknown hash, revoked, or expired token → 401 with WWW-Authenticate set; chain stops here.
- On success → PATAuth populated on context, last-used updated (debounced), chain proceeds.
func PolicyCache ¶
PolicyCache attaches a per-request memo to ctx so the policy package can de-duplicate (actor, repo) → role lookups across the handler chain. Wire this once near the top of the chain, after the request has been bound to a session.
Mutations that change collaborator state mid-request must call policy.InvalidateRepo(ctx, repoID) before re-checking, or the cache will return a stale role.
func QueriesFor ¶
QueriesFor reports the number of queries observed against the request's context. Returns 0 when CountQueries wasn't installed.
Reads the COUNTER from the request's context after the handler has returned; the counter is mutated by goroutines spawned during the request, but those should have synchronized before the handler returns.
func RealIP ¶
func RealIP(cfg RealIPConfig) func(http.Handler) http.Handler
RealIP returns middleware that resolves the client IP by walking the X-Forwarded-For header rightwards, accepting only entries that come from a configured trusted-proxy network. The resolved IP is stored in context for later middleware (access log, rate limit) to consume.
func RealIPFromContext ¶
RealIPFromContext returns the real IP set by the middleware, falling back to the request's RemoteAddr (host part) when no value has been resolved.
func Recover ¶
Recover returns middleware that catches panics, logs them with the request_id, and delegates rendering to handler. If handler is nil a plain "internal server error" body is written.
func RequestID ¶
RequestID returns middleware that ensures every request has a request_id. If the inbound request carries an X-Request-Id header it's reused (capped to a sane length); otherwise we generate a 16-byte random hex string. The id is propagated via context and echoed in the response headers so a caller can correlate logs with their own traces.
func RequestIDFromContext ¶
RequestIDFromContext returns the request_id, or "" if none is set.
func RequireScope ¶
RequireScope rejects with 403 if the request was authenticated via PAT and the token's scopes don't include required. Pure-session callers (PATAuth zero) pass through — sessions have implicit full scope.
func RequireSiteAdmin ¶
RequireSiteAdmin gates routes behind users.is_site_admin. Compose AFTER RequireUser so an anonymous request hits /login first; this middleware then guards the elevated surface.
Non-admin viewers receive 404 (NOT 403) so the existence of /admin isn't disclosed by the response shape. The same pattern matches every other "shouldn't-know-it-exists" surface across the app.
Impersonating admins lose their admin powers for the duration of the impersonation (CurrentUser.IsSiteAdmin is forced false in that path), so this gate transparently locks them out of /admin until they end the impersonation. Documented in docs/internal/admin.md.
func RequireUser ¶
RequireUser is a stricter wrapper: redirects to /login (preserving the requested path in the `next` query param) when there is no user bound to the session. Pages that demand a user (settings, dashboard) compose this on top of OptionalUser.
func SecureHeaders ¶
func SecureHeaders(cfg SecureHeadersConfig) func(http.Handler) http.Handler
SecureHeaders returns middleware that stamps the configured security headers on every response. HSTS is only set when the request reached us over TLS or via a trusted X-Forwarded-Proto=https proxy header.
func SessionFromContext ¶
SessionFromContext returns the loaded session, or an empty Session if the loader middleware didn't run (which would itself be a wiring bug).
func SessionLoader ¶
SessionLoader returns middleware that loads the session into request context before downstream handlers run. Save happens via the store — handlers that mutate the session call store.Save explicitly.
func SessionStoreFromContext ¶
SessionStoreFromContext returns the session store associated with the request. Handlers that need to Save / Clear pull it out via this helper.
func Timeout ¶
Timeout returns middleware that enforces a per-request timeout via context cancellation. Handlers must respect the request context.
Streaming routes (git protocol in S12) MUST be exempt from this middleware via a separate route group; the timeout-aware ResponseWriter breaks Flusher and would corrupt streaming pack data.
func WithCurrentUserForTest ¶
func WithCurrentUserForTest(ctx context.Context, u CurrentUser) context.Context
WithCurrentUserForTest binds u onto ctx the same way the OptionalUser middleware does. Test-only — production code paths reach a request through OptionalUser/RequireUser and never need this. Lives here so the context key stays unexported.
Types ¶
type CORSConfig ¶
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
MaxAge int
}
CORSConfig holds the strict CORS policy. By default no cross-origin requests are permitted; route groups that need CORS register an explicit whitelist (typically just the API surface in S08).
type CSRFConfig ¶
type CSRFConfig struct {
// CookieDomain is optional.
CookieDomain string
// Secure marks the CSRF cookie Secure (set when serving over TLS).
Secure bool
// FailureHandler is invoked when CSRF validation fails. If nil, a
// minimal 403 plain-text response is written.
FailureHandler http.Handler
// ExemptFunc returns true for requests that should bypass CSRF
// validation. State-changing endpoints authenticated by PAT (S08) and
// the git protocol (S12/S13) register exemptions here.
ExemptFunc func(*http.Request) bool
}
CSRFConfig configures the CSRF middleware.
type CurrentUser ¶
type CurrentUser struct {
ID int64
Username string
IsSuspended bool
IsSiteAdmin bool
// ImpersonatedUserID, when non-zero, identifies the user this admin
// is impersonating. ID/Username/IsSuspended above reflect the
// IMPERSONATED user (so policy checks behave as that user); the
// real admin's ID is preserved in RealActorID for audit rows.
ImpersonatedUserID int64
RealActorID int64
ImpersonateWriteOK bool
}
CurrentUser carries the loaded user identity in request context. Handlers pull this out via CurrentUserFromContext. Anonymous requests have ID == 0.
IsSuspended is sourced from users.suspended_at and is the canonical input to policy.UserActor for web requests. Without it, every handler that constructs an actor with a hard-coded `false` lets a suspended account keep writing — the audit found this gap (S00-S25 audit, finding C1).
func CurrentUserFromContext ¶
func CurrentUserFromContext(ctx context.Context) CurrentUser
CurrentUserFromContext returns the user bound to ctx by OptionalUser / RequireUser. Returns the zero value (anonymous) when no user is bound.
func (CurrentUser) IsAnonymous ¶
func (u CurrentUser) IsAnonymous() bool
IsAnonymous reports whether this is an unauthenticated request.
type PATAuth ¶
PATAuth carries the resolved-token state for downstream handlers. When the auth check passed via PAT, `Token != nil` and Scopes is the parsed scope list. Pure session callers see the zero value.
func PATAuthFromContext ¶
PATAuthFromContext returns the resolved PAT auth state, or the zero value when the request was authenticated some other way (or anonymous).
type PATConfig ¶
type PATConfig struct {
Pool *pgxpool.Pool
Debouncer *pat.Debouncer // optional; one is created if nil
Logger *slog.Logger
// Realm is the WWW-Authenticate realm string written on 401.
Realm string
}
PATConfig configures the PAT auth middleware.
type PanicHandler ¶
type PanicHandler interface {
HandlePanic(w http.ResponseWriter, r *http.Request, requestID string, recovered any)
}
PanicHandler renders a styled error response when the request handler panics. The error page receives the request_id so users can quote it for support.
type RealIPConfig ¶
type RealIPConfig struct {
// TrustedProxies is the list of CIDR blocks whose X-Forwarded-For values
// are honored. If empty, the middleware is a no-op (RemoteAddr wins).
TrustedProxies []*net.IPNet
}
RealIPConfig configures the real-IP middleware. Trust nothing by default; the operator opts into specific proxy networks.
type SecureHeadersConfig ¶
type SecureHeadersConfig struct {
CSP string // Content-Security-Policy
HSTS string // Strict-Transport-Security; only set when TLS
ReferrerPolicy string
PermissionsPol string
FrameOptions string
ContentTypeOpts string
COOP string
CORP string
}
SecureHeadersConfig lets the operator override defaults. Values left as the zero string fall back to the documented defaults.
func DefaultSecureHeaders ¶
func DefaultSecureHeaders() SecureHeadersConfig
DefaultSecureHeaders returns the policy applied to web responses. S35 will lock these down further; S02's defaults are the practical baseline.
type UserLookup ¶
type UserLookup func(ctx context.Context, userID int64) (UserLookupResult, error)
UserLookup resolves a user_id into the data the auth middleware needs. SessionEpoch is the users.session_epoch column; the middleware compares it to the session's recorded epoch on every request so "log out everywhere" (which bumps the column) invalidates stale cookies on the next hit.
type UserLookupResult ¶
type UserLookupResult struct {
Username string
SessionEpoch int32
IsSuspended bool
IsSiteAdmin bool
}
UserLookupResult is what UserLookup returns. It's a struct so future fields (e.g. is_admin once S34 lands) don't keep widening the signature and forcing every callsite to update.