Scaffolds the server module and everything Phase 0 of PLAN.md calls for: a binary that boots, serves its discovery endpoints and nothing else yet. - config: one YAML file plus per-field WARP_* environment overrides, unknown fields rejected, validation that reports every problem at once - logging and metrics: slog, a Prometheus registry, per-route HTTP instrumentation labelled by pattern rather than path - httputil: the Matrix error envelope, error-returning handlers, CORS, request logging, panic recovery, and a router over http.ServeMux - endpoints: /_matrix/client/versions, /.well-known/matrix/client, /.well-known/matrix/server (gated), /healthz, /metrics, and M_UNRECOGNIZED for everything else - storage: driver registry, shared database/sql base, checksummed migration runner, sqlite and postgres drivers, pocketbase stub - settings: server_settings table behind a cached service, so the federation and registration switches change without a restart - federation: the gate that makes federation a runtime mode - cmd/warp: serve, migrate, generate-keys, version - Makefile, golangci-lint v2, GitHub Actions CI (Linux and Windows tests, race on Linux, storage tests against PostgreSQL, lint, boot smoke test), Dockerfile, example config and docs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
180 lines
5.2 KiB
Go
180 lines
5.2 KiB
Go
package httputil
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Middleware wraps an http.Handler.
|
|
type Middleware func(http.Handler) http.Handler
|
|
|
|
// Chain applies middleware so that the first element is the outermost.
|
|
func Chain(h http.Handler, mw ...Middleware) http.Handler {
|
|
for i := len(mw) - 1; i >= 0; i-- {
|
|
h = mw[i](h)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// ResponseRecorder captures status and size for logging and metrics.
|
|
type ResponseRecorder struct {
|
|
http.ResponseWriter
|
|
Status int
|
|
Bytes int
|
|
}
|
|
|
|
// WriteHeader records the status before passing it on.
|
|
func (r *ResponseRecorder) WriteHeader(status int) {
|
|
if r.Status == 0 {
|
|
r.Status = status
|
|
}
|
|
r.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (r *ResponseRecorder) Write(b []byte) (int, error) {
|
|
if r.Status == 0 {
|
|
r.Status = http.StatusOK
|
|
}
|
|
n, err := r.ResponseWriter.Write(b)
|
|
r.Bytes += n
|
|
return n, err
|
|
}
|
|
|
|
// Unwrap lets http.ResponseController reach the underlying writer.
|
|
func (r *ResponseRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
|
|
|
// CORSOptions configures the CORS middleware.
|
|
type CORSOptions struct {
|
|
// AllowedOrigins is a list of origins or the single element "*".
|
|
AllowedOrigins []string
|
|
}
|
|
|
|
// corsHeaders are the headers the Matrix spec tells a homeserver to allow.
|
|
const (
|
|
corsMethods = "GET, POST, PUT, DELETE, OPTIONS"
|
|
corsHeaders = "X-Requested-With, Content-Type, Authorization, Accept, Origin, If-Match, If-None-Match"
|
|
corsExpose = "ETag, Location, Retry-After"
|
|
corsMaxAge = "86400"
|
|
)
|
|
|
|
// CORS answers preflight requests and adds the access-control headers to
|
|
// every response. The Matrix spec expects homeservers to allow all origins;
|
|
// a narrower list is honoured for deployments that only serve their own
|
|
// first-party clients.
|
|
func CORS(opts CORSOptions) Middleware {
|
|
allowAll := len(opts.AllowedOrigins) == 0 || slices.Contains(opts.AllowedOrigins, "*")
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
switch {
|
|
case allowAll:
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
case origin != "" && slices.Contains(opts.AllowedOrigins, origin):
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Add("Vary", "Origin")
|
|
default:
|
|
w.Header().Add("Vary", "Origin")
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Methods", corsMethods)
|
|
w.Header().Set("Access-Control-Allow-Headers", corsHeaders)
|
|
w.Header().Set("Access-Control-Expose-Headers", corsExpose)
|
|
|
|
if r.Method == http.MethodOptions {
|
|
w.Header().Set("Access-Control-Max-Age", corsMaxAge)
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// RequestLogger logs one line per request and puts a request-scoped logger
|
|
// on the context.
|
|
func RequestLogger(base *slog.Logger) Middleware {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
rec := &ResponseRecorder{ResponseWriter: w}
|
|
|
|
l := base.With("method", r.Method, "path", r.URL.Path)
|
|
next.ServeHTTP(rec, r.WithContext(WithLogger(r.Context(), l)))
|
|
|
|
status := rec.Status
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
level := slog.LevelInfo
|
|
switch {
|
|
case status >= 500:
|
|
level = slog.LevelError
|
|
case status >= 400:
|
|
level = slog.LevelWarn
|
|
case isNoise(r.URL.Path):
|
|
level = slog.LevelDebug
|
|
}
|
|
l.LogAttrs(r.Context(), level, "request",
|
|
slog.Int("status", status),
|
|
slog.Int("bytes", rec.Bytes),
|
|
slog.Duration("duration", time.Since(start)),
|
|
slog.String("remote", remoteIP(r)),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// isNoise keeps health checks and metrics scrapes out of the info log.
|
|
func isNoise(path string) bool {
|
|
return path == "/healthz" || path == "/metrics"
|
|
}
|
|
|
|
func remoteIP(r *http.Request) string {
|
|
host := r.RemoteAddr
|
|
if i := strings.LastIndex(host, ":"); i > 0 {
|
|
host = host[:i]
|
|
}
|
|
return strings.Trim(host, "[]")
|
|
}
|
|
|
|
// Recoverer turns a panic in a handler into a logged M_UNKNOWN response so
|
|
// one bad request cannot take the process down.
|
|
func Recoverer(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if v := recover(); v != nil {
|
|
if v == http.ErrAbortHandler {
|
|
panic(v)
|
|
}
|
|
LoggerFrom(r.Context()).Error("panic in handler",
|
|
"panic", v, "method", r.Method, "path", r.URL.Path)
|
|
WriteJSON(w, http.StatusInternalServerError, InternalError())
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// NoCache marks a response as uncacheable; used for /_matrix/client/versions
|
|
// and the well-known documents during development.
|
|
func NoCache(w http.ResponseWriter) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
}
|
|
|
|
// CacheFor sets a public max-age, used for the well-known documents.
|
|
func CacheFor(w http.ResponseWriter, d time.Duration) {
|
|
w.Header().Set("Cache-Control", "public, max-age="+strconv.Itoa(int(d.Seconds())))
|
|
}
|
|
|
|
// NotFoundHandler answers anything unrouted with M_UNRECOGNIZED, which is
|
|
// what the Matrix spec requires for unimplemented endpoints.
|
|
func NotFoundHandler() http.Handler {
|
|
return Wrap(func(http.ResponseWriter, *http.Request) error {
|
|
return Unrecognized()
|
|
})
|
|
}
|