diff --git a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt index 3cf064b..86d3cc6 100644 --- a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt +++ b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt @@ -48,6 +48,15 @@ class MainActivity : AppCompatActivity() { /** فقط خودِ سایت و فقط با https؛ «http://دامنه‌ی ما» قابلِ دست‌کاریِ شبکه است و نباید پل داشته باشد. */ private fun isOurPage(uri: Uri?): Boolean = uri != null && uri.scheme == "https" && uri.host == webHost + /** API و درگاه: زیردامنه‌های دامنه‌ی خودمان (mathapi…)، زرین‌پال و صفحه‌های بانکیِ شاپرک؛ فقط https. پل این‌جا کار نمی‌کند. */ + private fun isPaymentPage(uri: Uri?): Boolean { + val host = uri?.host ?: return false + if (uri.scheme != "https") return false + val ours = webHost.substringAfter('.') + return host == ours || host.endsWith(".$ours") || + host == "zarinpal.com" || host.endsWith(".zarinpal.com") || host.endsWith(".shaparak.ir") + } + @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -66,6 +75,9 @@ class MainActivity : AppCompatActivity() { // در صفحه‌های خودمان در دسترس است. override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { if (isOurPage(request.url)) return false + // پرداخت با درگاه داخلِ خودِ اپ می‌ماند: زرین‌پال بی‌Refererِ دامنه‌ی ما صفحه را باز نمی‌کند و مرورگرِ + // بیرونی Referer نمی‌فرستد. بعد از پرداخت، زرین‌پال به سایتِ ما برمی‌گرداند و نتیجه همین‌جا دیده می‌شود. + if (isPaymentPage(request.url)) return false // فقط http(s) به مرورگرِ گوشی می‌رود؛ intent:، file: و schemeهای سفارشی بی‌صدا بسته می‌شوند // (پیش‌تر هر آدرسی بیرونِ دامنه به ACTION_VIEW می‌رفت و صفحه می‌توانست اپِ دلخواه باز کند). if (request.url.scheme == "https" || request.url.scheme == "http") { @@ -193,6 +205,10 @@ class MainActivity : AppCompatActivity() { else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1) } + /** فرانت با این می‌فهمد درگاه داخلِ اپ باز می‌شود (isPaymentPage)، نه در مرورگرِ بیرونی. */ + @JavascriptInterface + fun paysInApp(): Boolean = true + @JavascriptInterface fun bazaarConsume(purchaseToken: String) { runOnUiThread { diff --git a/pay.html b/pay.html new file mode 100644 index 0000000..07a2463 --- /dev/null +++ b/pay.html @@ -0,0 +1,28 @@ + + + + + + + + + رفتن به درگاه — تدریسینو + + + + +
+
+

داریم می‌ریم به درگاهِ پرداخت…

+ رفتن به درگاه +
+ + + diff --git a/src/pay.ts b/src/pay.ts new file mode 100644 index 0000000..562c1d0 --- /dev/null +++ b/src/pay.ts @@ -0,0 +1,50 @@ +import { resolveApiBaseUrl } from '@/config'; + +/** + * صفحه‌ی واسطه‌ی پرداخت (pay.html)، برای اپِ اندرویدی که درگاه را در مرورگرِ گوشی باز می‌کند. + * + * زرین‌پال صفحه‌ی StartPay را فقط وقتی باز می‌کند که Referer از دامنه‌ی ثبت‌شده باشد؛ بی‌آن می‌گوید «دسترسی از این + * دامنه مجاز نمی‌باشد» (روی سرور با درخواستِ بی‌Referer دیده شد، ۱۴۰۵-۰۷). مرورگری که اپِ دیگری بازش کرده Referer + * نمی‌فرستد. پس اپ اول همین صفحه را در مرورگر باز می‌کند و این صفحه کاربر را به لینکِ پرداخت می‌فرستد؛ حالا Referer + * از math.tadrisino2.ir است، درست مثلِ پرداخت از سایت. + * + * فقط لینکِ پرداختِ API خودمان پذیرفته می‌شود (پیشوندِ آدرسِ API + /payment/orders//pay)، تا این صفحه + * ریدایرکتِ باز به هر جایی نشود. + */ +const msg = document.getElementById('msg')!; +const spin = document.getElementById('spin')!; +const go = document.getElementById('go') as HTMLAnchorElement; + +function fail(text: string) { + spin.style.display = 'none'; + msg.textContent = text; +} + +// در fragment می‌آید، نه query: اپ این صفحه را با http:// باز می‌کند (پایینِ SubscriptionScreen) و fragment هرگز +// به سرور فرستاده نمی‌شود، پس امضای لینکِ پرداخت روی آن یک گامِ بی‌رمز دیده نمی‌شود. +const raw = new URLSearchParams(location.hash.slice(1)).get('u') ?? ''; +let target: URL | null = null; +try { + target = new URL(raw); +} catch { + target = null; +} +const api = new URL(resolveApiBaseUrl() + '/', location.origin); +const ok = + target !== null && + target.protocol === 'https:' && + target.origin === api.origin && + new RegExp(`^${api.pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}payment/orders/\\d+/pay$`).test(target.pathname); + +if (!ok || !target) { + fail('این لینکِ پرداخت معتبر نیست. به اپ برگرد و دوباره «پرداخت» رو بزن.'); +} else { + go.href = target.href; + location.replace(target.href); + // اگر مرورگر جابه‌جا نشد (مثلاً جلوی ریدایرکت را گرفت)، دکمه‌ی دستی. + setTimeout(() => { + spin.style.display = 'none'; + msg.textContent = 'اگه صفحه‌ی پرداخت باز نشد، این دکمه رو بزن:'; + go.style.display = 'inline-block'; + }, 2500); +} diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx index a1eba6b..2c6b3cc 100644 --- a/src/screens/SubscriptionScreen.tsx +++ b/src/screens/SubscriptionScreen.tsx @@ -333,9 +333,18 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b if (!order.paymentUrl) throw new Error('order without paymentUrl'); // فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود. if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)'); - window.location.assign(order.paymentUrl); - // داخلِ اپ آدرسِ درگاه در مرورگرِ گوشی باز می‌شود و این صفحه همین‌جا می‌ماند؛ دکمه نباید تا ابد بچرخد. - if (bazaar) setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500); + const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid; + if (bazaar && !shell?.paysInApp?.()) { + // اپِ اندرویدِ فعلی هر آدرسِ بیرونی را در مرورگرِ گوشی باز می‌کند و آن مرورگر Referer نمی‌فرستد؛ زرین‌پال + // بی‌Referer می‌گوید «دسترسی از این دامنه مجاز نمی‌باشد». پس اول pay.htmlِ خودمان در مرورگر باز می‌شود + // (src/pay.ts) و آن به درگاه می‌رود. «http://» عمداً است: WebView فقط https‌ِ همین دامنه را در خودش نگه + // می‌دارد؛ آروان بلافاصله به https می‌برد و لینک در fragment است، پس به سرور نمی‌رسد. + window.location.assign(`http://${window.location.host}/pay.html#u=${encodeURIComponent(order.paymentUrl)}`); + setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500); + } else { + // مرورگر، یا اپی که پرداخت را در خودش باز می‌کند (paysInApp): همین صفحه به درگاه می‌رود و Referer دارد. + window.location.assign(order.paymentUrl); + } } catch (err) { setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن')); setBusy(false); @@ -397,7 +406,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b void pay()}>پرداخت {method === 'gateway' && (

- {bazaar ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز می‌شه؛ بعدش برگرد به اپ.' : 'پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.'} + پرداخت در درگاهِ امنِ زرین‌پال انجام می‌شه.

)} diff --git a/vite.config.ts b/vite.config.ts index 61997ac..b3bc678 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => { }, build: { rollupOptions: { + // pay.html: صفحه‌ی واسطه‌ی درگاه برای اپِ اندروید (src/pay.ts). + input: { main: path.resolve(__dirname, 'index.html'), pay: path.resolve(__dirname, 'pay.html') }, output: { // کتابخانه‌ها با هر انتشار عوض نمی‌شوند؛ فایلِ جدا یعنی بعد از هر دیپلوی // از کشِ مرورگر می‌آیند و فقط کدِ خودِ اپ دوباره دانلود می‌شود.