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:
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
@@ -89,20 +90,47 @@ def _with_defaults(name: str, defaults: list[str]) -> list[str]:
|
||||
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 = [
|
||||
'api.math.tadrisino2.ir',
|
||||
'math.tadrisino2.ir',
|
||||
'localhost', '127.0.0.1',
|
||||
]
|
||||
h for h in (_hostname(PUBLIC_BASE_URL), _hostname(FRONTEND_URL)) if h
|
||||
] + ['localhost', '127.0.0.1']
|
||||
ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS)
|
||||
|
||||
# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود
|
||||
# «CSRF verification failed» میدهد و کسی نمیفهمد چرا.
|
||||
CSRF_TRUSTED_ORIGINS = _with_defaults(
|
||||
'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 بود، یک دیپلویِ فراموششده یعنی بکاندِ سالم که مرورگر همهی
|
||||
# درخواستهایش را بلاک میکند — خطایی که در سمتِ سرور هیچ ردی نمیگذارد و فقط
|
||||
# در کنسولِ مرورگر دیده میشود.
|
||||
CORS_DEFAULT_ORIGINS = [
|
||||
'https://math.tadrisino2.ir', # فرانتِ مستقر
|
||||
]
|
||||
CORS_DEFAULT_ORIGINS = [o for o in (_origin(FRONTEND_URL),) if o] # فرانتِ مستقر
|
||||
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')
|
||||
# Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به
|
||||
# خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ
|
||||
# بازار را میگیرد و POSTِ آن خریدِ اپ را. PUBLIC_BASE_URL پایینتر تعریف
|
||||
# شده، پس آدرس اینجا کامل نوشته میشود.
|
||||
# بازار را میگیرد و POSTِ آن خریدِ اپ را. با عوض شدنِ PUBLIC_BASE_URL این هم
|
||||
# عوض میشود، و آنوقت در پنلِ بازار هم باید عوض شود.
|
||||
BAZAAR_REDIRECT_URI = (
|
||||
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'))
|
||||
@@ -745,16 +771,8 @@ BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOUR
|
||||
# -----------------------------------------------------------------------
|
||||
# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4)
|
||||
# -----------------------------------------------------------------------
|
||||
# آدرسِ عمومیِ بکاند: هم paymentUrlی که به کلاینت برمیگردد از آن ساخته
|
||||
# میشود، هم callback_urlی که به زرینپال میرود.
|
||||
#
|
||||
# پیشفرض دارد چون خالی بودنش سرویس را نمیخواباند، فقط مسیرِ پرداخت را
|
||||
# بیصدا میشکند و تا اولین پرداختِ واقعی معلوم نمیشود. `.strip() or` هم
|
||||
# لازم است: کلید در .env سرور هست ولی خالی، و os.environ.get فقط وقتی کلید
|
||||
# *نباشد* پیشفرض میدهد.
|
||||
PUBLIC_BASE_URL = (
|
||||
os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://api.math.tadrisino2.ir'
|
||||
).rstrip('/')
|
||||
# paymentUrlی که به کلاینت برمیگردد و callback_urlی که به زرینپال میرود
|
||||
# هر دو از PUBLIC_BASE_URL (بخشِ «دامنهها» در بالای همین فایل) ساخته میشوند.
|
||||
|
||||
ZARINPAL_MERCHANT_ID = os.environ.get('ZARINPAL_MERCHANT_ID', '')
|
||||
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
|
||||
else 'https://www.zarinpal.com/pg/StartPay',
|
||||
)
|
||||
ZARINPAL_CALLBACK_URL = os.environ.get(
|
||||
'ZARINPAL_CALLBACK_URL',
|
||||
f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback' if PUBLIC_BASE_URL else '',
|
||||
# `.strip() or` مثلِ بقیه: کلیدِ خالی در .env نباید callback را خالی کند (آنوقت
|
||||
# هر پرداختی بیصدا ۵۰۳ میگرفت).
|
||||
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=... برمیگردانیم
|
||||
# (برای اپِ موبایل میتواند یک deep link باشد). پیشفرض خودِ اپِ وب است؛ قبلاً
|
||||
# خالی بود و دانشآموز بعد از پرداختِ واقعی روی یک صفحهی 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'])
|
||||
Reference in New Issue
Block a user