| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- package middleware
- import (
- "net/http"
- "strings"
- "sync"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "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 !service.IsAdminRole(role) && !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"
- }
|