make the domain configuration, not code: two env vars move everything
A domain change is coming (the Zarinpal terminal rejects the current callback domain). ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, CORS defaults, the Bazaar redirect URI and deploy.sh's public health URL each spelled the domain out. They now derive from PUBLIC_BASE_URL and FRONTEND_URL, which keep today's domain as their default; env values for the three lists are still added on top, so the old domain can stay allowed while DNS moves. ZARINPAL_CALLBACK_URL used os.environ.get(key, default), so an empty key in .env produced an empty callback and every payment would fail closed. It now uses the same `.strip() or` fallback as its neighbours; the new test caught it. config/tests_domains.py loads settings in a subprocess with a new domain and fails if any derived value still mentions the old one. docs/operations.md gains the step-by-step domain move (DNS, .env, Zarinpal/Bazaar panels, frontend config.js, verification). CLAUDE.md records that the frontend is now ours and where the rebuild stands. 624 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
26
.env.example
26
.env.example
@@ -38,20 +38,22 @@ CACHE_URL=
|
|||||||
# TADRISINO_SERVICE=
|
# TADRISINO_SERVICE=
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# آدرسِ سرویس — این چهار تا پیشفرضِ درست دارند
|
# آدرسِ سرویس — دو آدرس، بقیه از آنها ساخته میشوند
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# تا پیش از این هر چهارتا باید دستی روی سرور اضافه میشدند، و چون در همین
|
# ALLOWED_HOSTS، CSRF_TRUSTED_ORIGINS، CORS_ALLOWED_ORIGINS، callbackِ زرینپال،
|
||||||
# فایل نبودند هر بار از نو کشف میشدند: نبودِ اولی یعنی «Disallowed Host»،
|
# برگشت از پرداخت و redirectِ بازار همه از این دو ساخته میشوند. پیشفرضشان
|
||||||
# دومی یعنی ردِ ورود به پنلِ ادمین، سومی یعنی بلاک شدنِ فرانت در مرورگر، و
|
# دامنهی فعلی است و لازم نیست ست شوند. برای جابهجایی دامنه فقط همین دو را
|
||||||
# چهارمی یعنی شکستنِ بیسروصدای مسیرِ پرداخت.
|
# باز کنید (ترتیبِ کار در docs/operations.md، «عوض کردنِ دامنه»):
|
||||||
#
|
#
|
||||||
# حالا هر چهارتا مقدارِ پیشفرضِ دامنهی خودمان را دارند و لازم نیست ست شوند.
|
|
||||||
# فقط اگر دامنه عوض شد، اینها را باز کنید:
|
|
||||||
#
|
|
||||||
# DJANGO_ALLOWED_HOSTS=api.math.tadrisino2.ir,math.tadrisino2.ir,localhost,127.0.0.1
|
|
||||||
# CSRF_TRUSTED_ORIGINS=https://api.math.tadrisino2.ir
|
|
||||||
# CORS_ALLOWED_ORIGINS=https://math.tadrisino2.ir
|
|
||||||
# PUBLIC_BASE_URL=https://api.math.tadrisino2.ir
|
# PUBLIC_BASE_URL=https://api.math.tadrisino2.ir
|
||||||
|
# FRONTEND_URL=https://math.tadrisino2.ir
|
||||||
|
#
|
||||||
|
# این سه فقط برای دامنهی *اضافه* لازماند (مثلاً دامنهی قبلی در دورانِ جابهجایی)؛
|
||||||
|
# مقدارشان به پیشفرضها اضافه میشود، جایگزینشان نمیشود:
|
||||||
|
#
|
||||||
|
# DJANGO_ALLOWED_HOSTS=old.example.ir
|
||||||
|
# CSRF_TRUSTED_ORIGINS=https://old.example.ir
|
||||||
|
# CORS_ALLOWED_ORIGINS=https://old-app.example.ir
|
||||||
|
|
||||||
# اختیاری — بدونش OTP واقعی ارسال نمیشه (فقط خطای صریح ۵۰۳ برمیگرده،
|
# اختیاری — بدونش OTP واقعی ارسال نمیشه (فقط خطای صریح ۵۰۳ برمیگرده،
|
||||||
# طبق رفتارِ موجودِ accounts/services.py)
|
# طبق رفتارِ موجودِ accounts/services.py)
|
||||||
@@ -115,7 +117,7 @@ RAG_FORCE_MOCK=
|
|||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# بدونِ ZARINPAL_MERCHANT_ID، مسیرِ پرداخت fail-closed میمونه (خطای صریحِ
|
# بدونِ ZARINPAL_MERCHANT_ID، مسیرِ پرداخت fail-closed میمونه (خطای صریحِ
|
||||||
# ۵۰۳ بهجایِ پرداختِ ساختگیِ موفق).
|
# ۵۰۳ بهجایِ پرداختِ ساختگیِ موفق).
|
||||||
# PUBLIC_BASE_URL بالاتر توضیح داده شده؛ پیشفرض دارد.
|
# callbackِ زرینپال از PUBLIC_BASE_URL (بالاتر) ساخته میشود.
|
||||||
ZARINPAL_MERCHANT_ID=
|
ZARINPAL_MERCHANT_ID=
|
||||||
# 1 = محیطِ sandbox زرینپال (برای تست بدونِ پرداختِ واقعی)
|
# 1 = محیطِ sandbox زرینپال (برای تست بدونِ پرداختِ واقعی)
|
||||||
ZARINPAL_SANDBOX=1
|
ZARINPAL_SANDBOX=1
|
||||||
|
|||||||
61
CLAUDE.md
61
CLAUDE.md
@@ -119,48 +119,37 @@ than a coding one, and never assume a step was done just because it was describe
|
|||||||
|
|
||||||
## Frontend ↔ backend (read before wiring the two together)
|
## Frontend ↔ backend (read before wiring the two together)
|
||||||
|
|
||||||
The app is two repos and two domains: the React frontend lives in
|
The app is two repos and two domains: the React frontend `Tadrisino/MathApp_frontend`
|
||||||
`Tadrisino/MathApp_frontend` (deployed at `math.tadrisino2.ir`, owned by another
|
(deployed at `math.tadrisino2.ir`) and this backend (`api.math.tadrisino2.ir`).
|
||||||
developer) and this backend serves `api.math.tadrisino2.ir`. Our typed client for them
|
**Since 2026-09-26 the owner maintains both:** the frontend developer is on leave and
|
||||||
is `src/api/` inside their repo, delivered by pull request — we have read-only Gitea
|
`AliReza_Zareian` has `write` on the frontend repo (branch protection on its `main`
|
||||||
access there, so changes go via a fork + PR, never a direct push.
|
unknown — not visible without admin). Local clone: `~/Desktop/MathApp_frontend`.
|
||||||
|
|
||||||
**The one trap that keeps biting.** Their `App.tsx` calls relative paths
|
**State on 2026-09-26:** the frontend was rebuilt from the single 3,071-line `App.tsx`
|
||||||
(`fetch('/api/auth/login')`). Those resolve against the *frontend* origin, where nginx
|
into a typed, modular app (branch `rebuild/v2`; its README is the map). Every item of
|
||||||
answers any unknown path with `index.html` — so the response is `200` with an HTML body
|
the old handoff list (`~/Desktop/کارهای-فرانت-تدریسینو.html`) is implemented, plus
|
||||||
and the code dies on `res.json()` with `Unexpected token '<'`. Verify with
|
screens for profile, streak, support, parents and invite. Verified by a headless-Chrome
|
||||||
`curl https://math.tadrisino2.ir/api/grades`: HTTP 200, HTML. Two fixes exist, both
|
walkthrough against a local backend; real SMS login and a real Zarinpal payment were not
|
||||||
shipped in PR #3: a `vite` dev proxy (dev works unchanged) and an exported `apiUrl()`
|
exercised. **The live site still serves the 2026-09-21 build** that calls a relative
|
||||||
for builds. **The better long-term fix is infrastructure, not code** — proxy `/api` on
|
`/api` and gets HTML back — nothing reaches users until someone builds and deploys the
|
||||||
the frontend domain to this backend, which makes everything same-origin and removes CORS
|
frontend, and who deploys `math.tadrisino2.ir` is not known to us yet.
|
||||||
entirely. That needs the server admin, not us.
|
|
||||||
|
|
||||||
**State on 2026-09-23 (checked against upstream `main`, last commit 2026-09-21):** the
|
**Domains are configuration, not code.** Backend: only `PUBLIC_BASE_URL` and
|
||||||
screens are wired to the API (PR #4/#5 upstream), but `App.tsx` builds the client with
|
`FRONTEND_URL`; hosts, CSRF, CORS, Zarinpal callback/return, Bazaar redirect and the
|
||||||
`env.VITE_API_URL ?? "/api"` — while their own `.env.example` and `client.ts` name the
|
`deploy.sh` health URL derive from them (`config/tests_domains.py` guards it). Frontend:
|
||||||
variable `VITE_API_BASE_URL`. The deployed bundle has neither, so it calls the relative
|
`dist/config.js` at runtime, else `VITE_API_BASE_URL` from `.env.production`. A domain
|
||||||
`/api` and gets HTML back: **math.tadrisino2.ir cannot reach the backend** until either
|
move is expected (Zarinpal `-14`); the procedure is in `docs/operations.md`
|
||||||
that line falls back to `https://api.math.tadrisino2.ir/api` or nginx proxies `/api`.
|
(«عوض کردنِ دامنه»).
|
||||||
Our PR #3 fixed the same trap and is still open, now likely conflicting with their
|
|
||||||
rewrite. Every path their client calls exists and was driven live against the server.
|
|
||||||
The app also generates a random `deviceId` per browser, so the seeded
|
|
||||||
`frontend-test-device` account only loads after
|
|
||||||
`localStorage.setItem("deviceId", "frontend-test-device")`.
|
|
||||||
|
|
||||||
**Order matters when bringing the stack up:** backend first, then frontend. If the
|
|
||||||
frontend is tested while `api.math.tadrisino2.ir` is down, every screen fails and it
|
|
||||||
looks like the client is broken.
|
|
||||||
|
|
||||||
**Never hand-write the client from memory.** Three of fifteen calls were silently wrong
|
**Never hand-write the client from memory.** Three of fifteen calls were silently wrong
|
||||||
the first time. Dump the real contract with
|
the first time, and the rebuilt client found two more the old one had (`POST` instead
|
||||||
`python manage.py spectacular --file schema.yaml --format openapi`, then drive the actual
|
of `PATCH` on notifications, a dropped guest refresh token). Read the contract from
|
||||||
client module against a running server (`runserver` on a spare port + `npx tsx`) before
|
`python manage.py spectacular --file schema.yaml --format openapi` and check shapes
|
||||||
sending anything. `content/tests_frontend_contract.py` is the backend-side guard.
|
against a running server. `content/tests_frontend_contract.py` is the backend-side guard.
|
||||||
|
|
||||||
**Content the frontend expects but nobody has filled in yet:** `Unit.blurb`,
|
**Content the frontend shows but nobody has filled in yet:** `Unit.blurb`,
|
||||||
`Plan.features`/`badge`, `Lesson.example`/`steps`. All optional, all editable from the
|
`Plan.features`/`badge`, `Lesson.example`/`steps`. All optional, all editable from the
|
||||||
admin panel. `Node.icon`/`color` fall back to a deterministic palette so cards are never
|
admin panel.
|
||||||
colourless.
|
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
@@ -89,20 +90,47 @@ def _with_defaults(name: str, defaults: list[str]) -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
# دامنههای شناختهشدهی خودمان همیشه مجازند تا یک .env قدیمی یا ناقص باعثِ
|
# --- دامنهها ----------------------------------------------------------------
|
||||||
# DisallowedHost نشود؛ هر چه در محیط باشد به اینها اضافه میشود.
|
# دو آدرسِ عمومی؛ تنها جای کد که دامنه در آن نوشته شده. ALLOWED_HOSTS، CSRF،
|
||||||
|
# CORS، callbackِ زرینپال، برگشت از پرداخت و redirectِ بازار همه از همین دو
|
||||||
|
# ساخته میشوند. برای جابهجایی دامنه فقط این دو را در .env عوض کنید؛ جزئیات
|
||||||
|
# و ترتیبِ کار در docs/operations.md («عوض کردنِ دامنه»).
|
||||||
|
#
|
||||||
|
# پیشفرض دارند چون خالی بودنشان سرویس را نمیخواباند، فقط مسیرِ پرداخت و
|
||||||
|
# فرانت را بیصدا میشکند. `.strip() or` لازم است: کلید ممکن است در .env
|
||||||
|
# باشد ولی خالی، و os.environ.get فقط وقتی کلید *نباشد* پیشفرض میدهد.
|
||||||
|
PUBLIC_BASE_URL = (
|
||||||
|
os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://api.math.tadrisino2.ir'
|
||||||
|
).rstrip('/')
|
||||||
|
# آدرسِ خودِ اپ (فرانت)؛ برگشت از درگاه و CORS از آن ساخته میشوند.
|
||||||
|
FRONTEND_URL = (
|
||||||
|
os.environ.get('FRONTEND_URL', '').strip() or 'https://math.tadrisino2.ir'
|
||||||
|
).rstrip('/')
|
||||||
|
|
||||||
|
|
||||||
|
def _origin(url):
|
||||||
|
parts = urlsplit(url)
|
||||||
|
return f'{parts.scheme}://{parts.netloc}' if parts.scheme and parts.netloc else ''
|
||||||
|
|
||||||
|
|
||||||
|
def _hostname(url):
|
||||||
|
return urlsplit(url).hostname or ''
|
||||||
|
|
||||||
|
|
||||||
|
# دامنههای خودمان (از دو آدرسِ بالا) همیشه مجازند تا یک .env قدیمی یا ناقص
|
||||||
|
# باعثِ DisallowedHost نشود؛ هر چه در محیط باشد به اینها اضافه میشود. در دورانِ
|
||||||
|
# جابهجاییِ دامنه، دامنهی قبلی را در DJANGO_ALLOWED_HOSTS نگه دارید تا تا
|
||||||
|
# عوض شدنِ DNS هر دو جواب بدهند.
|
||||||
DEFAULT_ALLOWED_HOSTS = [
|
DEFAULT_ALLOWED_HOSTS = [
|
||||||
'api.math.tadrisino2.ir',
|
h for h in (_hostname(PUBLIC_BASE_URL), _hostname(FRONTEND_URL)) if h
|
||||||
'math.tadrisino2.ir',
|
] + ['localhost', '127.0.0.1']
|
||||||
'localhost', '127.0.0.1',
|
|
||||||
]
|
|
||||||
ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS)
|
ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS)
|
||||||
|
|
||||||
# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود
|
# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود
|
||||||
# «CSRF verification failed» میدهد و کسی نمیفهمد چرا.
|
# «CSRF verification failed» میدهد و کسی نمیفهمد چرا.
|
||||||
CSRF_TRUSTED_ORIGINS = _with_defaults(
|
CSRF_TRUSTED_ORIGINS = _with_defaults(
|
||||||
'CSRF_TRUSTED_ORIGINS',
|
'CSRF_TRUSTED_ORIGINS',
|
||||||
['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir'],
|
[o for o in (_origin(PUBLIC_BASE_URL), _origin(FRONTEND_URL)) if o],
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- امنیتِ پشتِ پراکسی ---------------------------------------------------
|
# --- امنیتِ پشتِ پراکسی ---------------------------------------------------
|
||||||
@@ -133,9 +161,7 @@ SESSION_COOKIE_HTTPONLY = True
|
|||||||
# تنها راهش env بود، یک دیپلویِ فراموششده یعنی بکاندِ سالم که مرورگر همهی
|
# تنها راهش env بود، یک دیپلویِ فراموششده یعنی بکاندِ سالم که مرورگر همهی
|
||||||
# درخواستهایش را بلاک میکند — خطایی که در سمتِ سرور هیچ ردی نمیگذارد و فقط
|
# درخواستهایش را بلاک میکند — خطایی که در سمتِ سرور هیچ ردی نمیگذارد و فقط
|
||||||
# در کنسولِ مرورگر دیده میشود.
|
# در کنسولِ مرورگر دیده میشود.
|
||||||
CORS_DEFAULT_ORIGINS = [
|
CORS_DEFAULT_ORIGINS = [o for o in (_origin(FRONTEND_URL),) if o] # فرانتِ مستقر
|
||||||
'https://math.tadrisino2.ir', # فرانتِ مستقر
|
|
||||||
]
|
|
||||||
CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS)
|
CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS)
|
||||||
|
|
||||||
# ⚠️ پورتِ سرورِ توسعه را نباید فهرست کرد. فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی
|
# ⚠️ پورتِ سرورِ توسعه را نباید فهرست کرد. فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی
|
||||||
@@ -733,11 +759,11 @@ BAZAAR_PISHKHAN_API_SECRET = os.environ.get('BAZAAR_PISHKHAN_API_SECRET', '').st
|
|||||||
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2')
|
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2')
|
||||||
# Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به
|
# Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به
|
||||||
# خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ
|
# خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ
|
||||||
# بازار را میگیرد و POSTِ آن خریدِ اپ را. PUBLIC_BASE_URL پایینتر تعریف
|
# بازار را میگیرد و POSTِ آن خریدِ اپ را. با عوض شدنِ PUBLIC_BASE_URL این هم
|
||||||
# شده، پس آدرس اینجا کامل نوشته میشود.
|
# عوض میشود، و آنوقت در پنلِ بازار هم باید عوض شود.
|
||||||
BAZAAR_REDIRECT_URI = (
|
BAZAAR_REDIRECT_URI = (
|
||||||
os.environ.get('BAZAAR_REDIRECT_URI', '').strip()
|
os.environ.get('BAZAAR_REDIRECT_URI', '').strip()
|
||||||
or 'https://api.math.tadrisino2.ir/api/payment/bazaar/verify'
|
or f'{PUBLIC_BASE_URL}/api/payment/bazaar/verify'
|
||||||
)
|
)
|
||||||
# چند ساعت لینکِ «اجازه»ی بازار باز میماند؛ بعد از اتصالِ موفق خودش بسته میشود.
|
# چند ساعت لینکِ «اجازه»ی بازار باز میماند؛ بعد از اتصالِ موفق خودش بسته میشود.
|
||||||
BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOURS', '72'))
|
BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOURS', '72'))
|
||||||
@@ -745,16 +771,8 @@ BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOUR
|
|||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4)
|
# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4)
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# آدرسِ عمومیِ بکاند: هم paymentUrlی که به کلاینت برمیگردد از آن ساخته
|
# paymentUrlی که به کلاینت برمیگردد و callback_urlی که به زرینپال میرود
|
||||||
# میشود، هم callback_urlی که به زرینپال میرود.
|
# هر دو از PUBLIC_BASE_URL (بخشِ «دامنهها» در بالای همین فایل) ساخته میشوند.
|
||||||
#
|
|
||||||
# پیشفرض دارد چون خالی بودنش سرویس را نمیخواباند، فقط مسیرِ پرداخت را
|
|
||||||
# بیصدا میشکند و تا اولین پرداختِ واقعی معلوم نمیشود. `.strip() or` هم
|
|
||||||
# لازم است: کلید در .env سرور هست ولی خالی، و os.environ.get فقط وقتی کلید
|
|
||||||
# *نباشد* پیشفرض میدهد.
|
|
||||||
PUBLIC_BASE_URL = (
|
|
||||||
os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://api.math.tadrisino2.ir'
|
|
||||||
).rstrip('/')
|
|
||||||
|
|
||||||
ZARINPAL_MERCHANT_ID = os.environ.get('ZARINPAL_MERCHANT_ID', '')
|
ZARINPAL_MERCHANT_ID = os.environ.get('ZARINPAL_MERCHANT_ID', '')
|
||||||
ZARINPAL_SANDBOX = os.environ.get('ZARINPAL_SANDBOX') == '1'
|
ZARINPAL_SANDBOX = os.environ.get('ZARINPAL_SANDBOX') == '1'
|
||||||
@@ -768,14 +786,12 @@ ZARINPAL_STARTPAY_BASE = os.environ.get(
|
|||||||
'https://sandbox.zarinpal.com/pg/StartPay' if ZARINPAL_SANDBOX
|
'https://sandbox.zarinpal.com/pg/StartPay' if ZARINPAL_SANDBOX
|
||||||
else 'https://www.zarinpal.com/pg/StartPay',
|
else 'https://www.zarinpal.com/pg/StartPay',
|
||||||
)
|
)
|
||||||
ZARINPAL_CALLBACK_URL = os.environ.get(
|
# `.strip() or` مثلِ بقیه: کلیدِ خالی در .env نباید callback را خالی کند (آنوقت
|
||||||
'ZARINPAL_CALLBACK_URL',
|
# هر پرداختی بیصدا ۵۰۳ میگرفت).
|
||||||
f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback' if PUBLIC_BASE_URL else '',
|
ZARINPAL_CALLBACK_URL = (
|
||||||
|
os.environ.get('ZARINPAL_CALLBACK_URL', '').strip()
|
||||||
|
or f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback'
|
||||||
)
|
)
|
||||||
# آدرسِ خودِ اپ (فرانت) برای برگرداندنِ کاربر بعد از پرداخت.
|
|
||||||
FRONTEND_URL = (
|
|
||||||
os.environ.get('FRONTEND_URL', '').strip() or 'https://math.tadrisino2.ir'
|
|
||||||
).rstrip('/')
|
|
||||||
# بعدِ verify، مرورگر را به این URL با ?orderId=...&status=... برمیگردانیم
|
# بعدِ verify، مرورگر را به این URL با ?orderId=...&status=... برمیگردانیم
|
||||||
# (برای اپِ موبایل میتواند یک deep link باشد). پیشفرض خودِ اپِ وب است؛ قبلاً
|
# (برای اپِ موبایل میتواند یک deep link باشد). پیشفرض خودِ اپِ وب است؛ قبلاً
|
||||||
# خالی بود و دانشآموز بعد از پرداختِ واقعی روی یک صفحهی JSONِ خام روی دامنهی
|
# خالی بود و دانشآموز بعد از پرداختِ واقعی روی یک صفحهی JSONِ خام روی دامنهی
|
||||||
|
|||||||
62
config/tests_domains.py
Normal file
62
config/tests_domains.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
دامنه فقط از PUBLIC_BASE_URL و FRONTEND_URL میآید.
|
||||||
|
|
||||||
|
تنظیمات در زمانِ import ساخته میشوند، پس هر حالت در یک پروسهی جدا با env
|
||||||
|
خودش بار میشود. اگر کسی دوباره دامنهای را مستقیم در settings بنویسد، حالتِ
|
||||||
|
«دامنهی تازه» اینجا قرمز میشود.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
PROBE = """
|
||||||
|
import json, os
|
||||||
|
os.environ['DJANGO_SETTINGS_MODULE'] = 'config.settings'
|
||||||
|
import django; django.setup()
|
||||||
|
from django.conf import settings as s
|
||||||
|
print(json.dumps({
|
||||||
|
'hosts': s.ALLOWED_HOSTS, 'csrf': s.CSRF_TRUSTED_ORIGINS, 'cors': s.CORS_ALLOWED_ORIGINS,
|
||||||
|
'callback': s.ZARINPAL_CALLBACK_URL, 'redirect': s.ZARINPAL_REDIRECT_URL, 'bazaar': s.BAZAAR_REDIRECT_URI,
|
||||||
|
}))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings(**env):
|
||||||
|
clean = {k: v for k, v in os.environ.items() if not k.startswith(('DJANGO_ALLOWED', 'CSRF_', 'CORS_', 'ZARINPAL_', 'BAZAAR_REDIRECT', 'PUBLIC_BASE', 'FRONTEND_URL'))}
|
||||||
|
clean.update(env)
|
||||||
|
# .env محلی نباید مقدارهای آزمون را بپوشاند؛ python-dotenv متغیرِ موجود را عوض نمیکند،
|
||||||
|
# پس کلیدهای خالی را هم صریح میگذاریم.
|
||||||
|
for key in ('DJANGO_ALLOWED_HOSTS', 'CSRF_TRUSTED_ORIGINS', 'CORS_ALLOWED_ORIGINS', 'ZARINPAL_CALLBACK_URL',
|
||||||
|
'ZARINPAL_REDIRECT_URL', 'BAZAAR_REDIRECT_URI'):
|
||||||
|
clean.setdefault(key, '')
|
||||||
|
out = subprocess.run([sys.executable, '-c', PROBE], cwd=BASE_DIR, env=clean, capture_output=True, text=True, check=True)
|
||||||
|
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||||
|
|
||||||
|
|
||||||
|
class DomainSettingsTests(SimpleTestCase):
|
||||||
|
def test_new_domain_moves_everything(self):
|
||||||
|
s = load_settings(PUBLIC_BASE_URL='https://api.new-domain.ir', FRONTEND_URL='https://app.new-domain.ir/')
|
||||||
|
self.assertIn('api.new-domain.ir', s['hosts'])
|
||||||
|
self.assertIn('app.new-domain.ir', s['hosts'])
|
||||||
|
self.assertIn('https://app.new-domain.ir', s['cors'])
|
||||||
|
self.assertIn('https://api.new-domain.ir', s['csrf'])
|
||||||
|
self.assertEqual(s['callback'], 'https://api.new-domain.ir/api/payment/zarinpal/callback')
|
||||||
|
self.assertEqual(s['redirect'], 'https://app.new-domain.ir/')
|
||||||
|
self.assertEqual(s['bazaar'], 'https://api.new-domain.ir/api/payment/bazaar/verify')
|
||||||
|
self.assertNotIn('tadrisino2', json.dumps(s))
|
||||||
|
|
||||||
|
def test_extra_domain_is_added_not_replacing(self):
|
||||||
|
s = load_settings(
|
||||||
|
PUBLIC_BASE_URL='https://api.new-domain.ir', FRONTEND_URL='https://app.new-domain.ir',
|
||||||
|
DJANGO_ALLOWED_HOSTS='old.example.ir', CORS_ALLOWED_ORIGINS='https://old.example.ir',
|
||||||
|
)
|
||||||
|
self.assertIn('old.example.ir', s['hosts'])
|
||||||
|
self.assertIn('api.new-domain.ir', s['hosts'])
|
||||||
|
self.assertIn('https://old.example.ir', s['cors'])
|
||||||
|
self.assertIn('https://app.new-domain.ir', s['cors'])
|
||||||
12
deploy.sh
12
deploy.sh
@@ -27,15 +27,15 @@ fail() { printf '%s✖ %s%s\n' "$C_ERR" "$1" "$C_OFF" >&2; exit 1; }
|
|||||||
|
|
||||||
# اگر TADRISINO_HEALTH_URL ست نباشد چند آدرس امتحان میشود: پورتِ محلی ممکن
|
# اگر TADRISINO_HEALTH_URL ست نباشد چند آدرس امتحان میشود: پورتِ محلی ممکن
|
||||||
# است فرق کند (سوکتِ یونیکس، پورتِ دیگر)، و در آن حالت دیپلویِ موفق هم پیامِ
|
# است فرق کند (سوکتِ یونیکس، پورتِ دیگر)، و در آن حالت دیپلویِ موفق هم پیامِ
|
||||||
# خطا میگرفت. آدرسِ عمومی نشان میدهد سرویس واقعاً از بیرون جواب میدهد.
|
# خطا میگرفت. آدرسِ عمومی نشان میدهد سرویس واقعاً از بیرون جواب میدهد؛ آن
|
||||||
|
# را پایینتر، بعد از فعال شدنِ venv، از خودِ تنظیماتِ جنگو (PUBLIC_BASE_URL)
|
||||||
|
# میخوانیم تا با عوض شدنِ دامنه این فایل دست نخورد.
|
||||||
if [ -n "${TADRISINO_HEALTH_URL:-}" ]; then
|
if [ -n "${TADRISINO_HEALTH_URL:-}" ]; then
|
||||||
HEALTH_URLS=("$TADRISINO_HEALTH_URL")
|
HEALTH_URLS=("$TADRISINO_HEALTH_URL")
|
||||||
else
|
else
|
||||||
HEALTH_URLS=(
|
HEALTH_URLS=(
|
||||||
"http://127.0.0.1:8000/api/health"
|
"http://127.0.0.1:8000/api/health"
|
||||||
"http://127.0.0.1:8000/api/health/"
|
"http://127.0.0.1:8000/api/health/"
|
||||||
"https://api.math.tadrisino2.ir/api/health"
|
|
||||||
"https://api.math.tadrisino2.ir/api/health/"
|
|
||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -262,6 +262,12 @@ fi
|
|||||||
|
|
||||||
# ─── ۸. آیا واقعاً بالا آمد؟ ───────────────────────────────────────────────
|
# ─── ۸. آیا واقعاً بالا آمد؟ ───────────────────────────────────────────────
|
||||||
step "بررسیِ سلامت"
|
step "بررسیِ سلامت"
|
||||||
|
if [ -z "${TADRISINO_HEALTH_URL:-}" ]; then
|
||||||
|
PUBLIC_URL="$(python -c 'import django, os; os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings"); django.setup(); from django.conf import settings; print(settings.PUBLIC_BASE_URL)' 2>/dev/null | tail -n 1)"
|
||||||
|
if [ -n "$PUBLIC_URL" ]; then
|
||||||
|
HEALTH_URLS+=("$PUBLIC_URL/api/health" "$PUBLIC_URL/api/health/")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
CODE=000; BODY=""
|
CODE=000; BODY=""
|
||||||
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
for url in "${HEALTH_URLS[@]}"; do
|
for url in "${HEALTH_URLS[@]}"; do
|
||||||
|
|||||||
@@ -101,13 +101,13 @@ curl https://api.math.tadrisino2.ir/api/health
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `DJANGO_DEBUG` | خاموش (`0`) — که حالتِ درستِ پروداکشن است |
|
| `DJANGO_DEBUG` | خاموش (`0`) — که حالتِ درستِ پروداکشن است |
|
||||||
| `DJANGO_SECRET_KEY` | یک کلیدِ تصادفیِ قوی ساخته و در `.django_secret_key` (مجوز `0600`) ذخیره میشود |
|
| `DJANGO_SECRET_KEY` | یک کلیدِ تصادفیِ قوی ساخته و در `.django_secret_key` (مجوز `0600`) ذخیره میشود |
|
||||||
| `DJANGO_ALLOWED_HOSTS` | دامنههای خودمان پیشفرضاند |
|
| `PUBLIC_BASE_URL` · `FRONTEND_URL` | دامنهی فعلی (`api.math.tadrisino2.ir` / `math.tadrisino2.ir`). بقیهی تنظیمهای دامنه از همین دو ساخته میشوند؛ بخشِ «عوض کردنِ دامنه» |
|
||||||
| `CSRF_TRUSTED_ORIGINS` · `CORS_ALLOWED_ORIGINS` · `PUBLIC_BASE_URL` | پیشفرضِ درست دارند |
|
| `DJANGO_ALLOWED_HOSTS` · `CSRF_TRUSTED_ORIGINS` · `CORS_ALLOWED_ORIGINS` | از دو آدرسِ بالا ساخته میشوند؛ مقدارِ env فقط دامنهی *اضافه* است |
|
||||||
| `CACHE_URL` | سرویس بالا میآید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار میدهد |
|
| `CACHE_URL` | سرویس بالا میآید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار میدهد |
|
||||||
| `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` میدهد، نه موفقیتِ ساختگی |
|
| `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` میدهد، نه موفقیتِ ساختگی |
|
||||||
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed میماند (`503`) |
|
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed میماند (`503`) |
|
||||||
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشهی کاری میخواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ همزمانِ دستیار کلِ API را قفل میکرد |
|
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشهی کاری میخواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ همزمانِ دستیار کلِ API را قفل میکرد |
|
||||||
| `FRONTEND_URL` · `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `https://math.tadrisino2.ir/?orderId=…&status=paid\|failed` برمیگردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی |
|
| `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `FRONTEND_URL/?orderId=…&status=paid\|failed` برمیگردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی |
|
||||||
| `BAZAAR_PISHKHAN_API_SECRET` (روشِ جدید) یا `BAZAAR_CLIENT_ID` · `BAZAAR_CLIENT_SECRET` (روشِ قدیمی)، بهعلاوهی `BAZAAR_PACKAGE_NAME` | `POST /api/payment/bazaar/verify` هر خرید را نامعتبر میداند (fail-closed)؛ `/api/health` → `bazaar: false` |
|
| `BAZAAR_PISHKHAN_API_SECRET` (روشِ جدید) یا `BAZAAR_CLIENT_ID` · `BAZAAR_CLIENT_SECRET` (روشِ قدیمی)، بهعلاوهی `BAZAAR_PACKAGE_NAME` | `POST /api/payment/bazaar/verify` هر خرید را نامعتبر میداند (fail-closed)؛ `/api/health` → `bazaar: false` |
|
||||||
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را میدهد |
|
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را میدهد |
|
||||||
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
|
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
|
||||||
@@ -145,6 +145,35 @@ pendingِ همان کاربر را دوباره با زرینپال میس
|
|||||||
> روشن میکند که هر شمارهای را بهعنوانِ ورودِ معتبر میپذیرد، و صفحههای خطا
|
> روشن میکند که هر شمارهای را بهعنوانِ ورودِ معتبر میپذیرد، و صفحههای خطا
|
||||||
> کلِ تنظیمات را نشان میدهند. جایش فقط `.env` است، نه یونیتِ systemd.
|
> کلِ تنظیمات را نشان میدهند. جایش فقط `.env` است، نه یونیتِ systemd.
|
||||||
|
|
||||||
|
## عوض کردنِ دامنه
|
||||||
|
|
||||||
|
دامنه در کد فقط یکجا آمده: پیشفرضِ `PUBLIC_BASE_URL` و `FRONTEND_URL` در
|
||||||
|
`config/settings.py`. `ALLOWED_HOSTS`، `CSRF_TRUSTED_ORIGINS`، `CORS_ALLOWED_ORIGINS`،
|
||||||
|
callbackِ زرینپال، برگشت از پرداخت، `BAZAAR_REDIRECT_URI` و آدرسِ سلامت در
|
||||||
|
`deploy.sh` همه از همین دو ساخته میشوند (۲۰۲۶-۰۹-۲۶). فرانت هم دامنه را در کد
|
||||||
|
ندارد (README همان ریپو).
|
||||||
|
|
||||||
|
ترتیبِ کار، از بیرون به داخل:
|
||||||
|
|
||||||
|
1. **DNS، آروان و گواهی** برای دامنههای تازه، رو به همین سرور، و `server_name`ِ
|
||||||
|
nginx — کارِ ادمینِ سرور، نه ما.
|
||||||
|
2. **بکاند:** در `.env` سرور `PUBLIC_BASE_URL` و `FRONTEND_URL` را عوض کنید. تا DNS
|
||||||
|
کامل جابهجا شود، دامنههای قبلی را هم نگه دارید:
|
||||||
|
`DJANGO_ALLOWED_HOSTS=api.math.tadrisino2.ir,math.tadrisino2.ir` و
|
||||||
|
`CORS_ALLOWED_ORIGINS=https://math.tadrisino2.ir`. بعد ریاستارت.
|
||||||
|
3. **زرینپال:** دامنهی تازهی API را در پنلِ ترمینال ثبت کنید. خطای `-14` («دامنهی
|
||||||
|
callback با دامنهی ثبتشده نمیخواند») دقیقاً نبودنِ همین است.
|
||||||
|
**بازار** (اگر استفاده شود): Redirect URI در پنل باید عیناً
|
||||||
|
`PUBLIC_BASE_URL/api/payment/bazaar/verify` شود.
|
||||||
|
4. **فرانت:** روی سرورِ فرانت در `dist/config.js` بنویسید
|
||||||
|
`apiBaseUrl: "https://<دامنهی تازهی API>/api"` (یا `.env.production` را عوض و
|
||||||
|
build کنید)، و کشِ آروان را برای `config.js` و `index.html` پاک کنید.
|
||||||
|
5. **وارسی:** `curl https://<دامنهی تازهی API>/api/health` (در `clientIp` باید IPِ
|
||||||
|
خودتان باشد) و یک بار ورود و باز کردنِ یک درس در اپ.
|
||||||
|
|
||||||
|
مرحلهی ۳ را فقط یک پرداختِ واقعی ثابت میکند؛ تا آن انجام نشده، پرداخت را
|
||||||
|
«کار میکند» ننویسید.
|
||||||
|
|
||||||
## ورودِ توسعهدهندهها (بدونِ سرویسِ پیامک)
|
## ورودِ توسعهدهندهها (بدونِ سرویسِ پیامک)
|
||||||
|
|
||||||
تا وقتی `KAVENEGAR_API_KEY` ست نشده، با `DEBUG=0` هیچکس نمیتواند با شماره
|
تا وقتی `KAVENEGAR_API_KEY` ست نشده، با `DEBUG=0` هیچکس نمیتواند با شماره
|
||||||
|
|||||||
Reference in New Issue
Block a user