206 lines
5.3 KiB
Go
206 lines
5.3 KiB
Go
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"
|
||
|
|
}
|