stop an existing .env from cancelling the defaults meant to replace it

پیش‌فرض‌های دیروز روی یک سرورِ تازه کار می‌کردند ولی روی سرورِ واقعی نه، و
تستِ با .env واقعی نشانش داد: هر دو حالتِ شایع خنثی‌شان می‌کرد.

مقدارِ موجود ولی ناقص: .env سرور از قبل
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد. چون مقدار خالی نبود، کاملاً
جایگزینِ پیش‌فرض می‌شد و دامنه‌ی api.math.tadrisino2.ir باز هم غایب می‌ماند —
یعنی همان «Disallowed Host» قبلی، و باز هم ویرایشِ دستیِ فایل. حالا
دامنه‌های خودمان به مقدارِ محیطی اضافه می‌شوند نه جایگزینش؛ همین برای
CSRF_TRUSTED_ORIGINS و CORS_ALLOWED_ORIGINS هم اعمال شد.

مقدارِ موجود ولی خالی: PUBLIC_BASE_URL در .env هست و خالی است، و
os.environ.get فقط وقتی کلید *نباشد* پیش‌فرض می‌دهد. نتیجه‌اش paymentUrl
نسبی و callbackِ خالیِ زرین‌پال بود — خرابی‌ای که فقط موقعِ اولین پرداختِ
واقعی معلوم می‌شود. حالا مقدارِ خالی هم به پیش‌فرض می‌افتد.

این سه‌تا همان‌هایی‌اند که تا امروز مسئولِ سرور باید دستی به .env اضافه
می‌کرد و چون در .env.example هم نبودند، هر بار از نو کشف می‌شدند. حالا در
همان فایل مستند شده‌اند و تست نگهبانشان است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 00:00:35 +03:30
parent 3dacf3a50f
commit a3a47b459d
3 changed files with 109 additions and 21 deletions

View File

@@ -22,13 +22,7 @@
# تا وقتی کاربرِ واقعی ندارید اشکالی ندارد؛ قبل از اولین کاربرِ واقعی 0 کنید. # تا وقتی کاربرِ واقعی ندارید اشکالی ندارد؛ قبل از اولین کاربرِ واقعی 0 کنید.
DJANGO_DEBUG=1 DJANGO_DEBUG=1
# این سه پیش‌فرضِ درست دارند (api.math.tadrisino2.ir و math.tadrisino2.ir) # خالی بگذارید تا خودش ساخته و در .django_secret_key ذخیره شود.
# و فقط اگر دامنه عوض شد لازم می‌شوند.
# DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
# CSRF_TRUSTED_ORIGINS=
# CORS_ALLOWED_ORIGINS=
# خالی بگذارید تا خودش ساخته و ذخیره شود.
DJANGO_SECRET_KEY= DJANGO_SECRET_KEY=
# بدونِ Redis سرویس بالا می‌آید ولی محدودیتِ نرخ بینِ workerها مشترک نیست؛ # بدونِ Redis سرویس بالا می‌آید ولی محدودیتِ نرخ بینِ workerها مشترک نیست؛
@@ -38,6 +32,22 @@ CACHE_URL=
# نامِ سرویسِ systemd تا deploy.sh خودش ری‌استارت کند (فقط روی سرور). # نامِ سرویسِ systemd تا deploy.sh خودش ری‌استارت کند (فقط روی سرور).
# TADRISINO_SERVICE= # TADRISINO_SERVICE=
# -----------------------------------------------------------------------
# آدرسِ سرویس — این چهار تا پیش‌فرضِ درست دارند
# -----------------------------------------------------------------------
# تا پیش از این هر چهارتا باید دستی روی سرور اضافه می‌شدند، و چون در همین
# فایل نبودند هر بار از نو کشف می‌شدند: نبودِ اولی یعنی «Disallowed Host»،
# دومی یعنی ردِ ورود به پنلِ ادمین، سومی یعنی بلاک شدنِ فرانت در مرورگر، و
# چهارمی یعنی شکستنِ بی‌سروصدای مسیرِ پرداخت.
#
# حالا هر چهارتا مقدارِ پیش‌فرضِ دامنه‌ی خودمان را دارند و لازم نیست ست شوند.
# فقط اگر دامنه عوض شد، این‌ها را باز کنید:
#
# 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
# اختیاری — بدونش OTP واقعی ارسال نمی‌شه (فقط خطای صریح ۵۰۳ برمی‌گرده، # اختیاری — بدونش OTP واقعی ارسال نمی‌شه (فقط خطای صریح ۵۰۳ برمی‌گرده،
# طبق رفتارِ موجودِ accounts/services.py) # طبق رفتارِ موجودِ accounts/services.py)
KAVENEGAR_API_KEY= KAVENEGAR_API_KEY=
@@ -84,7 +94,7 @@ RAG_FORCE_MOCK=
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# بدونِ ZARINPAL_MERCHANT_ID، مسیرِ پرداخت fail-closed می‌مونه (خطای صریحِ # بدونِ ZARINPAL_MERCHANT_ID، مسیرِ پرداخت fail-closed می‌مونه (خطای صریحِ
# ۵۰۳ به‌جایِ پرداختِ ساختگیِ موفق). # ۵۰۳ به‌جایِ پرداختِ ساختگیِ موفق).
PUBLIC_BASE_URL= # PUBLIC_BASE_URL بالاتر توضیح داده شده؛ پیش‌فرض دارد.
ZARINPAL_MERCHANT_ID= ZARINPAL_MERCHANT_ID=
# 1 = محیطِ sandbox زرین‌پال (برای تست بدونِ پرداختِ واقعی) # 1 = محیطِ sandbox زرین‌پال (برای تست بدونِ پرداختِ واقعی)
ZARINPAL_SANDBOX=1 ZARINPAL_SANDBOX=1

