Files
enkly-ai/services/gateway/internal/handlers/middleware.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

316 lines
9.8 KiB
Go

package handlers
import (
"compress/gzip"
"log/slog"
"net/http"
"runtime/debug"
"strings"
"sync"
"time"
iamv1 "enkly/gen/iam/v1"
"enkly/pkg/authsupport"
)
// Middleware wraps an http.Handler.
type Middleware func(http.Handler) http.Handler
// Chain applies middlewares to h so that the first one listed is the
// outermost (runs first on the way in, last on the way out).
func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// ---------------------------------------------------------------------
// Response-writer wrappers (status capture, Flush/Unwrap passthrough)
// ---------------------------------------------------------------------
type statusWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *statusWriter) WriteHeader(code int) {
if w.status == 0 {
w.status = code
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
// Unwrap lets http.NewResponseController reach the real writer's
// Flush / SetWriteDeadline — essential for SSE, which flushes after every event.
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
// ---------------------------------------------------------------------
// Recover
// ---------------------------------------------------------------------
// Recover turns a handler panic into a logged 500 instead of a dropped
// connection.
func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
return func(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 {
panic(rec) // net/http's own "abort this response" signal
}
logger.Error("panic in handler",
"method", r.Method, "path", r.URL.Path, "panic", rec, "stack", string(debug.Stack()))
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// ---------------------------------------------------------------------
// Request logging
// ---------------------------------------------------------------------
// RequestLogger logs one structured line per request. Static assets and
// health checks are logged at debug level so they don't drown real traffic.
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w}
next.ServeHTTP(sw, r)
status := sw.status
if status == 0 {
status = http.StatusOK
}
level := slog.LevelInfo
if r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/static/") {
level = slog.LevelDebug
}
logger.Log(r.Context(), level, "request",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"bytes", sw.bytes,
"dur", time.Since(start).Round(time.Millisecond),
"ip", clientIP(r),
)
})
}
}
// ---------------------------------------------------------------------
// Security headers
// ---------------------------------------------------------------------
// contentSecurityPolicy: everything same-origin. style-src allows inline
// styles (a couple of templates use style="…" and htmx may inject one);
// script-src does NOT allow inline or eval — the front end is vendored
// htmx + one same-origin script file, and index.html configures htmx with
// allowEval:false so nothing in it needs 'unsafe-eval'.
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"connect-src 'self'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none'"
// SecurityHeaders adds baseline browser hardening headers to every response.
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hd := w.Header()
hd.Set("Content-Security-Policy", contentSecurityPolicy)
hd.Set("X-Content-Type-Options", "nosniff")
hd.Set("X-Frame-Options", "DENY")
hd.Set("Referrer-Policy", "same-origin")
hd.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
hd.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
// ---------------------------------------------------------------------
// Gzip
// ---------------------------------------------------------------------
var gzipPool = sync.Pool{New: func() any { return gzip.NewWriter(nil) }}
// gzipWriter compresses text-like responses. The decision is made
// lazily on the first write, when the Content-Type is known.
type gzipWriter struct {
http.ResponseWriter
gz *gzip.Writer
decided bool
wroteHeader bool
status int
}
func compressible(ct string) bool {
ct = strings.ToLower(ct)
for _, p := range []string{
"text/", "application/json", "application/javascript", "application/xml", "image/svg+xml",
} {
if strings.HasPrefix(ct, p) {
return true
}
}
return false
}
func (w *gzipWriter) WriteHeader(code int) {
if w.wroteHeader {
return
}
w.status = code
w.wroteHeader = true
// Decide now, so Content-Length can be dropped before the header goes out.
hd := w.Header()
if !w.decided {
w.decided = true
bodyless := code == http.StatusNoContent || code == http.StatusNotModified || code < 200
if !bodyless && hd.Get("Content-Encoding") == "" && compressible(hd.Get("Content-Type")) {
hd.Del("Content-Length")
hd.Set("Content-Encoding", "gzip")
w.gz = gzipPool.Get().(*gzip.Writer)
w.gz.Reset(w.ResponseWriter)
}
}
hd.Add("Vary", "Accept-Encoding")
w.ResponseWriter.WriteHeader(code)
}
func (w *gzipWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
if w.Header().Get("Content-Type") == "" {
w.Header().Set("Content-Type", http.DetectContentType(b))
}
w.WriteHeader(http.StatusOK)
}
if w.gz != nil {
return w.gz.Write(b)
}
return w.ResponseWriter.Write(b)
}
func (w *gzipWriter) Flush() {
if w.gz != nil {
_ = w.gz.Flush()
}
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *gzipWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func (w *gzipWriter) close() {
if w.gz != nil {
_ = w.gz.Close()
gzipPool.Put(w.gz)
w.gz = nil
}
}
// Gzip compresses responses for clients that accept it. It never touches
// the SSE stream endpoint (compression buffering would delay events), the
// file-download endpoint (arbitrary — often already-compressed — file
// bytes streamed via http.ServeContent, which also needs to serve
// uncompressed byte ranges), and skips Range requests generally (static
// file partial content).
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") ||
strings.HasPrefix(r.URL.Path, "/chat/stream/") ||
strings.HasPrefix(r.URL.Path, "/files/download/") ||
strings.Contains(r.Header.Get("Accept"), "text/event-stream") ||
r.Header.Get("Range") != "" {
next.ServeHTTP(w, r)
return
}
gw := &gzipWriter{ResponseWriter: w}
defer gw.close()
next.ServeHTTP(gw, r)
})
}
// ---------------------------------------------------------------------
// Auth & CSRF guards (handler-level, used per route in main.go)
// ---------------------------------------------------------------------
// RequireAuth replaces Handlers.RequireAuth: resolves the session
// cookie to a user via one IAM.ValidateSession gRPC call and puts it on
// the request context; otherwise it sends the browser to /login (htmx
// requests get an HX-Redirect so the whole page navigates, rather than
// the login form being swapped into a chat fragment). This is now the
// one RPC on the hot path of nearly every request — see
// ARCHITECTURE.md's "Session validation latency" note.
func (h *Handlers) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if raw := sessionToken(r); raw != "" {
resp, err := h.IAM.ValidateSession(r.Context(), &iamv1.ValidateSessionRequest{SessionToken: raw})
switch {
case err == nil && resp.GetValid() && resp.GetUser().GetEmailVerified():
next(w, r.WithContext(withUser(r.Context(), resp.GetUser())))
return
case err != nil:
h.Logger.Error("validate session", "err", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
if isHTMX(r) {
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
}
// RequireCSRF rejects a state-changing request unless its csrf_token
// form field (or X-CSRF-Token header) matches the csrf cookie. Purely
// an edge concern — unchanged from the monolith.
func (h *Handlers) RequireCSRF(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := parseForm(w, r); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
cookie, _ := r.Cookie(csrfCookieName)
var cookieVal string
if cookie != nil {
cookieVal = cookie.Value
}
submitted := r.PostFormValue(csrfFormField)
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
if !authsupport.VerifyCSRF(cookieVal, submitted) {
http.Error(w, "invalid or missing CSRF token — reload the page and try again", http.StatusForbidden)
return
}
next(w, r)
}
}
// RequireCSRFUpload lives in files.go (it needs the upload size
// constants defined there).