Files
back-hokm/internal/telescope/telescope.go
T
2026-07-10 00:56:03 +03:30

298 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}