From c6997477b9a13dc0308ccb574578da2dbb85670d Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Sat, 4 Jul 2026 21:21:13 +0330 Subject: [PATCH] feat: add uuid --- cmd/server/main.go | 20 ++- internal/admin/admin.go | 46 ++++--- internal/admin/templates/shop.html | 16 ++- internal/config/config.go | 7 ++ internal/economy/catalog.go | 5 + internal/economy/catalog_store.go | 16 +-- internal/economy/economy.go | 34 ++++-- internal/economy/verify.go | 168 ++++++++++++++++++++------ internal/economy/verify_test.go | 75 ++++++++++++ internal/store/migrations/010_sku.sql | 11 ++ 10 files changed, 320 insertions(+), 78 deletions(-) create mode 100644 internal/economy/verify_test.go create mode 100644 internal/store/migrations/010_sku.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index 3911b95..4c72bde 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -40,9 +40,23 @@ func main() { jwt := auth.NewJWT(cfg.JWTSecret, cfg.JWTTTL) authH := auth.NewHandler(users, otp, kave, jwt, cfg.AdminMobile, cfg.AdminOTP) - // اقتصاد و فروشگاه. تأییدکننده‌ها فعلاً نسخه‌ی توسعه‌اند؛ آداپتر واقعی - // تپسل (تبلیغ) و بازار/مایکت (IAP) با تنظیم creds جایگزین می‌شوند. - eco := economy.New(st.DB, economy.DevAdVerifier{}, economy.DevIAPVerifier{}) + // اقتصاد و فروشگاه. تأییدِ خرید: مایکت (X-Access-Token) و کافه‌بازار (امضای RSA). + // اگر کلیدها تنظیم نشده باشند، به تأییدکننده‌ی توسعه برمی‌گردیم (dev fallback). + iap := economy.StoreRouter{DevFallback: true} + if cfg.MyketAccessToken != "" && cfg.IAPPackage != "" { + iap.Myket = economy.NewMyketVerifier(cfg.IAPPackage, cfg.MyketAccessToken) + slog.Info("IAP myket configured", "package", cfg.IAPPackage) + } + if cfg.BazaarRSAKey != "" { + bv, err := economy.NewBazaarRSAVerifier(cfg.BazaarRSAKey) + if err != nil { + slog.Error("bazaar rsa key", "err", err) + os.Exit(1) + } + iap.Bazaar = bv + slog.Info("IAP bazaar (RSA) configured") + } + eco := economy.New(st.DB, economy.DevAdVerifier{}, iap) if err := eco.LoadCatalog(context.Background()); err != nil { slog.Error("load catalog", "err", err) os.Exit(1) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 2d5260f..5265be5 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -5,6 +5,7 @@ package admin import ( "archive/zip" "context" + "crypto/rand" "database/sql" "embed" "fmt" @@ -15,6 +16,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -99,16 +101,16 @@ func (h *Handler) shop(w http.ResponseWriter, r *http.Request) { ctx := r.Context() data := map[string]any{ "Nav": "shop", - "Coins": h.rows(ctx, `SELECT id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled FROM coin_packages ORDER BY sort`, - "id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sort", "enabled"), - "Tickets": h.rows(ctx, `SELECT id,title,tickets,price_toman,sort,enabled FROM ticket_packages ORDER BY sort`, - "id", "title", "tickets", "price_toman", "sort", "enabled"), + "Coins": h.rows(ctx, `SELECT id,title,coins,vip_days,price_toman,bonus_pct,sku,sort,enabled FROM coin_packages ORDER BY sort`, + "id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sku", "sort", "enabled"), + "Tickets": h.rows(ctx, `SELECT id,title,tickets,price_toman,sku,sort,enabled FROM ticket_packages ORDER BY sort`, + "id", "title", "tickets", "price_toman", "sku", "sort", "enabled"), "Cards": h.rows(ctx, `SELECT id,title,price_coins,sort,enabled FROM card_skins ORDER BY sort`, "id", "title", "price_coins", "sort", "enabled"), - "Boosters": h.rows(ctx, `SELECT id,title,multiplier,hours,price_toman,sort,enabled FROM boosters ORDER BY sort`, - "id", "title", "multiplier", "hours", "price_toman", "sort", "enabled"), - "VIP": h.rows(ctx, `SELECT id,title,months,price_toman,sort,enabled FROM vip_packages ORDER BY sort`, - "id", "title", "months", "price_toman", "sort", "enabled"), + "Boosters": h.rows(ctx, `SELECT id,title,multiplier,hours,price_toman,sku,sort,enabled FROM boosters ORDER BY sort`, + "id", "title", "multiplier", "hours", "price_toman", "sku", "sort", "enabled"), + "VIP": h.rows(ctx, `SELECT id,title,months,price_toman,sku,sort,enabled FROM vip_packages ORDER BY sort`, + "id", "title", "months", "price_toman", "sku", "sort", "enabled"), "Tiers": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled FROM table_tiers ORDER BY sort`, "id", "title", "hands", "entry", "prize", "xp", "trophy", "rank_reward", "sort", "enabled"), "Saved": r.URL.Query().Get("saved") == "1", @@ -181,25 +183,26 @@ func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) { if r.FormValue("enabled") == "on" { en = 1 } + sku := newUUID() // شناسه‌ی محصولِ فروشگاه (uuid) خودکار تولید می‌شود var q string var args []any switch kind { case "coin": - q = `INSERT INTO coin_packages (id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled) VALUES (?,?,?,?,?,?,?,?)` - args = []any{id, r.FormValue("title"), f("coins"), f("vip_days"), f("price_toman"), f("bonus_pct"), f("sort"), en} + q = `INSERT INTO coin_packages (id,title,coins,vip_days,price_toman,bonus_pct,sku,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?)` + args = []any{id, r.FormValue("title"), f("coins"), f("vip_days"), f("price_toman"), f("bonus_pct"), sku, f("sort"), en} case "ticket": - q = `INSERT INTO ticket_packages (id,title,tickets,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)` - args = []any{id, r.FormValue("title"), f("tickets"), f("price_toman"), f("sort"), en} + q = `INSERT INTO ticket_packages (id,title,tickets,price_toman,sku,sort,enabled) VALUES (?,?,?,?,?,?,?)` + args = []any{id, r.FormValue("title"), f("tickets"), f("price_toman"), sku, f("sort"), en} case "card": q = `INSERT INTO card_skins (id,title,price_coins,sort,enabled) VALUES (?,?,?,?,?)` args = []any{id, r.FormValue("title"), f("price_coins"), f("sort"), en} case "booster": - q = `INSERT INTO boosters (id,title,multiplier,hours,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)` - args = []any{id, r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), f("sort"), en} + q = `INSERT INTO boosters (id,title,multiplier,hours,price_toman,sku,sort,enabled) VALUES (?,?,?,?,?,?,?)` + args = []any{id, r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), sku, f("sort"), en} case "vip": - q = `INSERT INTO vip_packages (id,title,months,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)` - args = []any{id, r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en} + q = `INSERT INTO vip_packages (id,title,months,price_toman,sku,sort,enabled) VALUES (?,?,?,?,?,?,?)` + args = []any{id, r.FormValue("title"), f("months"), f("price_toman"), sku, f("sort"), en} case "tier": q = `INSERT INTO table_tiers (id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?,?)` args = []any{id, r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), f("sort"), en} @@ -643,6 +646,17 @@ func (h *Handler) chatMessageDelete(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/chat?saved=1", http.StatusSeeOther) } +// newUUID یک UUIDv4 برای شناسه‌ی محصولِ فروشگاه (SKU) تولید می‌کند. +func newUUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("sku-%d", time.Now().UnixNano()) + } + b[6] = (b[6] & 0x0f) | 0x40 // نسخه ۴ + b[8] = (b[8] & 0x3f) | 0x80 // variant + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any { rs, err := h.db.QueryContext(ctx, query) if err != nil { diff --git a/internal/admin/templates/shop.html b/internal/admin/templates/shop.html index 27758d3..abd5297 100644 --- a/internal/admin/templates/shop.html +++ b/internal/admin/templates/shop.html @@ -10,7 +10,7 @@

بسته‌های سکه

- + {{range .Coins}} @@ -19,6 +19,7 @@ + @@ -32,6 +33,7 @@ + @@ -40,13 +42,14 @@

بسته‌های بلیط

شناسهعنوانسکهVIP (روز)قیمت (تومان)بونوس٪ترتیبفعال
شناسهعنوانسکهVIP (روز)قیمت (تومان)بونوس٪SKU (مایکت/کافه)ترتیبفعال
{{.id}} {{.sku}} خودکار
- + {{range .Tickets}} + @@ -58,6 +61,7 @@ + @@ -98,7 +102,7 @@

بوسترها

شناسهعنوانبلیطقیمت (تومان)ترتیبفعال
شناسهعنوانبلیطقیمت (تومان)SKU (مایکت/کافه)ترتیبفعال
{{.id}} {{.sku}} خودکار
- + {{range .Boosters}} @@ -106,6 +110,7 @@ + @@ -118,6 +123,7 @@ + @@ -126,13 +132,14 @@

اشتراک‌های VIP

شناسهعنوانضریبساعتقیمت (تومان)ترتیبفعال
شناسهعنوانضریبساعتقیمت (تومان)SKU (مایکت/کافه)ترتیبفعال
{{.id}} {{.sku}} خودکار
- + {{range .VIP}} + @@ -144,6 +151,7 @@ + diff --git a/internal/config/config.go b/internal/config/config.go index 76075dd..5786681 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,10 @@ type Config struct { AdminPass string // پسوردِ پنل ادمین (Basic Auth) CardsDir string // پوشه‌ی دیسکیِ اسکین‌های آپلودی (روی volume ماندگار) CarpetsDir string // پوشه‌ی دیسکیِ تصاویرِ فرش (روی volume ماندگار) + // خرید درون‌برنامه‌ای (IAP) + IAPPackage string // نامِ بسته‌ی اپ (برای مایکت/کافه) + MyketAccessToken string // access token مایکت (تأیید سمت‌سرور) + BazaarRSAKey string // کلیدِ عمومیِ RSA کافه‌بازار (تأیید امضای آفلاین) } // Load کانفیگ را از env با مقادیر پیش‌فرض معقول می‌خواند. @@ -41,6 +45,9 @@ func Load() Config { AdminPass: env("ADMIN_PANEL_PASS", "admin"), CardsDir: env("CARDS_DIR", "carddata"), CarpetsDir: env("CARPETS_DIR", "carpetdata"), + IAPPackage: env("IAP_PACKAGE", ""), + MyketAccessToken: env("MYKET_ACCESS_TOKEN", ""), + BazaarRSAKey: env("BAZAAR_RSA_KEY", ""), } } diff --git a/internal/economy/catalog.go b/internal/economy/catalog.go index ce25aa5..f2ac4d9 100644 --- a/internal/economy/catalog.go +++ b/internal/economy/catalog.go @@ -8,6 +8,7 @@ const ( ) // CoinPackage بسته‌ی سکه با پول واقعی (IAP) — ممکن است VIP هم بدهد. +// SKU شناسه‌ی محصول در پنلِ مایکت/کافه‌بازار (uuid) است. type CoinPackage struct { ID string `json:"id"` Title string `json:"title"` @@ -15,6 +16,7 @@ type CoinPackage struct { VIPDays int `json:"vip_days"` PriceToman int `json:"price_toman"` BonusPct int `json:"bonus_pct"` + SKU string `json:"sku"` } // TicketPackage بسته‌ی بلیط با پول واقعی (IAP). @@ -23,6 +25,7 @@ type TicketPackage struct { Title string `json:"title"` Tickets int64 `json:"tickets"` PriceToman int `json:"price_toman"` + SKU string `json:"sku"` } // CardSkin اسکین کارت که با سکه باز می‌شود (قیمت ۰ یعنی پیش‌فرض رایگان). @@ -39,6 +42,7 @@ type Booster struct { Multiplier int `json:"multiplier"` Hours int `json:"hours"` PriceToman int `json:"price_toman"` + SKU string `json:"sku"` } // VIPPackage بسته‌ی اشتراک VIP با پول واقعی (IAP). @@ -47,6 +51,7 @@ type VIPPackage struct { Title string `json:"title"` Months int `json:"months"` PriceToman int `json:"price_toman"` + SKU string `json:"sku"` } // TableTier نوع میز: تعداد دست، ورودی، جایزه، XP و جام. diff --git a/internal/economy/catalog_store.go b/internal/economy/catalog_store.go index bfd86e8..10571f7 100644 --- a/internal/economy/catalog_store.go +++ b/internal/economy/catalog_store.go @@ -8,13 +8,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error { var c Catalog rows, err := s.db.QueryContext(ctx, - `SELECT id, title, coins, vip_days, price_toman, bonus_pct FROM coin_packages WHERE enabled = 1 ORDER BY sort`) + `SELECT id, title, coins, vip_days, price_toman, bonus_pct, sku FROM coin_packages WHERE enabled = 1 ORDER BY sort`) if err != nil { return err } for rows.Next() { var p CoinPackage - if err := rows.Scan(&p.ID, &p.Title, &p.Coins, &p.VIPDays, &p.PriceToman, &p.BonusPct); err != nil { + if err := rows.Scan(&p.ID, &p.Title, &p.Coins, &p.VIPDays, &p.PriceToman, &p.BonusPct, &p.SKU); err != nil { rows.Close() return err } @@ -23,13 +23,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error { rows.Close() rows, err = s.db.QueryContext(ctx, - `SELECT id, title, tickets, price_toman FROM ticket_packages WHERE enabled = 1 ORDER BY sort`) + `SELECT id, title, tickets, price_toman, sku FROM ticket_packages WHERE enabled = 1 ORDER BY sort`) if err != nil { return err } for rows.Next() { var p TicketPackage - if err := rows.Scan(&p.ID, &p.Title, &p.Tickets, &p.PriceToman); err != nil { + if err := rows.Scan(&p.ID, &p.Title, &p.Tickets, &p.PriceToman, &p.SKU); err != nil { rows.Close() return err } @@ -53,13 +53,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error { rows.Close() rows, err = s.db.QueryContext(ctx, - `SELECT id, title, multiplier, hours, price_toman FROM boosters WHERE enabled = 1 ORDER BY sort`) + `SELECT id, title, multiplier, hours, price_toman, sku FROM boosters WHERE enabled = 1 ORDER BY sort`) if err != nil { return err } for rows.Next() { var p Booster - if err := rows.Scan(&p.ID, &p.Title, &p.Multiplier, &p.Hours, &p.PriceToman); err != nil { + if err := rows.Scan(&p.ID, &p.Title, &p.Multiplier, &p.Hours, &p.PriceToman, &p.SKU); err != nil { rows.Close() return err } @@ -68,13 +68,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error { rows.Close() rows, err = s.db.QueryContext(ctx, - `SELECT id, title, months, price_toman FROM vip_packages WHERE enabled = 1 ORDER BY sort`) + `SELECT id, title, months, price_toman, sku FROM vip_packages WHERE enabled = 1 ORDER BY sort`) if err != nil { return err } for rows.Next() { var p VIPPackage - if err := rows.Scan(&p.ID, &p.Title, &p.Months, &p.PriceToman); err != nil { + if err := rows.Scan(&p.ID, &p.Title, &p.Months, &p.PriceToman, &p.SKU); err != nil { rows.Close() return err } diff --git a/internal/economy/economy.go b/internal/economy/economy.go index 8c79f79..e02b5eb 100644 --- a/internal/economy/economy.go +++ b/internal/economy/economy.go @@ -248,27 +248,28 @@ func (s *Service) addXPTx(ctx context.Context, tx *sql.Tx, userID int64, base in type PurchaseRequest struct { Store string `json:"store"` // bazaar | myket Kind string `json:"kind"` // coin | ticket | booster | vip - ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ + ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ (SKU فروشگاه) Token string `json:"token"` // purchaseToken + // برای تأیید امضای آفلاین (کافه‌بازار): متنِ خامِ خرید و امضای RSA (base64). + PurchaseData string `json:"purchase_data"` + Signature string `json:"signature"` } // VerifyPurchase خرید را تأیید و در صورت معتبر بودن اعتبار/بلیط/VIP/بوستر می‌دهد (idempotent). func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req PurchaseRequest) error { - ok, err := s.iap.Verify(ctx, req.Store, req.ProductID, req.Token) - if err != nil || !ok { - return ErrIAPInvalid - } - + // ابتدا بسته را از کاتالوگ می‌یابیم تا پاداش و SKU (شناسه‌ی محصول در فروشگاه) + // را به‌دست آوریم؛ سپس با همان SKU خرید را تأیید می‌کنیم. var coins, tickets int64 var vipDays int var booster *Booster + var sku string switch req.Kind { case "coin": p := s.findCoinPackage(req.ProductID) if p == nil { return ErrNotFound } - coins, vipDays = p.Coins, p.VIPDays + coins, vipDays, sku = p.Coins, p.VIPDays, p.SKU if s.isVIP(ctx, userID) { coins += coins / 10 // پاداش ۱۰٪ VIP } @@ -277,21 +278,36 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase if p == nil { return ErrNotFound } - tickets = p.Tickets + tickets, sku = p.Tickets, p.SKU case "booster": booster = s.findBooster(req.ProductID) if booster == nil { return ErrNotFound } + sku = booster.SKU case "vip": p := s.findVIPPackage(req.ProductID) if p == nil { return ErrNotFound } - vipDays = p.Months * 30 // هر ماه = ۳۰ روز + vipDays, sku = p.Months*30, p.SKU // هر ماه = ۳۰ روز default: return ErrNotFound } + if sku == "" { + sku = req.ProductID // اگر SKU تنظیم نشده، از شناسه‌ی کاتالوگ استفاده می‌شود + } + + ok, err := s.iap.Verify(ctx, IAPProof{ + Store: req.Store, + ProductID: sku, + Token: req.Token, + PurchaseData: req.PurchaseData, + Signature: req.Signature, + }) + if err != nil || !ok { + return ErrIAPInvalid + } tx, err := s.db.BeginTx(ctx, nil) if err != nil { diff --git a/internal/economy/verify.go b/internal/economy/verify.go index 1dedcee..9c7cd36 100644 --- a/internal/economy/verify.go +++ b/internal/economy/verify.go @@ -2,9 +2,16 @@ package economy import ( "context" + "crypto" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "net/http" + "strings" "time" ) @@ -13,9 +20,20 @@ type AdVerifier interface { Verify(ctx context.Context, userID int64, token string) (bool, error) } +// IAPProof داده‌های لازم برای تأیید یک خرید درون‌برنامه‌ای. +// برای مایکت: Store/ProductID/Token کافی است (تأیید سمت‌سرور با access token). +// برای کافه‌بازار: PurchaseData + Signature برای تأیید امضای RSA آفلاین. +type IAPProof struct { + Store string + ProductID string + Token string + PurchaseData string + Signature string +} + // IAPVerifier تأیید می‌کند که یک خرید درون‌برنامه‌ای معتبر و پرداخت‌شده است. type IAPVerifier interface { - Verify(ctx context.Context, store, productID, purchaseToken string) (bool, error) + Verify(ctx context.Context, p IAPProof) (bool, error) } // --- استاب توسعه: هر توکن غیرخالی را معتبر می‌داند (فقط برای محیط dev/تست) --- @@ -28,8 +46,8 @@ func (DevAdVerifier) Verify(_ context.Context, _ int64, token string) (bool, err type DevIAPVerifier struct{} -func (DevIAPVerifier) Verify(_ context.Context, _ string, _ string, token string) (bool, error) { - return token != "", nil +func (DevIAPVerifier) Verify(_ context.Context, p IAPProof) (bool, error) { + return p.Token != "", nil } // --- آداپتر تپسل (تأیید سمت‌سرور تبلیغ rewarded) --- @@ -77,63 +95,137 @@ func (t *TapsellAdVerifier) Verify(ctx context.Context, userID int64, token stri return out.Valid && out.Completed, nil } -// --- آداپتر IAP کافه‌بازار / مایکت --- +// --- مایکت: تأیید سمت‌سرور با X-Access-Token --- // -// هر دو فروشگاه API شبیه Google Play دارند: با access_token (که از refresh_token -// گرفته می‌شود) وضعیت یک purchaseToken برای یک محصول را اعتبارسنجی می‌کنیم. -type StoreIAPVerifier struct { - // قالب آدرس اعتبارسنجی؛ %s ها به ترتیب: package, product, token - ValidateURLFmt string - PackageName string - AccessToken string // در عمل باید از refresh_token تازه‌سازی شود - client *http.Client +// GET https://developer.myket.ir/api/applications/{pkg}/purchases/products/{sku}/tokens/{token} +// هدر: X-Access-Token: . پاسخ purchaseState=0 یعنی معتبر (غیرِ صفر=مسترد). +type MyketVerifier struct { + PackageName string + AccessToken string + client *http.Client } -// NewBazaarVerifier آداپتر کافه‌بازار. -func NewBazaarVerifier(pkg, accessToken string) *StoreIAPVerifier { - return &StoreIAPVerifier{ - ValidateURLFmt: "https://pardakht.cafebazaar.ir/devapi/v2/api/validate/%s/inapp/%s/purchases/%s/", - PackageName: pkg, - AccessToken: accessToken, - client: &http.Client{Timeout: 6 * time.Second}, +func NewMyketVerifier(pkg, accessToken string) *MyketVerifier { + return &MyketVerifier{ + PackageName: pkg, + AccessToken: accessToken, + client: &http.Client{Timeout: 6 * time.Second}, } } -// NewMyketVerifier آداپتر مایکت. -func NewMyketVerifier(pkg, accessToken string) *StoreIAPVerifier { - return &StoreIAPVerifier{ - ValidateURLFmt: "https://developer.myket.ir/api/applications/%s/purchases/products/%s/tokens/%s/", - PackageName: pkg, - AccessToken: accessToken, - client: &http.Client{Timeout: 6 * time.Second}, +func (v *MyketVerifier) Verify(ctx context.Context, p IAPProof) (bool, error) { + if v.AccessToken == "" || p.Token == "" { + return false, fmt.Errorf("myket: missing access token or purchase token") } -} - -func (v *StoreIAPVerifier) Verify(ctx context.Context, _ string, productID, purchaseToken string) (bool, error) { - if v.AccessToken == "" || purchaseToken == "" { - return false, fmt.Errorf("iap: missing access token or purchase token") - } - url := fmt.Sprintf(v.ValidateURLFmt, v.PackageName, productID, purchaseToken) + url := fmt.Sprintf( + "https://developer.myket.ir/api/applications/%s/purchases/products/%s/tokens/%s", + v.PackageName, p.ProductID, p.Token) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return false, err } - req.Header.Set("Authorization", "Bearer "+v.AccessToken) + req.Header.Set("X-Access-Token", v.AccessToken) resp, err := v.client.Do(req) if err != nil { return false, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return false, nil + return false, nil // ۴۰۴/۴۰۱ ⇒ توکنِ نامعتبر } var out struct { - // هر دو فروشگاه فیلدی شبیه purchaseState برمی‌گردانند (۰ = خریداری‌شده) - PurchaseState int `json:"purchaseState"` - ConsumptionState int `json:"consumptionState"` + PurchaseState int `json:"purchaseState"` // ۰=خریداری‌شده، غیرِصفر=مسترد } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return false, err } return out.PurchaseState == 0, nil } + +// --- کافه‌بازار: تأیید امضای RSA آفلاین (فقط کلید عمومی لازم است) --- +// +// کلاینت purchaseData (متنِ خامِ JSON) و signature (base64) را می‌فرستد؛ صحتِ امضا +// با کلیدِ عمومیِ RSA و الگوریتم SHA1withRSA (مانند Google Play/Bazaar) بررسی می‌شود. +type BazaarRSAVerifier struct { + pub *rsa.PublicKey +} + +// NewBazaarRSAVerifier کلیدِ عمومی را از رشته‌ی base64 (DER/PKIX) یا PEM می‌پذیرد. +func NewBazaarRSAVerifier(publicKey string) (*BazaarRSAVerifier, error) { + pub, err := parseRSAPublicKey(publicKey) + if err != nil { + return nil, err + } + return &BazaarRSAVerifier{pub: pub}, nil +} + +func (v *BazaarRSAVerifier) Verify(_ context.Context, p IAPProof) (bool, error) { + if p.PurchaseData == "" || p.Signature == "" { + return false, fmt.Errorf("bazaar: missing purchase data or signature") + } + sig, err := base64.StdEncoding.DecodeString(p.Signature) + if err != nil { + return false, fmt.Errorf("bazaar: 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 +} + +// parseRSAPublicKey کلیدِ عمومیِ RSA را از PEM یا base64ِ خامِ DER (خروجیِ کنسولِ +// توسعه‌دهنده) استخراج می‌کند. +func parseRSAPublicKey(key string) (*rsa.PublicKey, error) { + key = strings.TrimSpace(key) + var der []byte + if strings.Contains(key, "BEGIN") { + block, _ := pem.Decode([]byte(key)) + if block == nil { + return nil, fmt.Errorf("rsa: invalid PEM") + } + der = block.Bytes + } else { + clean := strings.NewReplacer("\n", "", "\r", "", " ", "").Replace(key) + b, err := base64.StdEncoding.DecodeString(clean) + if err != nil { + return nil, fmt.Errorf("rsa: invalid base64 key: %w", err) + } + der = b + } + pub, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return nil, fmt.Errorf("rsa: parse key: %w", err) + } + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("rsa: not an RSA public key") + } + return rsaPub, nil +} + +// StoreRouter بر اساس store، تأیید را به مایکت یا کافه‌بازار می‌سپارد. +// اگر فروشگاهی پیکربندی نشده باشد و devFallback روشن باشد، از DevIAPVerifier +// استفاده می‌شود (برای توسعه/تست). +type StoreRouter struct { + Myket IAPVerifier + Bazaar IAPVerifier + DevFallback bool +} + +func (r StoreRouter) Verify(ctx context.Context, p IAPProof) (bool, error) { + var v IAPVerifier + switch p.Store { + case "myket": + v = r.Myket + case "bazaar", "cafe", "cafebazaar": + v = r.Bazaar + } + if v == nil { + if r.DevFallback { + return DevIAPVerifier{}.Verify(ctx, p) + } + return false, fmt.Errorf("iap: store %q not configured", p.Store) + } + return v.Verify(ctx, p) +} diff --git a/internal/economy/verify_test.go b/internal/economy/verify_test.go new file mode 100644 index 0000000..dcde7d8 --- /dev/null +++ b/internal/economy/verify_test.go @@ -0,0 +1,75 @@ +package economy + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/base64" + "testing" +) + +// TestBazaarRSAVerify امضای معتبر را می‌پذیرد و امضای/دیتای دستکاری‌شده را رد می‌کند. +func TestBazaarRSAVerify(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + pubB64 := base64.StdEncoding.EncodeToString(der) // مثلِ خروجیِ کنسولِ کافه‌بازار + + v, err := NewBazaarRSAVerifier(pubB64) + if err != nil { + t.Fatalf("new verifier: %v", err) + } + + data := `{"orderId":"1","productId":"coin_small","purchaseToken":"tok123","purchaseState":0}` + h := sha1.Sum([]byte(data)) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, h[:]) + if err != nil { + t.Fatal(err) + } + sigB64 := base64.StdEncoding.EncodeToString(sig) + + ctx := context.Background() + + ok, err := v.Verify(ctx, IAPProof{PurchaseData: data, Signature: sigB64}) + if err != nil || !ok { + t.Fatalf("valid signature rejected: ok=%v err=%v", ok, err) + } + + // دیتای دستکاری‌شده ⇒ رد + ok, _ = v.Verify(ctx, IAPProof{PurchaseData: data + " ", Signature: sigB64}) + if ok { + t.Fatal("tampered data accepted") + } + + // امضای دستکاری‌شده ⇒ رد + bad := base64.StdEncoding.EncodeToString([]byte("not-a-real-signature")) + ok, _ = v.Verify(ctx, IAPProof{PurchaseData: data, Signature: bad}) + if ok { + t.Fatal("bad signature accepted") + } +} + +// TestStoreRouter مسیر‌دهی بر اساس store و fallback را بررسی می‌کند. +func TestStoreRouter(t *testing.T) { + ctx := context.Background() + + // بدونِ پیکربندی و بدونِ fallback ⇒ خطا. + r := StoreRouter{} + if ok, err := r.Verify(ctx, IAPProof{Store: "myket", Token: "x"}); ok || err == nil { + t.Fatal("unconfigured store should error") + } + + // با devFallback ⇒ توکنِ غیرخالی معتبر است. + r = StoreRouter{DevFallback: true} + if ok, err := r.Verify(ctx, IAPProof{Store: "bazaar", Token: "x"}); err != nil || !ok { + t.Fatalf("dev fallback failed: ok=%v err=%v", ok, err) + } +} diff --git a/internal/store/migrations/010_sku.sql b/internal/store/migrations/010_sku.sql new file mode 100644 index 0000000..1736521 --- /dev/null +++ b/internal/store/migrations/010_sku.sql @@ -0,0 +1,11 @@ +-- شناسه‌ی محصولِ فروشگاه (uuid) در پنلِ مایکت/کافه‌بازار برای تأییدِ خرید. +ALTER TABLE coin_packages ADD COLUMN sku TEXT NOT NULL DEFAULT ''; +ALTER TABLE ticket_packages ADD COLUMN sku TEXT NOT NULL DEFAULT ''; +ALTER TABLE boosters ADD COLUMN sku TEXT NOT NULL DEFAULT ''; +ALTER TABLE vip_packages ADD COLUMN sku TEXT NOT NULL DEFAULT ''; + +-- تولیدِ خودکارِ uuid برای محصولاتِ موجود (شناسه‌ی یکتا برای پنلِ مایکت/کافه). +UPDATE coin_packages SET sku = lower(hex(randomblob(16))) WHERE sku = ''; +UPDATE ticket_packages SET sku = lower(hex(randomblob(16))) WHERE sku = ''; +UPDATE boosters SET sku = lower(hex(randomblob(16))) WHERE sku = ''; +UPDATE vip_packages SET sku = lower(hex(randomblob(16))) WHERE sku = '';
شناسهعنوانماهقیمت (تومان)ترتیبفعال
شناسهعنوانماهقیمت (تومان)SKU (مایکت/کافه)ترتیبفعال
{{.id}} {{.sku}} خودکار