Payment from the Android app: reach Zarinpal through pay.html so it carries our Referer
The app hands every outside URL to the phone browser, which sends no Referer, and Zarinpal's StartPay refuses a request without our domain as Referer («دسترسی از این دامنه مجاز نمیباشد»). Checked against production on a pending order: no Referer → refused; Referer https://math.tadrisino2.ir/ → forwards to the bank (sep.shaparak.ir). - pay.html + src/pay.ts: opened in the phone browser, forwards only to our own /api/payment/orders/<id>/pay link (anything else is refused), so the Referer is math.tadrisino2.ir. The app opens it as http:// (the WebView keeps only https on its own host); ArvanCloud moves it to https, and the link rides in the fragment, which never reaches a server. - MainActivity.kt (next APK): payment pages (our subdomains, zarinpal, shaparak) stay inside the WebView and `paysInApp()` tells the frontend to navigate directly. Not built. Not checked: a full payment on a real phone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,15 @@ class MainActivity : AppCompatActivity() {
|
|||||||
/** فقط خودِ سایت و فقط با https؛ «http://دامنهی ما» قابلِ دستکاریِ شبکه است و نباید پل داشته باشد. */
|
/** فقط خودِ سایت و فقط با https؛ «http://دامنهی ما» قابلِ دستکاریِ شبکه است و نباید پل داشته باشد. */
|
||||||
private fun isOurPage(uri: Uri?): Boolean = uri != null && uri.scheme == "https" && uri.host == webHost
|
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")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -66,6 +75,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// در صفحههای خودمان در دسترس است.
|
// در صفحههای خودمان در دسترس است.
|
||||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||||
if (isOurPage(request.url)) return false
|
if (isOurPage(request.url)) return false
|
||||||
|
// پرداخت با درگاه داخلِ خودِ اپ میماند: زرینپال بیRefererِ دامنهی ما صفحه را باز نمیکند و مرورگرِ
|
||||||
|
// بیرونی Referer نمیفرستد. بعد از پرداخت، زرینپال به سایتِ ما برمیگرداند و نتیجه همینجا دیده میشود.
|
||||||
|
if (isPaymentPage(request.url)) return false
|
||||||
// فقط http(s) به مرورگرِ گوشی میرود؛ intent:، file: و schemeهای سفارشی بیصدا بسته میشوند
|
// فقط http(s) به مرورگرِ گوشی میرود؛ intent:، file: و schemeهای سفارشی بیصدا بسته میشوند
|
||||||
// (پیشتر هر آدرسی بیرونِ دامنه به ACTION_VIEW میرفت و صفحه میتوانست اپِ دلخواه باز کند).
|
// (پیشتر هر آدرسی بیرونِ دامنه به ACTION_VIEW میرفت و صفحه میتوانست اپِ دلخواه باز کند).
|
||||||
if (request.url.scheme == "https" || request.url.scheme == "http") {
|
if (request.url.scheme == "https" || request.url.scheme == "http") {
|
||||||
@@ -193,6 +205,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1)
|
else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** فرانت با این میفهمد درگاه داخلِ اپ باز میشود (isPaymentPage)، نه در مرورگرِ بیرونی. */
|
||||||
|
@JavascriptInterface
|
||||||
|
fun paysInApp(): Boolean = true
|
||||||
|
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun bazaarConsume(purchaseToken: String) {
|
fun bazaarConsume(purchaseToken: String) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
|
|||||||
28
pay.html
Normal file
28
pay.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<!-- Referer باید از دامنهی خودمان برود؛ زرینپال بیآن صفحه را باز نمیکند (src/pay.ts). -->
|
||||||
|
<meta name="referrer" content="strict-origin-when-cross-origin" />
|
||||||
|
<title>رفتن به درگاه — تدریسینو</title>
|
||||||
|
<script src="/config.js"></script>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f6f5fb; color: #1a1a2e; font-family: Vazirmatn, system-ui, sans-serif; }
|
||||||
|
main { text-align: center; padding: 24px; max-width: 340px; }
|
||||||
|
.spin { width: 36px; height: 36px; margin: 0 auto 16px; border-radius: 50%; border: 4px solid #f3c9c2; border-top-color: #ED6956; animation: s 0.8s linear infinite; }
|
||||||
|
@keyframes s { to { transform: rotate(360deg); } }
|
||||||
|
a { display: none; margin-top: 16px; padding: 14px 24px; border-radius: 16px; background: #ED6956; color: #fff; font-weight: 800; text-decoration: none; }
|
||||||
|
p { line-height: 1.9; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="spin" id="spin"></div>
|
||||||
|
<p id="msg">داریم میریم به درگاهِ پرداخت…</p>
|
||||||
|
<a id="go" href="#">رفتن به درگاه</a>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/src/pay.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
src/pay.ts
Normal file
50
src/pay.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { resolveApiBaseUrl } from '@/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* صفحهی واسطهی پرداخت (pay.html)، برای اپِ اندرویدی که درگاه را در مرورگرِ گوشی باز میکند.
|
||||||
|
*
|
||||||
|
* زرینپال صفحهی StartPay را فقط وقتی باز میکند که Referer از دامنهی ثبتشده باشد؛ بیآن میگوید «دسترسی از این
|
||||||
|
* دامنه مجاز نمیباشد» (روی سرور با درخواستِ بیReferer دیده شد، ۱۴۰۵-۰۷). مرورگری که اپِ دیگری بازش کرده Referer
|
||||||
|
* نمیفرستد. پس اپ اول همین صفحه را در مرورگر باز میکند و این صفحه کاربر را به لینکِ پرداخت میفرستد؛ حالا Referer
|
||||||
|
* از math.tadrisino2.ir است، درست مثلِ پرداخت از سایت.
|
||||||
|
*
|
||||||
|
* فقط لینکِ پرداختِ API خودمان پذیرفته میشود (پیشوندِ آدرسِ API + /payment/orders/<id>/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);
|
||||||
|
}
|
||||||
@@ -333,9 +333,18 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
|||||||
if (!order.paymentUrl) throw new Error('order without paymentUrl');
|
if (!order.paymentUrl) throw new Error('order without paymentUrl');
|
||||||
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
|
// فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود.
|
||||||
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
|
if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)');
|
||||||
window.location.assign(order.paymentUrl);
|
const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid;
|
||||||
// داخلِ اپ آدرسِ درگاه در مرورگرِ گوشی باز میشود و این صفحه همینجا میماند؛ دکمه نباید تا ابد بچرخد.
|
if (bazaar && !shell?.paysInApp?.()) {
|
||||||
if (bazaar) setTimeout(() => { setBusy(false); setDone('gateway'); }, 1500);
|
// اپِ اندرویدِ فعلی هر آدرسِ بیرونی را در مرورگرِ گوشی باز میکند و آن مرورگر 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) {
|
} catch (err) {
|
||||||
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
setError(friendlyError(err, 'اتصال به درگاه انجام نشد، دوباره امتحان کن'));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -397,7 +406,7 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b
|
|||||||
<PrimaryButton loading={busy} onClick={() => void pay()}>پرداخت</PrimaryButton>
|
<PrimaryButton loading={busy} onClick={() => void pay()}>پرداخت</PrimaryButton>
|
||||||
{method === 'gateway' && (
|
{method === 'gateway' && (
|
||||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
|
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-muted">
|
||||||
<ShieldCheck size={14} /> {bazaar ? 'صفحهی پرداخت در مرورگرِ گوشی باز میشه؛ بعدش برگرد به اپ.' : 'پرداخت در درگاهِ امنِ زرینپال انجام میشه.'}
|
<ShieldCheck size={14} /> پرداخت در درگاهِ امنِ زرینپال انجام میشه.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
// pay.html: صفحهی واسطهی درگاه برای اپِ اندروید (src/pay.ts).
|
||||||
|
input: { main: path.resolve(__dirname, 'index.html'), pay: path.resolve(__dirname, 'pay.html') },
|
||||||
output: {
|
output: {
|
||||||
// کتابخانهها با هر انتشار عوض نمیشوند؛ فایلِ جدا یعنی بعد از هر دیپلوی
|
// کتابخانهها با هر انتشار عوض نمیشوند؛ فایلِ جدا یعنی بعد از هر دیپلوی
|
||||||
// از کشِ مرورگر میآیند و فقط کدِ خودِ اپ دوباره دانلود میشود.
|
// از کشِ مرورگر میآیند و فقط کدِ خودِ اپ دوباره دانلود میشود.
|
||||||
|
|||||||
Reference in New Issue
Block a user