. پاسخ purchaseState=0 یعنی معتبر (غیرِ صفر=مسترد).
type MyketVerifier struct {
PackageName string
AccessToken string
+ pub *rsa.PublicKey // کلیدِ عمومیِ RSA برای تأییدِ آفلاین (اختیاری)
client *http.Client
}
+// NewMyketVerifier یک تأییدکننده با access token (سمتسرور) میسازد.
func NewMyketVerifier(pkg, accessToken string) *MyketVerifier {
return &MyketVerifier{
PackageName: pkg,
@@ -113,7 +119,33 @@ func NewMyketVerifier(pkg, accessToken string) *MyketVerifier {
}
}
+// WithRSAKey کلیدِ عمومیِ RSA را برای تأییدِ آفلاین ضمیمه میکند (اگر خالی نباشد).
+func (v *MyketVerifier) WithRSAKey(publicKey string) (*MyketVerifier, error) {
+ if strings.TrimSpace(publicKey) == "" {
+ return v, nil
+ }
+ pub, err := parseRSAPublicKey(publicKey)
+ if err != nil {
+ return nil, fmt.Errorf("myket rsa: %w", err)
+ }
+ v.pub = pub
+ return v, nil
+}
+
func (v *MyketVerifier) Verify(ctx context.Context, p IAPProof) (bool, error) {
+ // مسیرِ ترجیحی: تأییدِ امضای آفلاین (اگر کلید و امضا موجود باشند).
+ if v.pub != nil && p.PurchaseData != "" && p.Signature != "" {
+ sig, err := base64.StdEncoding.DecodeString(p.Signature)
+ if err != nil {
+ return false, fmt.Errorf("myket: bad signature encoding: %w", err)
+ }
+ h := sha1.Sum([]byte(p.PurchaseData))
+ if err := rsa.VerifyPKCS1v15(v.pub, crypto.SHA1, h[:], sig); err != nil {
+ return false, nil // امضای نامعتبر ⇒ خریدِ نامعتبر
+ }
+ return true, nil
+ }
+ // مسیرِ جایگزین: تأییدِ سمتسرور با access token.
if v.AccessToken == "" || p.Token == "" {
return false, fmt.Errorf("myket: missing access token or purchase token")
}
diff --git a/internal/telescope/page.go b/internal/telescope/page.go
new file mode 100644
index 0000000..ec3763b
--- /dev/null
+++ b/internal/telescope/page.go
@@ -0,0 +1,122 @@
+package telescope
+
+// pageHTML یک صفحهی مستقل (بدونِ وابستگیِ خارجی) که هر ۲ ثانیه داده را میگیرد.
+const pageHTML = `
+
+
+
+
+تلسکوپ — بازرسِ درخواستها
+
+
+
+
+
+
+
+
+
+
+
+ | زمان | متد | مسیر | وضعیت | مدت | حجم | IP |
+
+
+
+درخواستی ثبت نشده است
+
+
+`
diff --git a/internal/telescope/telescope.go b/internal/telescope/telescope.go
new file mode 100644
index 0000000..bb92b28
--- /dev/null
+++ b/internal/telescope/telescope.go
@@ -0,0 +1,188 @@
+// Package telescope یک بازرسِ سبکِ درخواستها است (شبیهِ Laravel Telescope).
+// آخرین N درخواست را در یک رینگبافرِ حافظه نگه میدارد: متد، مسیر، وضعیت،
+// مدتزمان، IP و در صورتِ خطا (>=۴۰۰) بدنهی درخواست. بدونِ هیچ وابستگیِ خارجی
+// و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
+package telescope
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+// 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"`
+ Body string `json:"body,omitempty"` // فقط برای خطاها (>=۴۰۰) و POST/PUT
+}
+
+// Telescope رینگبافرِ همزمانامنِ درخواستها.
+type Telescope struct {
+ mu sync.Mutex
+ buf []Entry
+ size int
+ next int64 // شناسهی افزایشی
+ full bool
+ head int // اندیسِ نوشتنِ بعدی
+}
+
+// New یک بازرس با ظرفیتِ size میسازد (حداقل ۱).
+func New(size int) *Telescope {
+ if size < 1 {
+ size = 200
+ }
+ return &Telescope{buf: make([]Entry, size), size: size}
+}
+
+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 آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند.
+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])
+ }
+ return out
+}
+
+// statusRecorder کدِ وضعیت و حجمِ پاسخ را میگیرد.
+type statusRecorder struct {
+ http.ResponseWriter
+ status int
+ bytes int
+}
+
+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
+ }
+ 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 را ثبت میکند (بهجز خودِ صفحهی تلسکوپ).
+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 body string
+ if (req.Method == http.MethodPost || req.Method == http.MethodPut) && req.Body != nil {
+ b, _ := io.ReadAll(io.LimitReader(req.Body, 4096))
+ req.Body = io.NopCloser(bytes.NewReader(b))
+ body = maskSecrets(string(b))
+ }
+
+ rec := &statusRecorder{ResponseWriter: w}
+ start := time.Now()
+ next.ServeHTTP(rec, req)
+ dur := time.Since(start)
+
+ e := Entry{
+ Time: start,
+ Method: req.Method,
+ Path: p,
+ Status: rec.status,
+ Duration: dur.Milliseconds(),
+ IP: req.RemoteAddr,
+ Bytes: rec.bytes,
+ }
+ // بدنه را فقط برای خطاها نگه میداریم تا حافظه/حریمِ خصوصی حفظ شود.
+ if rec.status >= 400 {
+ e.Body = body
+ }
+ t.add(e)
+ })
+}
+
+// maskSecrets مقادیرِ حساس (توکن، کد، پسورد) را در بدنهی JSON پنهان میکند.
+func maskSecrets(s string) string {
+ for _, k := range []string{"password", "token", "code", "otp", "signature"} {
+ 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 {
+ masked := s[:i+j+1] + `"***"` + rest[end+2:]
+ return masked
+ }
+ }
+ 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)
+}
diff --git a/internal/telescope/telescope_test.go b/internal/telescope/telescope_test.go
new file mode 100644
index 0000000..52bee6e
--- /dev/null
+++ b/internal/telescope/telescope_test.go
@@ -0,0 +1,76 @@
+package telescope
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestRingOrderAndOverflow(t *testing.T) {
+ tel := New(3)
+ for i := 0; i < 5; i++ {
+ tel.add(Entry{Path: string(rune('a' + i))})
+ }
+ got := tel.Entries()
+ if len(got) != 3 {
+ t.Fatalf("want 3 entries, got %d", len(got))
+ }
+ // جدید-به-قدیم: e, d, c (a و b بازنویسی شدهاند).
+ want := []string{"e", "d", "c"}
+ for i, w := range want {
+ if got[i].Path != w {
+ t.Errorf("entry %d: want %q got %q", i, w, got[i].Path)
+ }
+ }
+ // شناسهها باید افزایشی و یکتا باشند.
+ if got[0].ID != 5 || got[2].ID != 3 {
+ t.Errorf("ids wrong: %d..%d", got[2].ID, got[0].ID)
+ }
+}
+
+func TestMiddlewareCapturesErrorBodyMasked(t *testing.T) {
+ tel := New(10)
+ h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ }))
+ req := httptest.NewRequest(http.MethodPost, "/api/x",
+ strings.NewReader(`{"password":"secret","kind":"coin"}`))
+ 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)
+ }
+ if !strings.Contains(e[0].Body, `"kind":"coin"`) {
+ t.Errorf("non-secret field lost: %s", e[0].Body)
+ }
+}
+
+func TestMiddlewareOKHasNoBody(t *testing.T) {
+ tel := New(10)
+ 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"}`))
+ 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)
+ }
+}
+
+func TestMiddlewareSkipsWS(t *testing.T) {
+ tel := New(10)
+ h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/ws", nil))
+ if len(tel.Entries()) != 0 {
+ t.Error("ws request should be skipped")
+ }
+}