diff --git a/cmd/server/main.go b/cmd/server/main.go
index f2d112a..96411ea 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -6,6 +6,7 @@ import (
"net/http"
"os"
"strconv"
+ "strings"
"time"
"github.com/go-chi/chi/v5"
@@ -27,9 +28,6 @@ func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil)))
cfg := config.Load()
- // تلسکوپ: بازرسِ درونبرنامهایِ درخواستها (رینگبافرِ حافظه، بدونِ وابستگی).
- scope := telescope.New(cfg.TelescopeSize)
-
st, err := store.Open(cfg.DBPath)
if err != nil {
slog.Error("open db", "err", err)
@@ -44,6 +42,31 @@ func main() {
jwt := auth.NewJWT(cfg.JWTSecret, cfg.JWTTTL)
authH := auth.NewHandler(users, otp, kave, jwt, cfg.AdminMobile, cfg.AdminOTP)
+ // تلسکوپ: بازرسِ درونبرنامهایِ درخواستها. شناسهی کاربر را ارزان (فقط HMAC)
+ // از توکن درمیآورد و شمارهی موبایل را در زمانِ نمایش (با DB) resolve میکند.
+ scope := telescope.New(cfg.TelescopeSize,
+ func(r *http.Request) int64 {
+ tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
+ if tok == "" || tok == r.Header.Get("Authorization") {
+ tok = r.URL.Query().Get("token")
+ }
+ if tok == "" {
+ return 0
+ }
+ id, err := jwt.Verify(tok)
+ if err != nil {
+ return 0
+ }
+ return id
+ },
+ func(id int64) string {
+ if u, err := users.FindByID(context.Background(), id); err == nil {
+ return u.Mobile
+ }
+ return ""
+ },
+ )
+
// اقتصاد و فروشگاه. تأییدِ خرید: مایکت (X-Access-Token) و کافهبازار (امضای RSA).
// اگر کلیدها تنظیم نشده باشند، به تأییدکنندهی توسعه برمیگردیم (dev fallback).
iap := economy.StoreRouter{DevFallback: true}
diff --git a/internal/telescope/page.go b/internal/telescope/page.go
index ec3763b..c7de2ed 100644
--- a/internal/telescope/page.go
+++ b/internal/telescope/page.go
@@ -13,7 +13,7 @@ const pageHTML = `
body { margin:0; font-family: -apple-system, "Segoe UI", Tahoma, sans-serif;
background:#0E2347; color:#E8EEF7; }
header { padding:14px 20px; background:#17345C; border-bottom:1px solid #1E5FA8;
- display:flex; align-items:center; gap:14px; position:sticky; top:0; }
+ display:flex; align-items:center; gap:14px; position:sticky; top:0; z-index:2; }
header h1 { font-size:17px; margin:0; font-weight:700; }
header .dot { width:9px; height:9px; border-radius:50%; background:#4ade80; }
header .meta { margin-inline-start:auto; font-size:12px; opacity:.7; }
@@ -22,24 +22,34 @@ const pageHTML = `
.filters input, .filters select {
background:#0b1c39; color:#E8EEF7; border:1px solid #1E5FA8;
border-radius:8px; padding:7px 10px; font-size:13px; }
- .filters input { flex:1; min-width:160px; }
+ .filters input { flex:1; min-width:140px; }
+ .wrap { overflow-x:auto; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th, td { padding:9px 12px; text-align:right; border-bottom:1px solid #16305a; white-space:nowrap; }
- th { position:sticky; top:52px; background:#122c52; font-weight:600; z-index:1; }
- tr:hover { background:#132b50; }
+ th { background:#122c52; font-weight:600; border-bottom:2px solid #1E5FA8; }
+ tr.row { cursor:pointer; }
+ tr.row:hover { background:#132b50; }
.m { font-weight:700; font-size:11px; padding:2px 7px; border-radius:6px; }
.GET{background:#134e4a;color:#5eead4} .POST{background:#1e3a8a;color:#93c5fd}
.PUT{background:#713f12;color:#fcd34d} .DELETE{background:#7f1d1d;color:#fca5a5}
.st { font-weight:700; }
.s2{color:#4ade80} .s3{color:#60a5fa} .s4{color:#fbbf24} .s5{color:#f87171}
.path { font-family: ui-monospace, monospace; }
- .body { display:none; }
- tr.open .body { display:table-row; }
- .body td { background:#0b1c39; white-space:pre-wrap; font-family: ui-monospace, monospace;
- font-size:12px; color:#fca5a5; direction:ltr; text-align:left; }
+ .phone { font-family: ui-monospace, monospace; color:#fcd34d; }
.slow { color:#fbbf24; }
.dim { opacity:.55; }
.empty { padding:40px; text-align:center; opacity:.6; }
+ .detail td { background:#0b1c39; padding:0; }
+ .detail.hidden { display:none; }
+ .panels { padding:12px 16px; display:grid; gap:12px; }
+ .panel h4 { margin:0 0 6px; font-size:12px; color:#8ab4e8; font-weight:700;
+ display:flex; align-items:center; gap:6px; }
+ .panel pre { margin:0; background:#04101f; border:1px solid #16305a; border-radius:8px;
+ padding:10px; font-family: ui-monospace, monospace; font-size:12px; line-height:1.6;
+ color:#cbd5e1; white-space:pre-wrap; word-break:break-word; direction:ltr; text-align:left;
+ max-height:280px; overflow:auto; }
+ .kv { display:flex; gap:8px; flex-wrap:wrap; font-size:12px; margin-bottom:4px; }
+ .kv b { color:#8ab4e8; }
@@ -51,6 +61,7 @@ const pageHTML = `
+
+
- | زمان | متد | مسیر | وضعیت | مدت | حجم | IP |
+ زمان | متد | مسیر | وضعیت | مدت | موبایل | IP |
+
درخواستی ثبت نشده است
diff --git a/internal/telescope/telescope.go b/internal/telescope/telescope.go
index bb92b28..f68b500 100644
--- a/internal/telescope/telescope.go
+++ b/internal/telescope/telescope.go
@@ -1,7 +1,7 @@
// Package telescope یک بازرسِ سبکِ درخواستها است (شبیهِ Laravel Telescope).
// آخرین N درخواست را در یک رینگبافرِ حافظه نگه میدارد: متد، مسیر، وضعیت،
-// مدتزمان، IP و در صورتِ خطا (>=۴۰۰) بدنهی درخواست. بدونِ هیچ وابستگیِ خارجی
-// و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
+// مدتزمان، IP، هدرها، payloadِ درخواست، پاسخ و شمارهی موبایلِ کاربر. بدونِ
+// وابستگیِ خارجی و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
package telescope
import (
@@ -11,11 +11,18 @@ import (
"io"
"net"
"net/http"
+ "regexp"
+ "sort"
"strings"
"sync"
"time"
)
+const (
+ maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست
+ maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ
+)
+
// Entry یک درخواستِ ثبتشده را نگه میدارد.
type Entry struct {
ID int64 `json:"id"`
@@ -26,7 +33,11 @@ type Entry struct {
Duration int64 `json:"duration_ms"`
IP string `json:"ip"`
Bytes int `json:"bytes"`
- Body string `json:"body,omitempty"` // فقط برای خطاها (>=۴۰۰) و POST/PUT
+ 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 رینگبافرِ همزمانامنِ درخواستها.
@@ -37,14 +48,27 @@ type Telescope struct {
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 میسازد (حداقل ۱).
-func New(size int) *Telescope {
+// 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}
+ return &Telescope{
+ buf: make([]Entry, size),
+ size: size,
+ decodeUID: decodeUID,
+ resolveMobile: resolveMobile,
+ mobileCache: map[int64]string{},
+ }
}
func (t *Telescope) add(e Entry) {
@@ -59,28 +83,55 @@ func (t *Telescope) add(e Entry) {
t.mu.Unlock()
}
-// Entries آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند.
+// Entries آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند و در همینجا
+// شمارهی موبایلِ هر کاربر را (با کش) resolve میکند.
func (t *Telescope) Entries() []Entry {
t.mu.Lock()
- defer t.mu.Unlock()
n := t.head
if t.full {
n = t.size
}
out := make([]Entry, 0, n)
- // از جدیدترین (درست قبلِ head) عقب میرویم.
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
}
-// statusRecorder کدِ وضعیت و حجمِ پاسخ را میگیرد.
+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) {
@@ -92,6 +143,14 @@ 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
@@ -105,7 +164,8 @@ func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return nil, nil, http.ErrNotSupported
}
-// Middleware هر درخواستِ /api و /admin را ثبت میکند (بهجز خودِ صفحهی تلسکوپ).
+// 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
@@ -115,19 +175,24 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler {
return
}
- var body string
- if (req.Method == http.MethodPost || req.Method == http.MethodPut) && req.Body != nil {
- b, _ := io.ReadAll(io.LimitReader(req.Body, 4096))
+ var reqBody string
+ if req.Body != nil && req.Method != http.MethodGet {
+ b, _ := io.ReadAll(io.LimitReader(req.Body, maxReqBody))
req.Body = io.NopCloser(bytes.NewReader(b))
- body = maskSecrets(string(b))
+ reqBody = maskSecrets(string(b))
}
- rec := &statusRecorder{ResponseWriter: w}
+ 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)
- e := Entry{
+ t.add(Entry{
Time: start,
Method: req.Method,
Path: p,
@@ -135,18 +200,49 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler {
Duration: dur.Milliseconds(),
IP: req.RemoteAddr,
Bytes: rec.bytes,
- }
- // بدنه را فقط برای خطاها نگه میداریم تا حافظه/حریمِ خصوصی حفظ شود.
- if rec.status >= 400 {
- e.Body = body
- }
- t.add(e)
+ UserID: uid,
+ Headers: formatHeaders(req.Header),
+ ReqBody: reqBody,
+ RespBody: maskSecrets(rec.buf.String()),
+ })
})
}
-// maskSecrets مقادیرِ حساس (توکن، کد، پسورد) را در بدنهی JSON پنهان میکند.
+// 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"} {
+ for _, k := range []string{"password", "token", "code", "otp", "signature", "purchase_data"} {
s = maskField(s, k)
}
return s
@@ -158,7 +254,6 @@ func maskField(s, key string) string {
if i < 0 {
return s
}
- // دنبالِ ": " و سپس مقدار میگردیم.
j := strings.Index(s[i:], ":")
if j < 0 {
return s
@@ -168,8 +263,7 @@ func maskField(s, key string) string {
if strings.HasPrefix(rest, `"`) {
end := strings.Index(rest[1:], `"`)
if end >= 0 {
- masked := s[:i+j+1] + `"***"` + rest[end+2:]
- return masked
+ return s[:i+j+1] + `"***"` + rest[end+2:]
}
}
return s
diff --git a/internal/telescope/telescope_test.go b/internal/telescope/telescope_test.go
index 52bee6e..6966e3b 100644
--- a/internal/telescope/telescope_test.go
+++ b/internal/telescope/telescope_test.go
@@ -8,7 +8,7 @@ import (
)
func TestRingOrderAndOverflow(t *testing.T) {
- tel := New(3)
+ tel := New(3, nil, nil)
for i := 0; i < 5; i++ {
tel.add(Entry{Path: string(rune('a' + i))})
}
@@ -29,43 +29,76 @@ func TestRingOrderAndOverflow(t *testing.T) {
}
}
-func TestMiddlewareCapturesErrorBodyMasked(t *testing.T) {
- tel := New(10)
+func TestMiddlewareCapturesPayloadHeadersResponseMasked(t *testing.T) {
+ tel := New(10, nil, nil)
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"token":"xyz","message":"bad"}`))
}))
req := httptest.NewRequest(http.MethodPost, "/api/x",
strings.NewReader(`{"password":"secret","kind":"coin"}`))
+ req.Header.Set("Authorization", "Bearer supersecret")
h.ServeHTTP(httptest.NewRecorder(), req)
e := tel.Entries()
if len(e) != 1 || e[0].Status != 400 {
t.Fatalf("expected one 400 entry, got %+v", e)
}
- if !strings.Contains(e[0].Body, `"password":"***"`) {
- t.Errorf("password not masked: %s", e[0].Body)
+ // payload برای هر درخواست ثبت میشود و مقادیرِ حساس ماسک میشوند.
+ if !strings.Contains(e[0].ReqBody, `"password":"***"`) {
+ t.Errorf("password not masked: %s", e[0].ReqBody)
}
- if !strings.Contains(e[0].Body, `"kind":"coin"`) {
- t.Errorf("non-secret field lost: %s", e[0].Body)
+ if !strings.Contains(e[0].ReqBody, `"kind":"coin"`) {
+ t.Errorf("non-secret field lost: %s", e[0].ReqBody)
+ }
+ // پاسخ ثبت و توکنِ آن ماسک میشود.
+ if !strings.Contains(e[0].RespBody, `"token":"***"`) {
+ t.Errorf("response token not masked: %s", e[0].RespBody)
+ }
+ // هدرِ Authorization در نمایش پنهان میشود.
+ if strings.Contains(e[0].Headers, "supersecret") {
+ t.Errorf("authorization header leaked: %s", e[0].Headers)
+ }
+ if !strings.Contains(e[0].Headers, "Authorization: ***") {
+ t.Errorf("authorization header not masked: %s", e[0].Headers)
}
}
-func TestMiddlewareOKHasNoBody(t *testing.T) {
- tel := New(10)
+func TestMobileFromLoginBody(t *testing.T) {
+ tel := New(10, nil, nil)
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
- req := httptest.NewRequest(http.MethodPost, "/api/y",
- strings.NewReader(`{"token":"abc"}`))
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/login-otp",
+ strings.NewReader(`{"mobile":"09120000000"}`))
h.ServeHTTP(httptest.NewRecorder(), req)
e := tel.Entries()
- if len(e) != 1 || e[0].Body != "" {
- t.Errorf("2xx should not store body, got %+v", e)
+ if len(e) != 1 || e[0].Mobile != "09120000000" {
+ t.Errorf("mobile not extracted from login body, got %+v", e)
+ }
+}
+
+func TestResolveMobileByUserID(t *testing.T) {
+ tel := New(10,
+ func(*http.Request) int64 { return 42 },
+ func(id int64) string {
+ if id == 42 {
+ return "09121112233"
+ }
+ return ""
+ })
+ h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/me", nil))
+ e := tel.Entries()
+ if len(e) != 1 || e[0].Mobile != "09121112233" || e[0].UserID != 42 {
+ t.Errorf("mobile not resolved by user id, got %+v", e)
}
}
func TestMiddlewareSkipsWS(t *testing.T) {
- tel := New(10)
+ tel := New(10, nil, nil)
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))