Files
BountyBoard/internal/http/middleware.go
T
etalon 34bf5b5ac2 feat: base UI shell with themes, auth pages, profile, and role navigation (phase 4)
- embedded Go templates + vanilla ES-module JS + hand-written CSS, no build step
- exact §10 beige light / dark design tokens, square edges (radius 2px),
  system font stack, visible focus rings
- theme toggle persisted to localStorage and the user profile
- login/register/change-password pages wired to the auth API
- profile page: avatar upload (image-sniffed, old file cleanup), bio,
  contacts, arbitrary extra key/value fields, optimistic-concurrency 409
- role-based top navigation with placeholders for later-phase areas
- GET /files/{id} with scope-based access (session) or signed token (§5.1)
- security headers incl. CSP without unsafe-inline scripts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:39:38 +02:00

139 lines
4.1 KiB
Go

package httpx
import (
"context"
"fmt"
"net/http"
"net/netip"
"runtime/debug"
"time"
"bountyboard/internal/ulid"
)
type ctxKey int
const (
ctxKeyRequestID ctxKey = iota
ctxKeyClient
)
// RequestID returns the id assigned to this request by the middleware chain.
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(ctxKeyRequestID).(string)
return id
}
// Client returns the trusted-proxy-resolved client info for this request.
func Client(ctx context.Context) clientInfo {
c, _ := ctx.Value(ctxKeyClient).(clientInfo)
return c
}
// ClientIP is a convenience accessor used by rate limiting and audit logging.
func ClientIP(ctx context.Context) netip.Addr { return Client(ctx).IP }
// statusRecorder captures the response status for logging and metrics while
// staying compatible with http.ResponseController (Unwrap).
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
if r.status == 0 {
r.status = code
}
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
return r.ResponseWriter.Write(b)
}
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
func (s *Server) withMiddleware(next http.Handler) http.Handler {
return s.recoverPanic(s.assignRequestID(s.resolveClientInfo(s.securityHeaders(s.accessLog(next)))))
}
// securityHeaders applies the §12 hardening headers. CSP allows no inline
// scripts — all JS ships as external modules.
func (s *Server) securityHeaders(next http.Handler) http.Handler {
const csp = "default-src 'self'; script-src 'self'; style-src 'self'; " +
"img-src 'self' data:; connect-src 'self'; font-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", csp)
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
// recoverPanic guarantees no panic escapes a request path: it logs the stack
// and returns the standard JSON error envelope.
func (s *Server) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
if rec == http.ErrAbortHandler { // client went away mid-write
panic(rec)
}
s.log.Error("panic in request handler",
"err", fmt.Sprint(rec),
"requestId", RequestID(r.Context()),
"path", r.URL.Path,
"stack", string(debug.Stack()))
s.metrics.Inc("http_panics_total", 1)
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) assignRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := ulid.New()
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id)))
})
}
func (s *Server) resolveClientInfo(next http.Handler) http.Handler {
defaultProto := "http"
if r := s.cfg.AppBaseURL; len(r) >= 8 && r[:8] == "https://" {
defaultProto = "https"
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
info := resolveClient(r, s.cfg.TrustedProxyCIDRs, defaultProto)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyClient, info)))
})
}
func (s *Server) accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
if rec.status == 0 {
rec.status = http.StatusOK
}
s.metrics.Inc("http_requests_total", 1)
s.metrics.Inc(fmt.Sprintf("http_responses_%dxx_total", rec.status/100), 1)
s.log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"durationMs", time.Since(start).Milliseconds(),
"clientIp", Client(r.Context()).IP.String(),
"requestId", RequestID(r.Context()))
})
}