Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
snapshotCacheHeader = "X-Snapshot-Cache"
|
||||
usageCacheHeader = "X-Usage-Stats-Cache"
|
||||
)
|
||||
|
||||
type serverTimingResponseWriter struct {
|
||||
gin.ResponseWriter
|
||||
context *gin.Context
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (w *serverTimingResponseWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
|
||||
// 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) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if !shouldCollectServerTiming(c) || c.Request == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
collector := servertiming.New(time.Now())
|
||||
c.Request = c.Request.WithContext(servertiming.WithCollector(c.Request.Context(), collector))
|
||||
writer := &serverTimingResponseWriter{
|
||||
ResponseWriter: c.Writer,
|
||||
context: c,
|
||||
}
|
||||
c.Writer = writer
|
||||
c.Next()
|
||||
writer.finalize()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *serverTimingResponseWriter) WriteHeader(statusCode int) {
|
||||
w.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (w *serverTimingResponseWriter) WriteHeaderNow() {
|
||||
w.finalize()
|
||||
w.ResponseWriter.WriteHeaderNow()
|
||||
}
|
||||
|
||||
func (w *serverTimingResponseWriter) Write(data []byte) (int, error) {
|
||||
w.finalize()
|
||||
return w.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
func (w *serverTimingResponseWriter) WriteString(data string) (int, error) {
|
||||
w.finalize()
|
||||
return w.ResponseWriter.WriteString(data)
|
||||
}
|
||||
|
||||
func (w *serverTimingResponseWriter) Flush() {
|
||||
w.finalize()
|
||||
w.ResponseWriter.Flush()
|
||||
}
|
||||
|
||||
func (w *serverTimingResponseWriter) finalize() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.once.Do(func() {
|
||||
if value := ServerTimingHeaderValue(w.context); value != "" {
|
||||
w.ResponseWriter.Header().Set(servertiming.HeaderName, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
return ""
|
||||
}
|
||||
if role != "admin" && !isUserTimingPath(c.Request.URL.Path) {
|
||||
return ""
|
||||
}
|
||||
return servertiming.HeaderValue(c.Request.Context(), time.Now(), responseCacheStatus(c.Writer.Header()))
|
||||
}
|
||||
|
||||
// ServerTimingResponseHeader builds the extra header map required by WebSocket upgrades.
|
||||
func ServerTimingResponseHeader(c *gin.Context) http.Header {
|
||||
value := ServerTimingHeaderValue(c)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
if strings.TrimSpace(c.GetHeader(servertiming.AdminUIHeader)) == "1" {
|
||||
return true
|
||||
}
|
||||
path := strings.TrimSpace(c.Request.URL.Path)
|
||||
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))) {
|
||||
case "hit":
|
||||
return "hit"
|
||||
case "miss":
|
||||
return "miss"
|
||||
case "bypass":
|
||||
return "bypass"
|
||||
}
|
||||
}
|
||||
return "bypass"
|
||||
}
|
||||
Reference in New Issue
Block a user