Files
2026-07-05 08:42:42 +03:30

264 lines
9.3 KiB
Go

package economy
import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"strings"
"time"
)
// AdVerifier تأیید می‌کند که کاربر یک تبلیغ rewarded را کامل دیده است.
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, p IAPProof) (bool, error)
}
// --- استاب توسعه: هر توکن غیرخالی را معتبر می‌داند (فقط برای محیط dev/تست) ---
type DevAdVerifier struct{}
func (DevAdVerifier) Verify(_ context.Context, _ int64, token string) (bool, error) {
return token != "", nil
}
type DevIAPVerifier struct{}
func (DevIAPVerifier) Verify(_ context.Context, p IAPProof) (bool, error) {
return p.Token != "", nil
}
// --- آداپتر تپسل (تأیید سمت‌سرور تبلیغ rewarded) ---
//
// تپسل پس از پایان کامل تبلیغ، یک توکن/شناسه می‌دهد که سرور باید آن را
// نزد تپسل اعتبارسنجی کند. آدرس و قالب دقیق بسته به پنل تپسل تنظیم می‌شود؛
// اینجا اسکلت فراخوانی با کلید قرار داده شده است.
type TapsellAdVerifier struct {
APIKey string
BaseURL string // پیش‌فرض اگر خالی باشد ست می‌شود
client *http.Client
}
func NewTapsellAdVerifier(apiKey string) *TapsellAdVerifier {
return &TapsellAdVerifier{
APIKey: apiKey,
BaseURL: "https://api.tapsell.ir",
client: &http.Client{Timeout: 5 * time.Second},
}
}
func (t *TapsellAdVerifier) Verify(ctx context.Context, userID int64, token string) (bool, error) {
if t.APIKey == "" || token == "" {
return false, fmt.Errorf("tapsell: missing api key or token")
}
// TODO: مسیر دقیق اعتبارسنجی تپسل را مطابق مستندات پنل تنظیم کنید.
url := fmt.Sprintf("%s/rewarded/verify?token=%s", t.BaseURL, token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err
}
req.Header.Set("Authorization", t.APIKey)
resp, err := t.client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
var out struct {
Valid bool `json:"valid"`
Completed bool `json:"completed"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return false, err
}
return out.Valid && out.Completed, nil
}
// --- مایکت: تأیید امضای RSA آفلاین (اولویت) یا سمت‌سرور با X-Access-Token ---
//
// افزونه‌ی myket_iap مثلِ کافه‌بازار، purchaseData (JSONِ خام) و signature (base64)
// برمی‌گرداند که با کلیدِ عمومیِ RSA و SHA1withRSA به‌صورتِ آفلاین تأیید می‌شوند —
// بدونِ رفت‌وبرگشتِ شبکه به‌ازای هر خرید. اگر کلیدِ عمومی تنظیم نشده باشد یا امضا
// ارسال نشده باشد، به تأییدِ سمت‌سرور برمی‌گردیم:
// GET https://developer.myket.ir/api/applications/{pkg}/purchases/products/{sku}/tokens/{token}
// هدر: X-Access-Token: <accessToken> . پاسخ 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,
AccessToken: accessToken,
client: &http.Client{Timeout: 6 * time.Second},
}
}
// 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")
}
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("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 // ۴۰۴/۴۰۱ ⇒ توکنِ نامعتبر
}
var out struct {
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)
}