232 lines
7.5 KiB
Go
232 lines
7.5 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
|
|
}
|
|
|
|
// --- مایکت: تأیید سمتسرور با X-Access-Token ---
|
|
//
|
|
// 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
|
|
client *http.Client
|
|
}
|
|
|
|
func NewMyketVerifier(pkg, accessToken string) *MyketVerifier {
|
|
return &MyketVerifier{
|
|
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")
|
|
}
|
|
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)
|
|
}
|