multica/server/internal/middleware/client.go

81 lines
2.9 KiB
Go

package middleware
import (
"context"
"net/http"
)
// Client metadata context keys.
//
// Populated by ClientMetadata middleware from X-Client-Platform / X-Client-Version /
// X-Client-OS request headers. Sent by every first-party client (Web, Desktop, CLI,
// Daemon) so the server can split logs / metrics / gating decisions by caller
// without having to reverse-engineer User-Agent strings or upgrade payloads.
//
// All three values are best-effort: handlers must treat missing values as
// "unknown" and never make security decisions based on them — these headers
// are client-controlled and trivial to spoof.
type clientMetadataKey int
const (
ctxKeyClientPlatform clientMetadataKey = iota
ctxKeyClientVersion
ctxKeyClientOS
)
// Header names — exported so other packages (request logger, realtime hub)
// can stay in sync without re-declaring magic strings.
const (
HeaderClientPlatform = "X-Client-Platform"
HeaderClientVersion = "X-Client-Version"
HeaderClientOS = "X-Client-OS"
)
// ClientMetadata extracts X-Client-Platform / X-Client-Version / X-Client-OS
// from the request and stashes them in the request context so downstream
// handlers and the request logger can read them via ClientMetadataFromContext.
//
// Wired in router.go before route mounting so every authenticated and
// unauthenticated handler benefits from the same observability dimensions.
func ClientMetadata(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if v := r.Header.Get(HeaderClientPlatform); v != "" {
ctx = context.WithValue(ctx, ctxKeyClientPlatform, v)
}
if v := r.Header.Get(HeaderClientVersion); v != "" {
ctx = context.WithValue(ctx, ctxKeyClientVersion, v)
}
if v := r.Header.Get(HeaderClientOS); v != "" {
ctx = context.WithValue(ctx, ctxKeyClientOS, v)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// ClientMetadataFromContext returns the platform/version/os captured from
// X-Client-* headers. Empty strings are returned for any value that wasn't
// sent — callers must treat missing values as "unknown" rather than failing.
func ClientMetadataFromContext(ctx context.Context) (platform, version, os string) {
platform, _ = ctx.Value(ctxKeyClientPlatform).(string)
version, _ = ctx.Value(ctxKeyClientVersion).(string)
os, _ = ctx.Value(ctxKeyClientOS).(string)
return platform, version, os
}
// SetClientMetadata explicitly attaches client metadata to a context. Used
// by the realtime hub, where metadata arrives via WS query parameters
// (`client_platform`, `client_version`, `client_os`) instead of headers.
func SetClientMetadata(ctx context.Context, platform, version, os string) context.Context {
if platform != "" {
ctx = context.WithValue(ctx, ctxKeyClientPlatform, platform)
}
if version != "" {
ctx = context.WithValue(ctx, ctxKeyClientVersion, version)
}
if os != "" {
ctx = context.WithValue(ctx, ctxKeyClientOS, os)
}
return ctx
}