c59aea42ef
- theme rework (user request): white paper bg, brown ink, ordered-dither halftone textures, hard offset Y2K shadows, dithered masthead bands; dark theme matched; token test + DECISIONS updated - fix: .card + .card stacking margin leaked into grid layouts, shifting every card except the first (the 'always the first item' reports) - fix: CSP style-src 'self' silently dropped every inline style attribute (misaligned save button, stat values, editor attach button, bell badge); styles now allow inline, scripts remain strict per §12 - fix: [hidden] is now display:none !important so flex containers cannot defeat it (chat panel/footers) - board + metrics filters live in boxed .toolbar rows with baseline-aligned controls; metric stat cards use a uniform .stat layout - new-conversation dialog: fixed 440px width and 180px results list (no more resizing while searching), full-width result rows, picked people drop out of the list - floating messages bubble bottom-right on all pages (except /messages): unread badge, mini panel with conversation list, thread view, quick composer, live WS updates; toasts moved up to clear it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
142 lines
4.3 KiB
Go
142 lines
4.3 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 {
|
|
// 'unsafe-inline' applies to STYLES only (style attributes used across
|
|
// the UI; without it the browser silently drops them). §12 forbids
|
|
// unsafe-inline for scripts, which stays strict.
|
|
const csp = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
|
"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()))
|
|
})
|
|
}
|