server_timing.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. package middleware
  2. import (
  3. "net/http"
  4. "strings"
  5. "sync"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
  8. "github.com/Wei-Shaw/sub2api/internal/service"
  9. "github.com/gin-gonic/gin"
  10. )
  11. const (
  12. snapshotCacheHeader = "X-Snapshot-Cache"
  13. usageCacheHeader = "X-Usage-Stats-Cache"
  14. )
  15. type serverTimingResponseWriter struct {
  16. gin.ResponseWriter
  17. context *gin.Context
  18. once sync.Once
  19. }
  20. func (w *serverTimingResponseWriter) Unwrap() http.ResponseWriter {
  21. return w.ResponseWriter
  22. }
  23. // ServerTiming collects timing for Admin and User web UI requests when enabled.
  24. func ServerTiming(enabled bool) gin.HandlerFunc {
  25. if !enabled {
  26. return func(c *gin.Context) {
  27. c.Next()
  28. }
  29. }
  30. return func(c *gin.Context) {
  31. if !shouldCollectServerTiming(c) || c.Request == nil {
  32. c.Next()
  33. return
  34. }
  35. collector := servertiming.New(time.Now())
  36. c.Request = c.Request.WithContext(servertiming.WithCollector(c.Request.Context(), collector))
  37. writer := &serverTimingResponseWriter{
  38. ResponseWriter: c.Writer,
  39. context: c,
  40. }
  41. c.Writer = writer
  42. c.Next()
  43. writer.finalize()
  44. }
  45. }
  46. func (w *serverTimingResponseWriter) WriteHeader(statusCode int) {
  47. w.ResponseWriter.WriteHeader(statusCode)
  48. }
  49. func (w *serverTimingResponseWriter) WriteHeaderNow() {
  50. w.finalize()
  51. w.ResponseWriter.WriteHeaderNow()
  52. }
  53. func (w *serverTimingResponseWriter) Write(data []byte) (int, error) {
  54. w.finalize()
  55. return w.ResponseWriter.Write(data)
  56. }
  57. func (w *serverTimingResponseWriter) WriteString(data string) (int, error) {
  58. w.finalize()
  59. return w.ResponseWriter.WriteString(data)
  60. }
  61. func (w *serverTimingResponseWriter) Flush() {
  62. w.finalize()
  63. w.ResponseWriter.Flush()
  64. }
  65. func (w *serverTimingResponseWriter) finalize() {
  66. if w == nil {
  67. return
  68. }
  69. w.once.Do(func() {
  70. if value := ServerTimingHeaderValue(w.context); value != "" {
  71. w.ResponseWriter.Header().Set(servertiming.HeaderName, value)
  72. }
  73. })
  74. }
  75. // ServerTimingHeaderValue returns a timing value only for authorized UI scopes.
  76. // Admins may receive timing for any collected Admin/User UI request. Non-admin
  77. // authenticated users may receive timing only on allowlisted user-facing paths.
  78. // X-User-UI-Request is a scope signal and is never used as authorization.
  79. func ServerTimingHeaderValue(c *gin.Context) string {
  80. if c == nil || c.Request == nil {
  81. return ""
  82. }
  83. role, ok := GetUserRoleFromContext(c)
  84. if !ok || role == "" {
  85. return ""
  86. }
  87. if !service.IsAdminRole(role) && !isUserTimingPath(c.Request.URL.Path) {
  88. return ""
  89. }
  90. return servertiming.HeaderValue(c.Request.Context(), time.Now(), responseCacheStatus(c.Writer.Header()))
  91. }
  92. // ServerTimingResponseHeader builds the extra header map required by WebSocket upgrades.
  93. func ServerTimingResponseHeader(c *gin.Context) http.Header {
  94. value := ServerTimingHeaderValue(c)
  95. if value == "" {
  96. return nil
  97. }
  98. return http.Header{servertiming.HeaderName: []string{value}}
  99. }
  100. func shouldCollectServerTiming(c *gin.Context) bool {
  101. return isAdminUIRequest(c) || isUserUIRequest(c)
  102. }
  103. func isAdminUIRequest(c *gin.Context) bool {
  104. if c == nil || c.Request == nil || c.Request.URL == nil {
  105. return false
  106. }
  107. if strings.TrimSpace(c.GetHeader(servertiming.AdminUIHeader)) == "1" {
  108. return true
  109. }
  110. path := strings.TrimSpace(c.Request.URL.Path)
  111. return path == "/api/v1/admin" || strings.HasPrefix(path, "/api/v1/admin/")
  112. }
  113. func isUserUIRequest(c *gin.Context) bool {
  114. if c == nil || c.Request == nil || c.Request.URL == nil {
  115. return false
  116. }
  117. if strings.TrimSpace(c.GetHeader(servertiming.UserUIHeader)) == "1" {
  118. return true
  119. }
  120. return isUserTimingPath(c.Request.URL.Path)
  121. }
  122. // isUserTimingPath reports whether the path is a user-facing web API that may
  123. // emit Server-Timing for authenticated callers (excluding public payment routes).
  124. func isUserTimingPath(path string) bool {
  125. path = strings.TrimSpace(path)
  126. if path == "" {
  127. return false
  128. }
  129. const prefix = "/api/v1"
  130. if !strings.HasPrefix(path, prefix) {
  131. return false
  132. }
  133. rest := strings.TrimPrefix(path, prefix)
  134. if rest == "" {
  135. return false
  136. }
  137. if !strings.HasPrefix(rest, "/") {
  138. rest = "/" + rest
  139. }
  140. switch {
  141. case rest == "/auth/me",
  142. rest == "/auth/revoke-all-sessions",
  143. rest == "/auth/oauth/bind-token":
  144. return true
  145. case rest == "/user", strings.HasPrefix(rest, "/user/"):
  146. return true
  147. case rest == "/keys", strings.HasPrefix(rest, "/keys/"):
  148. return true
  149. case rest == "/groups/available", rest == "/groups/rates":
  150. return true
  151. case rest == "/channels/available":
  152. return true
  153. case rest == "/usage", strings.HasPrefix(rest, "/usage/"):
  154. return true
  155. case rest == "/announcements", strings.HasPrefix(rest, "/announcements/"):
  156. return true
  157. case rest == "/redeem", strings.HasPrefix(rest, "/redeem/"):
  158. return true
  159. case rest == "/subscriptions", strings.HasPrefix(rest, "/subscriptions/"):
  160. return true
  161. case rest == "/channel-monitors", strings.HasPrefix(rest, "/channel-monitors/"):
  162. return true
  163. case strings.HasPrefix(rest, "/payment/"):
  164. // Exclude public and webhook payment surfaces.
  165. if strings.HasPrefix(rest, "/payment/public") || strings.HasPrefix(rest, "/payment/webhook") {
  166. return false
  167. }
  168. return true
  169. default:
  170. return false
  171. }
  172. }
  173. func responseCacheStatus(header http.Header) string {
  174. for _, name := range []string{snapshotCacheHeader, usageCacheHeader} {
  175. switch strings.ToLower(strings.TrimSpace(header.Get(name))) {
  176. case "hit":
  177. return "hit"
  178. case "miss":
  179. return "miss"
  180. case "bypass":
  181. return "bypass"
  182. }
  183. }
  184. return "bypass"
  185. }