mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-01 15:02:58 +08:00
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:
@@ -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_"
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ADMIN_UI_REQUEST_HEADER,
|
||||
USER_UI_REQUEST_HEADER,
|
||||
isUserTimingAPIPath,
|
||||
shouldMarkAdminUIRequest,
|
||||
shouldMarkUserUIRequest,
|
||||
} from '@/api/adminUIRequest'
|
||||
|
||||
describe('Admin UI request marker', () => {
|
||||
@@ -36,3 +39,62 @@ describe('Admin UI request marker', () => {
|
||||
expect(shouldMarkAdminUIRequest(requestURL, pagePath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User UI request marker', () => {
|
||||
it('uses the stable request header name', () => {
|
||||
expect(USER_UI_REQUEST_HEADER).toBe('X-User-UI-Request')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/auth/me',
|
||||
'/auth/revoke-all-sessions',
|
||||
'/auth/oauth/bind-token',
|
||||
'/user',
|
||||
'/user/profile',
|
||||
'/user/password',
|
||||
'/user/notify-email/send-code',
|
||||
'/user/totp/status',
|
||||
'/user/aff',
|
||||
'/user/platform-quotas',
|
||||
'/keys',
|
||||
'/keys/12',
|
||||
'/groups/available',
|
||||
'/groups/rates',
|
||||
'/channels/available',
|
||||
'/usage',
|
||||
'/usage/stats',
|
||||
'/usage/dashboard/snapshot-v2',
|
||||
'/announcements',
|
||||
'/announcements/3/read',
|
||||
'/redeem',
|
||||
'/redeem/history',
|
||||
'/subscriptions',
|
||||
'/subscriptions/active',
|
||||
'/channel-monitors',
|
||||
'/channel-monitors/9/status',
|
||||
'/payment/config',
|
||||
'/payment/plans',
|
||||
'/payment/orders',
|
||||
'/payment/orders/my',
|
||||
'/api/v1/auth/me',
|
||||
'/api/v1/keys?page=1',
|
||||
'https://api.example.test/api/v1/payment/orders/1',
|
||||
])('marks user timing API %s', (requestURL) => {
|
||||
expect(shouldMarkUserUIRequest(requestURL)).toBe(true)
|
||||
expect(isUserTimingAPIPath(requestURL)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/auth/login',
|
||||
'/settings/public',
|
||||
'/admin/users',
|
||||
'/groups',
|
||||
'/channels',
|
||||
'/payment/public/orders/verify',
|
||||
'/payment/webhook/stripe',
|
||||
'/api/v1/payment/public/orders/resolve',
|
||||
'',
|
||||
])('does not mark non-user timing API %s', (requestURL) => {
|
||||
expect(shouldMarkUserUIRequest(requestURL)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,6 +170,58 @@ describe('API Client', () => {
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('X-Admin-UI-Request')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('用户侧 timing API 自动带 User UI 标记', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/auth/me')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('X-User-UI-Request')).toBe('1')
|
||||
expect(config.headers.get('X-Admin-UI-Request')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('支付用户 API 带 User UI 标记,公开支付 API 不带', async () => {
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/payment/plans')
|
||||
expect(adapter.mock.calls[0][0].headers.get('X-User-UI-Request')).toBe('1')
|
||||
|
||||
await apiClient.post('/payment/public/orders/verify', {})
|
||||
expect(adapter.mock.calls[1][0].headers.get('X-User-UI-Request')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('管理页调用共享 API 时同时带 Admin 与 User UI 标记', async () => {
|
||||
window.history.replaceState({}, '', '/admin/dashboard')
|
||||
const adapter = vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: { code: 0, data: {} },
|
||||
headers: {},
|
||||
config: {},
|
||||
statusText: 'OK',
|
||||
})
|
||||
apiClient.defaults.adapter = adapter
|
||||
|
||||
await apiClient.get('/keys')
|
||||
|
||||
const config = adapter.mock.calls[0][0]
|
||||
expect(config.headers.get('X-Admin-UI-Request')).toBe('1')
|
||||
expect(config.headers.get('X-User-UI-Request')).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
// --- 响应拦截器 ---
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const ADMIN_UI_REQUEST_HEADER = 'X-Admin-UI-Request'
|
||||
export const USER_UI_REQUEST_HEADER = 'X-User-UI-Request'
|
||||
|
||||
function isAdminPath(path: string): boolean {
|
||||
return (
|
||||
@@ -20,8 +21,58 @@ function requestPath(rawURL: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize Axios relative paths and absolute API paths to a comparable form. */
|
||||
function normalizeAPIPath(path: string): string {
|
||||
const raw = requestPath(path)
|
||||
if (!raw) return ''
|
||||
if (raw === '/api/v1' || raw.startsWith('/api/v1/')) {
|
||||
return raw.slice('/api/v1'.length) || '/'
|
||||
}
|
||||
if (raw.startsWith('/')) {
|
||||
return raw
|
||||
}
|
||||
return `/${raw}`
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing web APIs that may emit Server-Timing when ENABLE_SERVER_TIMING is on.
|
||||
* Mirrors backend isUserTimingPath allowlist (excluding public payment surfaces).
|
||||
*/
|
||||
export function isUserTimingAPIPath(requestURL: string): boolean {
|
||||
const path = normalizeAPIPath(requestURL)
|
||||
if (!path) return false
|
||||
|
||||
if (
|
||||
path === '/auth/me' ||
|
||||
path === '/auth/revoke-all-sessions' ||
|
||||
path === '/auth/oauth/bind-token'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (path === '/user' || path.startsWith('/user/')) return true
|
||||
if (path === '/keys' || path.startsWith('/keys/')) return true
|
||||
if (path === '/groups/available' || path === '/groups/rates') return true
|
||||
if (path === '/channels/available') return true
|
||||
if (path === '/usage' || path.startsWith('/usage/')) return true
|
||||
if (path === '/announcements' || path.startsWith('/announcements/')) return true
|
||||
if (path === '/redeem' || path.startsWith('/redeem/')) return true
|
||||
if (path === '/subscriptions' || path.startsWith('/subscriptions/')) return true
|
||||
if (path === '/channel-monitors' || path.startsWith('/channel-monitors/')) return true
|
||||
if (path.startsWith('/payment/')) {
|
||||
if (path.startsWith('/payment/public') || path.startsWith('/payment/webhook')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function shouldMarkAdminUIRequest(requestURL: string, pagePath?: string): boolean {
|
||||
const currentPath =
|
||||
pagePath ?? (typeof window !== 'undefined' ? window.location.pathname : '')
|
||||
return isAdminPath(requestPath(requestURL)) || isAdminPath(currentPath)
|
||||
}
|
||||
|
||||
export function shouldMarkUserUIRequest(requestURL: string): boolean {
|
||||
return isUserTimingAPIPath(requestURL)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import type { ApiResponse } from '@/types'
|
||||
import { getLocale } from '@/i18n'
|
||||
import { ADMIN_UI_REQUEST_HEADER, shouldMarkAdminUIRequest } from './adminUIRequest'
|
||||
import {
|
||||
ADMIN_UI_REQUEST_HEADER,
|
||||
USER_UI_REQUEST_HEADER,
|
||||
shouldMarkAdminUIRequest,
|
||||
shouldMarkUserUIRequest,
|
||||
} from './adminUIRequest'
|
||||
import { getAPIBaseURL } from './url'
|
||||
export { buildApiUrl, buildGatewayUrl } from './url'
|
||||
|
||||
@@ -75,8 +80,14 @@ apiClient.interceptors.request.use(
|
||||
config.params.timezone = getUserTimezone()
|
||||
}
|
||||
|
||||
if (config.headers && shouldMarkAdminUIRequest(String(config.url || ''))) {
|
||||
config.headers[ADMIN_UI_REQUEST_HEADER] = '1'
|
||||
if (config.headers) {
|
||||
const requestURL = String(config.url || '')
|
||||
if (shouldMarkAdminUIRequest(requestURL)) {
|
||||
config.headers[ADMIN_UI_REQUEST_HEADER] = '1'
|
||||
}
|
||||
if (shouldMarkUserUIRequest(requestURL)) {
|
||||
config.headers[USER_UI_REQUEST_HEADER] = '1'
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
Reference in New Issue
Block a user