feat: extend Server-Timing to authenticated user web APIs

Mirror the Admin UI Server-Timing opt-in for user-facing pages so
authenticated callers can inspect total/app/db/redis/deps metrics on
session, profile, keys, usage, payment, and related user APIs.

- Collect when X-User-UI-Request=1 or path is on the user allowlist
- Emit for non-admin only on allowlisted paths (header is not auth)
- Exclude payment public/webhook surfaces
- Mark matching SPA requests and allow the new CORS request header
This commit is contained in:
bestony
2026-07-15 00:23:27 +08:00
parent da85cc7e47
commit 324a491671
9 changed files with 349 additions and 28 deletions
@@ -13,6 +13,7 @@ import (
const (
HeaderName = "Server-Timing"
AdminUIHeader = "X-Admin-UI-Request"
UserUIHeader = "X-User-UI-Request"
MetricDatabase = "db"
MetricRedis = "redis"
dependencyPrefix = "dep_"
+1 -1
View File
@@ -52,7 +52,7 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc {
}
allowHeaders := []string{
"Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization",
"accept", "origin", "Cache-Control", "X-Requested-With", "X-API-Key", "X-Admin-UI-Request",
"accept", "origin", "Cache-Control", "X-Requested-With", "X-API-Key", "X-Admin-UI-Request", "X-User-UI-Request",
}
// OpenAI Node SDK 会发送 x-stainless-* 请求头,需在 CORS 中显式放行。
openAIProperties := []string{
@@ -104,6 +104,7 @@ func TestCORS_AllowedOrigin_HasAllowHeaders(t *testing.T) {
assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Headers"),
"允许的 origin 应收到 Allow-Headers")
assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "X-Admin-UI-Request")
assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "X-User-UI-Request")
assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Methods"),
"允许的 origin 应收到 Allow-Methods")
assert.Contains(t, w.Header().Get("Access-Control-Expose-Headers"), "Server-Timing")
@@ -25,7 +25,7 @@ func (w *serverTimingResponseWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
// ServerTiming collects timing only for requests made by the Admin web UI.
// ServerTiming collects timing for Admin and User web UI requests when enabled.
func ServerTiming(enabled bool) gin.HandlerFunc {
if !enabled {
return func(c *gin.Context) {
@@ -33,7 +33,7 @@ func ServerTiming(enabled bool) gin.HandlerFunc {
}
}
return func(c *gin.Context) {
if !isAdminUIRequest(c) || c.Request == nil {
if !shouldCollectServerTiming(c) || c.Request == nil {
c.Next()
return
}
@@ -85,13 +85,19 @@ func (w *serverTimingResponseWriter) finalize() {
})
}
// ServerTimingHeaderValue returns a timing value only for an authenticated admin.
// ServerTimingHeaderValue returns a timing value only for authorized UI scopes.
// Admins may receive timing for any collected Admin/User UI request. Non-admin
// authenticated users may receive timing only on allowlisted user-facing paths.
// X-User-UI-Request is a scope signal and is never used as authorization.
func ServerTimingHeaderValue(c *gin.Context) string {
if c == nil || c.Request == nil {
return ""
}
role, ok := GetUserRoleFromContext(c)
if !ok || role != "admin" {
if !ok || role == "" {
return ""
}
if role != "admin" && !isUserTimingPath(c.Request.URL.Path) {
return ""
}
return servertiming.HeaderValue(c.Request.Context(), time.Now(), responseCacheStatus(c.Writer.Header()))
@@ -106,6 +112,10 @@ func ServerTimingResponseHeader(c *gin.Context) http.Header {
return http.Header{servertiming.HeaderName: []string{value}}
}
func shouldCollectServerTiming(c *gin.Context) bool {
return isAdminUIRequest(c) || isUserUIRequest(c)
}
func isAdminUIRequest(c *gin.Context) bool {
if c == nil || c.Request == nil || c.Request.URL == nil {
return false
@@ -117,6 +127,69 @@ func isAdminUIRequest(c *gin.Context) bool {
return path == "/api/v1/admin" || strings.HasPrefix(path, "/api/v1/admin/")
}
func isUserUIRequest(c *gin.Context) bool {
if c == nil || c.Request == nil || c.Request.URL == nil {
return false
}
if strings.TrimSpace(c.GetHeader(servertiming.UserUIHeader)) == "1" {
return true
}
return isUserTimingPath(c.Request.URL.Path)
}
// isUserTimingPath reports whether the path is a user-facing web API that may
// emit Server-Timing for authenticated callers (excluding public payment routes).
func isUserTimingPath(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
const prefix = "/api/v1"
if !strings.HasPrefix(path, prefix) {
return false
}
rest := strings.TrimPrefix(path, prefix)
if rest == "" {
return false
}
if !strings.HasPrefix(rest, "/") {
rest = "/" + rest
}
switch {
case rest == "/auth/me",
rest == "/auth/revoke-all-sessions",
rest == "/auth/oauth/bind-token":
return true
case rest == "/user", strings.HasPrefix(rest, "/user/"):
return true
case rest == "/keys", strings.HasPrefix(rest, "/keys/"):
return true
case rest == "/groups/available", rest == "/groups/rates":
return true
case rest == "/channels/available":
return true
case rest == "/usage", strings.HasPrefix(rest, "/usage/"):
return true
case rest == "/announcements", strings.HasPrefix(rest, "/announcements/"):
return true
case rest == "/redeem", strings.HasPrefix(rest, "/redeem/"):
return true
case rest == "/subscriptions", strings.HasPrefix(rest, "/subscriptions/"):
return true
case rest == "/channel-monitors", strings.HasPrefix(rest, "/channel-monitors/"):
return true
case strings.HasPrefix(rest, "/payment/"):
// Exclude public and webhook payment surfaces.
if strings.HasPrefix(rest, "/payment/public") || strings.HasPrefix(rest, "/payment/webhook") {
return false
}
return true
default:
return false
}
}
func responseCacheStatus(header http.Header) string {
for _, name := range []string{snapshotCacheHeader, usageCacheHeader} {
switch strings.ToLower(strings.TrimSpace(header.Get(name))) {
@@ -15,7 +15,8 @@ func runServerTimingRequest(
t *testing.T,
enabled bool,
path string,
marker string,
adminMarker string,
userMarker string,
role string,
handler gin.HandlerFunc,
) *httptest.ResponseRecorder {
@@ -32,8 +33,11 @@ func runServerTimingRequest(
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, path, nil)
if marker != "" {
request.Header.Set(servertiming.AdminUIHeader, marker)
if adminMarker != "" {
request.Header.Set(servertiming.AdminUIHeader, adminMarker)
}
if userMarker != "" {
request.Header.Set(servertiming.UserUIHeader, userMarker)
}
engine.ServeHTTP(recorder, request)
return recorder
@@ -41,26 +45,36 @@ func runServerTimingRequest(
func TestServerTimingScopesAndRoleGate(t *testing.T) {
tests := []struct {
name string
enabled bool
path string
marker string
role string
wantHeader bool
name string
enabled bool
path string
adminMarker string
userMarker string
role string
wantHeader bool
}{
{name: "disabled", enabled: false, path: "/api/v1/admin/users", role: "admin"},
{name: "admin API path", enabled: true, path: "/api/v1/admin/users", role: "admin", wantHeader: true},
{name: "shared API marked by admin UI", enabled: true, path: "/api/v1/groups/available", marker: "1", role: "admin", wantHeader: true},
{name: "non admin role", enabled: true, path: "/api/v1/groups/available", marker: "1", role: "user"},
{name: "unauthenticated public request", enabled: true, path: "/api/v1/settings/public", marker: "1"},
{name: "unmarked shared API", enabled: true, path: "/api/v1/groups/available", role: "admin"},
{name: "invalid marker", enabled: true, path: "/api/v1/groups/available", marker: "true", role: "admin"},
{name: "shared API marked by admin UI", enabled: true, path: "/api/v1/groups/available", adminMarker: "1", role: "admin", wantHeader: true},
{name: "user role on allowlisted path", enabled: true, path: "/api/v1/groups/available", role: "user", wantHeader: true},
{name: "user role with user UI marker on allowlisted path", enabled: true, path: "/api/v1/keys", userMarker: "1", role: "user", wantHeader: true},
{name: "user role cannot use admin marker on non-user path", enabled: true, path: "/api/v1/settings/public", adminMarker: "1", role: "user"},
{name: "user marker alone does not authorize non-user path", enabled: true, path: "/api/v1/settings/public", userMarker: "1", role: "user"},
{name: "unauthenticated public request", enabled: true, path: "/api/v1/settings/public", adminMarker: "1"},
{name: "unauthenticated user path", enabled: true, path: "/api/v1/keys"},
{name: "unmarked shared API still scopes by path for admin", enabled: true, path: "/api/v1/groups/available", role: "admin", wantHeader: true},
{name: "invalid admin marker on non-scoped path", enabled: true, path: "/api/v1/settings/public", adminMarker: "true", role: "admin"},
{name: "admin prefix boundary", enabled: true, path: "/api/v1/administrator", role: "admin"},
{name: "auth me path", enabled: true, path: "/api/v1/auth/me", role: "user", wantHeader: true},
{name: "payment user path", enabled: true, path: "/api/v1/payment/plans", role: "user", wantHeader: true},
{name: "payment public excluded", enabled: true, path: "/api/v1/payment/public/orders/verify", userMarker: "1", role: "user"},
{name: "payment webhook excluded", enabled: true, path: "/api/v1/payment/webhook/stripe", userMarker: "1", role: "user"},
{name: "channel monitors path", enabled: true, path: "/api/v1/channel-monitors/1/status", role: "user", wantHeader: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := runServerTimingRequest(t, tt.enabled, tt.path, tt.marker, tt.role, func(c *gin.Context) {
recorder := runServerTimingRequest(t, tt.enabled, tt.path, tt.adminMarker, tt.userMarker, tt.role, func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
header := recorder.Header().Get(servertiming.HeaderName)
@@ -77,9 +91,49 @@ func TestServerTimingScopesAndRoleGate(t *testing.T) {
}
}
func TestIsUserTimingPath(t *testing.T) {
tests := []struct {
path string
want bool
}{
{"/api/v1/auth/me", true},
{"/api/v1/auth/revoke-all-sessions", true},
{"/api/v1/auth/oauth/bind-token", true},
{"/api/v1/auth/login", false},
{"/api/v1/user", true},
{"/api/v1/user/profile", true},
{"/api/v1/user/totp/status", true},
{"/api/v1/keys", true},
{"/api/v1/keys/12", true},
{"/api/v1/groups/available", true},
{"/api/v1/groups/rates", true},
{"/api/v1/groups", false},
{"/api/v1/channels/available", true},
{"/api/v1/channels", false},
{"/api/v1/usage/stats", true},
{"/api/v1/announcements", true},
{"/api/v1/redeem/history", true},
{"/api/v1/subscriptions/active", true},
{"/api/v1/channel-monitors", true},
{"/api/v1/payment/config", true},
{"/api/v1/payment/orders/my", true},
{"/api/v1/payment/public/orders/verify", false},
{"/api/v1/payment/webhook/easypay", false},
{"/api/v1/admin/users", false},
{"/api/v1/settings/public", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
if got := isUserTimingPath(tt.path); got != tt.want {
t.Fatalf("isUserTimingPath(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}
func TestServerTimingCollectorIsRequestScoped(t *testing.T) {
active := false
recorder := runServerTimingRequest(t, true, "/api/v1/keys", "1", "admin", func(c *gin.Context) {
recorder := runServerTimingRequest(t, true, "/api/v1/keys", "1", "", "admin", func(c *gin.Context) {
active = servertiming.Active(c.Request.Context())
c.Status(http.StatusNoContent)
})
@@ -91,8 +145,24 @@ func TestServerTimingCollectorIsRequestScoped(t *testing.T) {
}
}
func TestServerTimingCollectorForUserUIMarker(t *testing.T) {
active := false
// Use a non-allowlisted path so collection depends on the user UI marker.
recorder := runServerTimingRequest(t, true, "/api/v1/settings/public", "", "1", "admin", func(c *gin.Context) {
active = servertiming.Active(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"ok": true})
})
if !active {
t.Fatal("collector was not attached for user UI marker")
}
// Admin role may emit even when the path is not user-allowlisted.
if recorder.Header().Get(servertiming.HeaderName) == "" {
t.Fatal("admin timing header missing for user-UI-marked request")
}
}
func TestServerTimingFinalizesBeforeEarlyCommit(t *testing.T) {
recorder := runServerTimingRequest(t, true, "/api/v1/admin/stream", "", "admin", func(c *gin.Context) {
recorder := runServerTimingRequest(t, true, "/api/v1/admin/stream", "", "", "admin", func(c *gin.Context) {
c.Status(http.StatusAccepted)
c.Writer.WriteHeaderNow()
})
@@ -102,7 +172,7 @@ func TestServerTimingFinalizesBeforeEarlyCommit(t *testing.T) {
}
func TestServerTimingFinalizesOnFlush(t *testing.T) {
recorder := runServerTimingRequest(t, true, "/api/v1/admin/export", "", "admin", func(c *gin.Context) {
recorder := runServerTimingRequest(t, true, "/api/v1/admin/export", "", "", "admin", func(c *gin.Context) {
c.Writer.Flush()
})
if got := recorder.Header().Get(servertiming.HeaderName); got == "" {
@@ -120,7 +190,7 @@ func TestServerTimingStatusResponses(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := runServerTimingRequest(t, true, "/api/v1/admin/test", "", "admin", func(c *gin.Context) {
recorder := runServerTimingRequest(t, true, "/api/v1/admin/test", "", "", "admin", func(c *gin.Context) {
c.Status(tt.status)
})
if recorder.Code != tt.status {
@@ -156,7 +226,7 @@ func TestServerTimingCacheOutcome(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := runServerTimingRequest(t, true, "/api/v1/admin/dashboard", "", "admin", func(c *gin.Context) {
recorder := runServerTimingRequest(t, true, "/api/v1/admin/dashboard", "", "", "admin", func(c *gin.Context) {
c.Header(tt.headerName, tt.value)
c.JSON(http.StatusOK, gin.H{"ok": true})
})