package middleware import ( "bytes" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/gin-gonic/gin" ) var captureEnabledUsers sync.Map // key: int64, value: struct{} func isCaptureEnabled(userID int64) bool { _, ok := captureEnabledUsers.Load(userID) return ok } func SetCaptureEnabled(userID int64, enabled bool) { if enabled { captureEnabledUsers.Store(userID, struct{}{}) } else { captureEnabledUsers.Delete(userID) } } func LoadCaptureEnabledUsers() error { var ids []int64 if err := model.DB.Model(&model.User{}). Where("capture_relay = ?", true). Pluck("id", &ids).Error; err != nil { return err } for _, id := range ids { captureEnabledUsers.Store(id, struct{}{}) } return nil } func GetCaptureEnabledUsersCount() int { count := 0 captureEnabledUsers.Range(func(key, value interface{}) bool { count++ return true }) return count } type relayCaptureRecord struct { RequestID string `json:"request_id"` CapturedAt string `json:"captured_at"` DurationMs int64 `json:"duration_ms"` UserID int64 `json:"user_id"` Request relayCaptureRequestRecord `json:"request"` Response relayCaptureResponseRecord `json:"response"` CaptureErrors []string `json:"capture_errors"` } type relayCaptureRequestRecord struct { Method string `json:"method"` Path string `json:"path"` Query string `json:"query"` Headers map[string][]string `json:"headers"` Body string `json:"body"` } type relayCaptureResponseRecord struct { StatusCode int `json:"status_code"` Headers map[string][]string `json:"headers"` Body string `json:"body"` BodyChunks []string `json:"body_chunks"` } type responseCaptureCollector struct { body bytes.Buffer chunks []string } func newResponseCaptureCollector() *responseCaptureCollector { return &responseCaptureCollector{ chunks: make([]string, 0), } } func (collector *responseCaptureCollector) Add(chunk []byte) { if len(chunk) == 0 { return } copied := make([]byte, len(chunk)) copy(copied, chunk) collector.body.Write(copied) collector.chunks = append(collector.chunks, string(copied)) } func (collector *responseCaptureCollector) Body() string { return collector.body.String() } func (collector *responseCaptureCollector) Chunks() []string { chunks := make([]string, len(collector.chunks)) copy(chunks, collector.chunks) return chunks } type captureResponseWriter struct { gin.ResponseWriter collector *responseCaptureCollector } func (cw *captureResponseWriter) Write(b []byte) (int, error) { n, err := cw.ResponseWriter.Write(b) if n > 0 { cw.collector.Add(b[:n]) } return n, err } func (cw *captureResponseWriter) WriteString(s string) (int, error) { n, err := cw.ResponseWriter.WriteString(s) if n > 0 { cw.collector.Add([]byte(s[:n])) } return n, err } func normalizeCaptureRequestID(requestID string) string { if strings.TrimSpace(requestID) != "" { return requestID } return fmt.Sprintf("capture-%d", time.Now().UnixNano()) } func createCaptureFile(requestID string) (*os.File, error) { dir := filepath.Join("./data", "relay-capture", time.Now().Format("2006-01-02")) if err := os.MkdirAll(dir, 0755); err != nil { return nil, err } return os.OpenFile( filepath.Join(dir, normalizeCaptureRequestID(requestID)+".json"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644, ) } func isSensitiveCaptureHeader(key string) bool { switch strings.ToLower(key) { case "authorization", "proxy-authorization", "x-api-key", "api-key", "cookie": return true default: return false } } func copyHeadersForCapture(headers http.Header, redactSensitive bool) map[string][]string { copied := make(map[string][]string, len(headers)) for key, values := range headers { copiedValues := make([]string, len(values)) if redactSensitive && isSensitiveCaptureHeader(key) { for i := range copiedValues { copiedValues[i] = "[REDACTED]" } } else { copy(copiedValues, values) } copied[key] = copiedValues } return copied } func buildCaptureRequestRecord(c *gin.Context, captureErrors *[]string) relayCaptureRequestRecord { record := relayCaptureRequestRecord{ Method: c.Request.Method, Path: c.Request.URL.Path, Query: c.Request.URL.RawQuery, Headers: copyHeadersForCapture(c.Request.Header, true), Body: "", } if c.Request.Body == nil { return record } storage, err := common.GetBodyStorage(c) if err != nil { *captureErrors = append(*captureErrors, "failed to read request body: "+err.Error()) return record } body, err := storage.Bytes() if err != nil { *captureErrors = append(*captureErrors, "failed to read request body bytes: "+err.Error()) return record } record.Body = string(body) return record } func buildCaptureResponseRecord(c *gin.Context, collector *responseCaptureCollector) relayCaptureResponseRecord { return relayCaptureResponseRecord{ StatusCode: c.Writer.Status(), Headers: copyHeadersForCapture(c.Writer.Header(), false), Body: collector.Body(), BodyChunks: collector.Chunks(), } } func writeCaptureRecord(requestID string, record relayCaptureRecord) error { f, err := createCaptureFile(requestID) if err != nil { return err } defer f.Close() data, err := common.Marshal(record) if err != nil { return err } _, err = f.Write(data) return err } func RelayCaptureMiddleware() gin.HandlerFunc { return func(c *gin.Context) { userID := int64(c.GetInt("id")) if !isCaptureEnabled(userID) { c.Next() return } start := time.Now() requestID := normalizeCaptureRequestID(c.GetString(common.RequestIdKey)) captureErrors := make([]string, 0) requestRecord := buildCaptureRequestRecord(c, &captureErrors) collector := newResponseCaptureCollector() c.Writer = &captureResponseWriter{ ResponseWriter: c.Writer, collector: collector, } c.Next() record := relayCaptureRecord{ RequestID: requestID, CapturedAt: start.UTC().Format(time.RFC3339Nano), DurationMs: time.Since(start).Milliseconds(), UserID: userID, Request: requestRecord, Response: buildCaptureResponseRecord(c, collector), CaptureErrors: captureErrors, } if err := writeCaptureRecord(requestID, record); err != nil { common.SysError("relay capture: failed to write json capture, request_id=" + requestID + ", error=" + err.Error()) } } }