316 lines
9.8 KiB
Go
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).
|