34bf5b5ac2
- 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>
139 lines
4.1 KiB
Go
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()))
|
|
})
|
|
}
|