// Package telescope یک بازرسِ سبکِ درخواست‌ها است (شبیهِ Laravel Telescope). // آخرین N درخواست را در یک رینگ‌بافرِ حافظه نگه می‌دارد: متد، مسیر، وضعیت، // مدت‌زمان، IP، هدرها، payloadِ درخواست، پاسخ و شماره‌ی موبایلِ کاربر. بدونِ // وابستگیِ خارجی و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کم‌منابع مناسب است. package telescope import ( "bufio" "bytes" "encoding/json" "io" "net" "net/http" "regexp" "sort" "strings" "sync" "time" ) const ( maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست (فقط برای نمایش) maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ // سقفِ خواندنِ بدنه‌ی غیرِ multipart (JSON): کامل بازگردانده می‌شود تا هندلر // همه را ببیند؛ فقط برای امنیت در برابرِ بدنه‌ی بسیار بزرگ محدود شده است. maxCaptureBody = 4 << 20 // ۴ مگابایت ) // Entry یک درخواستِ ثبت‌شده را نگه می‌دارد. type Entry struct { ID int64 `json:"id"` Time time.Time `json:"time"` Method string `json:"method"` Path string `json:"path"` Status int `json:"status"` Duration int64 `json:"duration_ms"` IP string `json:"ip"` Bytes int `json:"bytes"` UserID int64 `json:"user_id,omitempty"` Mobile string `json:"mobile,omitempty"` // در زمانِ نمایش resolve می‌شود Headers string `json:"headers,omitempty"` ReqBody string `json:"req_body,omitempty"` // payloadِ درخواست RespBody string `json:"resp_body,omitempty"` // بدنه‌ی پاسخ } // Telescope رینگ‌بافرِ هم‌زمان‌امنِ درخواست‌ها. type Telescope struct { mu sync.Mutex buf []Entry size int next int64 // شناسه‌ی افزایشی full bool head int // اندیسِ نوشتنِ بعدی // decodeUID شناسه‌ی کاربر را از توکنِ درخواست (بدونِ DB، فقط HMAC) درمی‌آورد. decodeUID func(*http.Request) int64 // resolveMobile شماره‌ی موبایل را از شناسه (با DB) می‌گیرد؛ فقط در زمانِ نمایش. resolveMobile func(int64) string mobileCache map[int64]string } // New یک بازرس با ظرفیتِ size می‌سازد (حداقل ۱). decodeUID و resolveMobile // می‌توانند nil باشند (مثلاً در تست‌ها). func New(size int, decodeUID func(*http.Request) int64, resolveMobile func(int64) string) *Telescope { if size < 1 { size = 200 } return &Telescope{ buf: make([]Entry, size), size: size, decodeUID: decodeUID, resolveMobile: resolveMobile, mobileCache: map[int64]string{}, } } func (t *Telescope) add(e Entry) { t.mu.Lock() t.next++ e.ID = t.next t.buf[t.head] = e t.head = (t.head + 1) % t.size if t.head == 0 { t.full = true } t.mu.Unlock() } // Entries آخرین درخواست‌ها را به‌ترتیبِ جدید-به-قدیم برمی‌گرداند و در همین‌جا // شماره‌ی موبایلِ هر کاربر را (با کش) resolve می‌کند. func (t *Telescope) Entries() []Entry { t.mu.Lock() n := t.head if t.full { n = t.size } out := make([]Entry, 0, n) for i := 0; i < n; i++ { idx := (t.head - 1 - i + t.size*2) % t.size out = append(out, t.buf[idx]) } t.mu.Unlock() // resolveِ موبایل بیرون از قفلِ اصلی (ممکن است به DB بزند). for i := range out { out[i].Mobile = t.mobileFor(out[i].UserID) // درخواست‌های ورود (بدونِ توکن) شماره را در payload دارند. if out[i].Mobile == "" { out[i].Mobile = mobileFromBody(out[i].ReqBody) } } return out } func (t *Telescope) mobileFor(id int64) string { if id == 0 || t.resolveMobile == nil { return "" } t.mu.Lock() if m, ok := t.mobileCache[id]; ok { t.mu.Unlock() return m } t.mu.Unlock() m := t.resolveMobile(id) t.mu.Lock() t.mobileCache[id] = m t.mu.Unlock() return m } // statusRecorder کدِ وضعیت، حجم و بخشی از بدنه‌ی پاسخ را می‌گیرد. type statusRecorder struct { http.ResponseWriter status int bytes int buf *bytes.Buffer } func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) } func (r *statusRecorder) Write(b []byte) (int, error) { if r.status == 0 { r.status = http.StatusOK } if r.buf != nil && r.buf.Len() < maxRespBody { remain := maxRespBody - r.buf.Len() if remain >= len(b) { r.buf.Write(b) } else { r.buf.Write(b[:remain]) } } n, err := r.ResponseWriter.Write(b) r.bytes += n return n, err } // Hijack برای WebSocket لازم است تا میدل‌ور مانعِ ارتقاء نشود. func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { if h, ok := r.ResponseWriter.(http.Hijacker); ok { return h.Hijack() } return nil, nil, http.ErrNotSupported } // Middleware هر درخواستِ /api و /admin را ثبت می‌کند (به‌جز خودِ صفحه‌ی تلسکوپ، // WebSocket و فایل‌های استاتیک). func (t *Telescope) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { p := req.URL.Path if p == "/ws" || strings.HasPrefix(p, "/admin/telescope") || strings.HasPrefix(p, "/cards/") || strings.HasPrefix(p, "/carpets/") { next.ServeHTTP(w, req) return } var reqBody string if req.Body != nil && req.Method != http.MethodGet { // آپلودِ فایل (multipart) را دست نزن؛ بدنه بزرگ است و باید کامل به // هندلر برسد (وگرنه ParseMultipartForm شکست می‌خورد ⇒ «bad upload»). if strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data") { reqBody = "[multipart upload]" } else { // بدنه را کامل بخوان و کامل بازگردان (تا هندلر همه‌ی آن را ببیند)، // اما فقط بخشِ ابتدایی را برای نمایش در تلسکوپ نگه‌دار. b, _ := io.ReadAll(io.LimitReader(req.Body, maxCaptureBody)) req.Body = io.NopCloser(bytes.NewReader(b)) n := len(b) if n > maxReqBody { n = maxReqBody } reqBody = maskSecrets(string(b[:n])) } } var uid int64 if t.decodeUID != nil { uid = t.decodeUID(req) // فقط HMAC، بدون DB } rec := &statusRecorder{ResponseWriter: w, buf: &bytes.Buffer{}} start := time.Now() next.ServeHTTP(rec, req) dur := time.Since(start) t.add(Entry{ Time: start, Method: req.Method, Path: p, Status: rec.status, Duration: dur.Milliseconds(), IP: req.RemoteAddr, Bytes: rec.bytes, UserID: uid, Headers: formatHeaders(req.Header), ReqBody: reqBody, RespBody: maskSecrets(rec.buf.String()), }) }) } // formatHeaders هدرهای درخواست را (با پنهان‌کردنِ مقادیرِ حساس) به رشته تبدیل می‌کند. func formatHeaders(h http.Header) string { keys := make([]string, 0, len(h)) for k := range h { keys = append(keys, k) } sort.Strings(keys) var sb strings.Builder for _, k := range keys { val := strings.Join(h[k], ", ") switch strings.ToLower(k) { case "authorization", "cookie", "x-access-token": val = "***" } sb.WriteString(k) sb.WriteString(": ") sb.WriteString(val) sb.WriteString("\n") } return sb.String() } var mobileRe = regexp.MustCompile(`"mobile"\s*:\s*"([^"]+)"`) // mobileFromBody شماره‌ی موبایل را از payloadِ درخواست (مثلِ ورود) درمی‌آورد. func mobileFromBody(body string) string { if m := mobileRe.FindStringSubmatch(body); len(m) == 2 { return m[1] } return "" } // maskSecrets مقادیرِ حساس (توکن، کد، پسورد، امضا) را در JSON پنهان می‌کند. func maskSecrets(s string) string { for _, k := range []string{"password", "token", "code", "otp", "signature", "purchase_data"} { s = maskField(s, k) } return s } func maskField(s, key string) string { needle := `"` + key + `"` i := strings.Index(s, needle) if i < 0 { return s } j := strings.Index(s[i:], ":") if j < 0 { return s } rest := s[i+j+1:] rest = strings.TrimLeft(rest, " ") if strings.HasPrefix(rest, `"`) { end := strings.Index(rest[1:], `"`) if end >= 0 { return s[:i+j+1] + `"***"` + rest[end+2:] } } return s } // Data آخرین درخواست‌ها را به‌صورتِ JSON برمی‌گرداند. func (t *Telescope) Data(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(t.Entries()) } // Page یک صفحه‌ی HTML سبک برای مرورِ درخواست‌ها می‌دهد. func (t *Telescope) Page(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = io.WriteString(w, pageHTML) }