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:
2026-09-26 23:46:25 +03:30
parent 2f8ed50b86
commit 4b246e0c70
6 changed files with 188 additions and 84 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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'])

View File

@@ -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

View File

@@ -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` هیچ‌کس نمی‌تواند با شماره