Files
multica/server/internal/middleware/client.go
LinYushen b624cd98ad feat: identify clients via X-Client-Platform/Version/OS (#1477)
* feat: identify clients via X-Client-Platform/Version/OS

Adds client identification headers (and matching WS query params) across
all first-party clients so the server can split logs/metrics/gating by
caller without parsing User-Agent.

- HTTP: X-Client-Platform, X-Client-Version, X-Client-OS
- WS: client_platform, client_version, client_os query params
- Platform ∈ {web, desktop, cli, daemon}; OS ∈ {macos, windows, linux}

Wired through the shared TS ApiClient/WSClient via a new identity option
on CoreProvider. Web reads its version from package.json/env; Desktop
captures version + OS synchronously in preload via sendSync IPC. Go CLI
and daemon clients populate the same headers using runtime.GOOS
(normalized darwin → macos).

Server-side adds a ClientMetadata middleware that stashes the headers in
request context; the request logger and logger.RequestAttrs surface them
on every access log and handler-level log. Realtime hub logs the same
fields on websocket connect.

CORS allowlist extended for the new headers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: address client-identity PR nits

- Memoize the CoreProvider identity object on Web and Desktop, and key
  WSProvider's effect on identity primitives instead of the object
  reference, so unrelated parent re-renders no longer tear down and
  reconnect the WebSocket.
- Add direct header-injection tests for the CLI and daemon Go HTTP
  clients (X-Client-Platform/Version/OS) and a normalizeGOOS unit test
  on both packages.
- Add a TS test for WSClient that asserts client_platform/client_version/
  client_os land on the upgrade URL and never leak the auth token.
- Add a hub test that dials the WS endpoint with client_* query params
  and asserts the "websocket connected" log entry surfaces them as
  structured attributes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 13:36:13 +08:00

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
}