View File

@@ -67,22 +67,42 @@ if not SECRET_KEY:
'یا متغیر را ست کنید یا اجازه‌ی نوشتن در پوشه‌ی پروژه بدهید.' 'یا متغیر را ست کنید یا اجازه‌ی نوشتن در پوشه‌ی پروژه بدهید.'
) from exc ) from exc
# دامنه‌های شناخته‌شده‌ی خودمان پیش‌فرض‌اند تا یک متغیرِ فراموش‌شده باعثِ def _env_list(name: str) -> list[str]:
# DisallowedHost نشود. متغیرِ محیطی، اگر ست باشد، کاملاً جایگزینشان می‌کند. return [v.strip() for v in os.environ.get(name, '').split(',') if v.strip()]
def _with_defaults(name: str, defaults: list[str]) -> list[str]:
"""
مقدارِ محیطی + پیش‌فرض‌های خودمان، بدونِ تکرار و با حفظِ ترتیب.
چرا اضافه و نه جایگزین: فایلِ .env روی سرور از قبل وجود دارد و مقدارهای
ناقص دارد (مثلاً DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1). اگر مقدارِ
موجود پیش‌فرض را کنار می‌زد، همان خطای قدیمی برمی‌گشت و باز کسی باید
دستی فایل را ویرایش می‌کرد. دامنه‌های خودمان همیشه باید مجاز باشند.
"""
seen, out = set(), []
for value in _env_list(name) + defaults:
if value not in seen:
seen.add(value)
out.append(value)
return out
# دامنه‌های شناخته‌شده‌ی خودمان همیشه مجازند تا یک .env قدیمی یا ناقص باعثِ
# DisallowedHost نشود؛ هر چه در محیط باشد به این‌ها اضافه می‌شود.
DEFAULT_ALLOWED_HOSTS = [ DEFAULT_ALLOWED_HOSTS = [
'api.math.tadrisino2.ir', 'api.math.tadrisino2.ir',
'math.tadrisino2.ir', 'math.tadrisino2.ir',
'localhost', '127.0.0.1', 'localhost', '127.0.0.1',
] ]
ALLOWED_HOSTS = [ ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS)
h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h
] or DEFAULT_ALLOWED_HOSTS
# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود # لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود
# «CSRF verification failed» می‌دهد و کسی نمی‌فهمد چرا. # «CSRF verification failed» می‌دهد و کسی نمی‌فهمد چرا.
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = _with_defaults(
o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o 'CSRF_TRUSTED_ORIGINS',
] or ['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir'] ['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir'],
)
# --- CORS ------------------------------------------------------------------- # --- CORS -------------------------------------------------------------------
# فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر # فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر
@@ -101,9 +121,7 @@ CORS_DEFAULT_ORIGINS = [
'http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:3000', 'http://127.0.0.1:3000',
'http://localhost:5173', 'http://127.0.0.1:5173', # Vite/CRA محلی 'http://localhost:5173', 'http://127.0.0.1:5173', # Vite/CRA محلی
] ]
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS)
o for o in os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',') if o
] or CORS_DEFAULT_ORIGINS
CORS_ALLOW_CREDENTIALS = False CORS_ALLOW_CREDENTIALS = False
# فقط مسیرهای API؛ /admin/ و فایل‌های استاتیک لازم نیست از origin دیگر خوانده شوند. # فقط مسیرهای API؛ /admin/ و فایل‌های استاتیک لازم نیست از origin دیگر خوانده شوند.
CORS_URLS_REGEX = r'^/api/.*$' CORS_URLS_REGEX = r'^/api/.*$'
@@ -490,8 +508,18 @@ BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# آدرسِ عمومیِ خودِ بک‌اند؛ هم برای ساختنِ paymentUrlی که به کلاینت برمی‌گردد # آدرسِ عمومیِ خودِ بک‌اند؛ هم برای ساختنِ paymentUrlی که به کلاینت برمی‌گردد
# (PaymentRedirectView) و هم برای callback_url که به زرین‌پال می‌فرستیم لازم # (PaymentRedirectView) و هم برای callback_url که به زرین‌پال می‌فرستیم لازم
# است. بدونش این دو URL شکسته می‌مانند — سیگنالِ واضحی که هنوز ست نشده. # است.
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', '').rstrip('/') #
# چرا پیش‌فرض دارد: خالی بودنش سرویس را نمی‌خواباند، فقط مسیرِ پرداخت را
# بی‌سروصدا می‌شکند — paymentUrl نسبی می‌شود و زرین‌پال callback خالی
# می‌گیرد. این دقیقاً همان دسته خطاست که فقط موقعِ اولین پرداختِ واقعی معلوم
# می‌شود. دامنه‌ی خودمان از قبل معلوم است، پس حدس زدنی نیست.
# .strip() اول لازم است: کلید در .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'

