From a3a47b459dc8a59735c70ee50ce78117f32db311 Mon Sep 17 00:00:00 2001 From: Zareian Date: Mon, 14 Sep 2026 00:00:35 +0330 Subject: [PATCH] stop an existing .env from cancelling the defaults meant to replace it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit پیش‌فرض‌های دیروز روی یک سرورِ تازه کار می‌کردند ولی روی سرورِ واقعی نه، و تستِ با .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 --- .env.example | 26 +++++++++++++++------- config/settings.py | 54 +++++++++++++++++++++++++++++++++++----------- config/tests.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 8eb85fb..cb118b8 100644 --- a/.env.example +++ b/.env.example @@ -22,13 +22,7 @@ # تا وقتی کاربرِ واقعی ندارید اشکالی ندارد؛ قبل از اولین کاربرِ واقعی 0 کنید. DJANGO_DEBUG=1 -# این سه پیش‌فرضِ درست دارند (api.math.tadrisino2.ir و math.tadrisino2.ir) -# و فقط اگر دامنه عوض شد لازم می‌شوند. -# DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 -# CSRF_TRUSTED_ORIGINS= -# CORS_ALLOWED_ORIGINS= - -# خالی بگذارید تا خودش ساخته و ذخیره شود. +# خالی بگذارید تا خودش ساخته و در .django_secret_key ذخیره شود. DJANGO_SECRET_KEY= # بدونِ Redis سرویس بالا می‌آید ولی محدودیتِ نرخ بینِ workerها مشترک نیست؛ @@ -38,6 +32,22 @@ CACHE_URL= # نامِ سرویسِ systemd تا deploy.sh خودش ری‌استارت کند (فقط روی سرور). # 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 واقعی ارسال نمی‌شه (فقط خطای صریح ۵۰۳ برمی‌گرده، # طبق رفتارِ موجودِ accounts/services.py) KAVENEGAR_API_KEY= @@ -84,7 +94,7 @@ RAG_FORCE_MOCK= # ----------------------------------------------------------------------- # بدونِ ZARINPAL_MERCHANT_ID، مسیرِ پرداخت fail-closed می‌مونه (خطای صریحِ # ۵۰۳ به‌جایِ پرداختِ ساختگیِ موفق). -PUBLIC_BASE_URL= +# PUBLIC_BASE_URL بالاتر توضیح داده شده؛ پیش‌فرض دارد. ZARINPAL_MERCHANT_ID= # 1 = محیطِ sandbox زرین‌پال (برای تست بدونِ پرداختِ واقعی) ZARINPAL_SANDBOX=1 diff --git a/config/settings.py b/config/settings.py index 35bedbe..9311440 100644 --- a/config/settings.py +++ b/config/settings.py @@ -67,22 +67,42 @@ if not SECRET_KEY: 'یا متغیر را ست کنید یا اجازه‌ی نوشتن در پوشه‌ی پروژه بدهید.' ) from exc -# دامنه‌های شناخته‌شده‌ی خودمان پیش‌فرض‌اند تا یک متغیرِ فراموش‌شده باعثِ -# DisallowedHost نشود. متغیرِ محیطی، اگر ست باشد، کاملاً جایگزینشان می‌کند. +def _env_list(name: str) -> list[str]: + 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 = [ 'api.math.tadrisino2.ir', 'math.tadrisino2.ir', 'localhost', '127.0.0.1', ] -ALLOWED_HOSTS = [ - h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h -] or DEFAULT_ALLOWED_HOSTS +ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS) # لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود # «CSRF verification failed» می‌دهد و کسی نمی‌فهمد چرا. -CSRF_TRUSTED_ORIGINS = [ - o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o -] or ['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir'] +CSRF_TRUSTED_ORIGINS = _with_defaults( + 'CSRF_TRUSTED_ORIGINS', + ['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir'], +) # --- CORS ------------------------------------------------------------------- # فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر @@ -101,9 +121,7 @@ CORS_DEFAULT_ORIGINS = [ 'http://localhost:3000', 'http://127.0.0.1:3000', 'http://localhost:5173', 'http://127.0.0.1:5173', # Vite/CRA محلی ] -CORS_ALLOWED_ORIGINS = [ - o for o in os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',') if o -] or CORS_DEFAULT_ORIGINS +CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS) CORS_ALLOW_CREDENTIALS = False # فقط مسیرهای API؛ /admin/ و فایل‌های استاتیک لازم نیست از origin دیگر خوانده شوند. CORS_URLS_REGEX = r'^/api/.*$' @@ -490,8 +508,18 @@ BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar # ----------------------------------------------------------------------- # آدرسِ عمومیِ خودِ بک‌اند؛ هم برای ساختنِ paymentUrlی که به کلاینت برمی‌گردد # (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_SANDBOX = os.environ.get('ZARINPAL_SANDBOX') == '1' diff --git a/config/tests.py b/config/tests.py index 4be3e00..7d4e5f1 100644 --- a/config/tests.py +++ b/config/tests.py @@ -4,6 +4,7 @@ import logging import os from unittest.mock import patch +from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.test import RequestFactory, SimpleTestCase, TestCase from rest_framework.test import APIClient @@ -306,3 +307,52 @@ class JSONLogFormatterTests(SimpleTestCase): self.assertEqual(payload['message'], 'something failed') self.assertIn('exception', payload) 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)