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:
2026-10-10 18:40:10 +03:30
parent 468f5b2d29
commit 737414f246
5 changed files with 109 additions and 4 deletions

50
src/pay.ts Normal file
View 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);
}