View File

@@ -4,6 +4,7 @@ import logging
import os import os
from unittest.mock import patch from unittest.mock import patch
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.test import RequestFactory, SimpleTestCase, TestCase from django.test import RequestFactory, SimpleTestCase, TestCase
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -306,3 +307,52 @@ class JSONLogFormatterTests(SimpleTestCase):
self.assertEqual(payload['message'], 'something failed') self.assertEqual(payload['message'], 'something failed')
self.assertIn('exception', payload) self.assertIn('exception', payload)
self.assertIn('ValueError: boom', payload['exception']) self.assertIn('ValueError: boom', payload['exception'])
class ServerEnvDefaultsTests(SimpleTestCase):
"""
متغیرهایی که قبلاً مسئولِ سرور مجبور بود دستی به .env اضافه کند.
هر سه‌تا یک الگو داشتند: نبودشان یا سرویس را می‌خواباند یا یک مسیر را
بی‌سروصدا می‌شکست، و چون در .env.example هم نبودند، هر بار از نو کشف
می‌شدند. این تست نگهبانِ همان است — نه اینکه مقدارها «قشنگ»اند، بلکه
اینکه بدونِ هیچ متغیرِ محیطی‌ای مقدارِ کارآمد دارند.
"""
HOST = 'api.math.tadrisino2.ir'
def test_the_api_domain_is_allowed_without_any_env_var(self):
self.assertIn(self.HOST, settings.DEFAULT_ALLOWED_HOSTS)
def test_an_incomplete_env_value_does_not_drop_our_domains(self):
"""
.env سرور از قبل DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 دارد.
اگر مقدارِ موجود پیش‌فرض را کنار بزند، همان «Disallowed Host» قدیمی
برمی‌گردد و باز کسی باید دستی فایل را ویرایش کند.
"""
# settings فقط نام‌های بزرگ را نشان می‌دهد؛ تابع از خودِ ماژول می‌آید.
from config.settings import _with_defaults
with patch.dict(os.environ, {'DJANGO_ALLOWED_HOSTS': 'localhost,127.0.0.1'}):
merged = _with_defaults('DJANGO_ALLOWED_HOSTS', settings.DEFAULT_ALLOWED_HOSTS)
self.assertIn(self.HOST, merged)
self.assertIn('localhost', merged, 'مقدارِ موجود نباید حذف شود')
def test_a_present_but_empty_value_still_falls_back(self):
"""کلید در .env هست ولی خالی — os.environ.get پیش‌فرض نمی‌دهد."""
with patch.dict(os.environ, {'PUBLIC_BASE_URL': ''}):
value = (os.environ.get('PUBLIC_BASE_URL', '').strip()
or 'https://api.math.tadrisino2.ir')
self.assertTrue(value.startswith('https://'))
def test_admin_login_origin_is_trusted_without_any_env_var(self):
self.assertIn(f'https://{self.HOST}', settings.CSRF_TRUSTED_ORIGINS)
def test_the_deployed_frontend_may_call_the_api(self):
self.assertIn('https://math.tadrisino2.ir', settings.CORS_ALLOWED_ORIGINS)
def test_payment_urls_are_absolute_without_any_env_var(self):
"""خالی بودنِ PUBLIC_BASE_URL مسیرِ پرداخت را بی‌صدا می‌شکست."""
self.assertTrue(settings.PUBLIC_BASE_URL.startswith('https://'))
self.assertTrue(settings.ZARINPAL_CALLBACK_URL.startswith('https://'))
self.assertIn('/api/payment/zarinpal/callback', settings.ZARINPAL_CALLBACK_URL)