stop a deploy from silently blocking the frontend or leaving 0000 open

سه حالتِ خراب که هیچ‌کدام در لاگِ سرور ردی نمی‌گذارند و فقط وقتی کشف می‌شدند
که یک آدم از بیرون اپ را دستی امتحان کند.

CORS_ALLOWED_ORIGINS فقط از env خوانده می‌شد و با DEBUG روشن به localhost
محدود می‌ماند. یعنی بک‌اندِ کاملاً سالم، ولی مرورگر همه‌ی درخواست‌های
math.tadrisino2.ir را بلاک می‌کند — خطایی که سمتِ سرور دیده نمی‌شود و فقط در
کنسولِ مرورگر پیداست. origin های شناخته‌شده حالا پیش‌فرضِ داخلِ کدند تا یک
متغیرِ فراموش‌شده اتصال را نشکند؛ env همچنان بازنویسی‌شان می‌کند.

/api/health حالا warnings هم می‌دهد. مهم‌ترینش: با DEBUG روشن روی سرورِ عمومی،
DEV_MASTER_OTP یعنی هر کسی با کدِ 0000 می‌تواند به‌جای هر شماره‌ای وارد شود.
این تا امروز فقط با خواندنِ تنظیماتِ سرور معلوم می‌شد، و ما به سرور دسترسی
نداریم. allowedOrigins هم برگردانده می‌شود تا مشکلِ CORS از بیرون قابلِ
تشخیص باشد.

deploy.sh قبل از دست زدن به دیتابیس متغیرهای لازم را بررسی می‌کند و با پیامِ
روشن می‌ایستد، به‌جای اینکه وسطِ کار بشکند و 502 بدهد.
.env.production.example همان‌ها را یک‌جا مستند می‌کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 20:46:23 +03:30
parent 190bff3d0f
commit eaa4b761dd
4 changed files with 116 additions and 7 deletions

44
.env.production.example Normal file
View File

@@ -0,0 +1,44 @@
# نمونه‌ی فایلِ .env برای سرور. یک‌بار پر می‌شود و بعد دیگر کاری با آن نیست.
#
# بعد از پر کردن، هر آپدیت فقط این است:
# git pull && ./deploy.sh
# ─── اجباری ──────────────────────────────────────────────────────────────
# دامنه‌هایی که این سرویس روی آن‌ها سرو می‌شود (با ویرگول جدا).
DJANGO_ALLOWED_HOSTS=api.math.tadrisino2.ir
# ─── حالتِ پروداکشن ──────────────────────────────────────────────────────
# DJANGO_DEBUG را ست نکنید (یا 0 بگذارید).
#
# ⚠️ با DJANGO_DEBUG=1 روی یک سرورِ عمومی:
# • صفحه‌های خطا کلِ تنظیمات و traceback را نشان می‌دهند
# • کدِ ورودِ آزمایشیِ 0000 برای هر شماره‌ای پذیرفته می‌شود،
# یعنی هر کسی می‌تواند به‌جای هر کاربری وارد شود
DJANGO_DEBUG=0
# با DEBUG خاموش این دو اجباری‌اند:
DJANGO_SECRET_KEY=
# یک رشته‌ی تصادفیِ بلند. ساختنش:
# python -c "import secrets; print(secrets.token_urlsafe(64))"
CACHE_URL=redis://127.0.0.1:6379/1
# بدونِ کشِ مشترک، محدودیتِ نرخ بینِ workerهای gunicorn مشترک نیست.
# دامنه‌هایی که فرم/ادمین از آن‌ها POST می‌کند
CSRF_TRUSTED_ORIGINS=https://api.math.tadrisino2.ir
# ─── اختیاری ─────────────────────────────────────────────────────────────
# origin های فرانت. اگر خالی باشد از فهرستِ پیش‌فرضِ داخلِ کد استفاده می‌شود
# که https://math.tadrisino2.ir را شامل است — معمولاً لازم نیست ست شود.
# CORS_ALLOWED_ORIGINS=https://math.tadrisino2.ir
# کلیدهای سرویس‌های بیرونی
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.gapgpt.app/v1
QDRANT_URL=https://qdrant.tadrisino2.ir:443
QDRANT_API_KEY=
KAVENEGAR_API_KEY=
KAVENEGAR_SENDER=
# نامِ سرویسِ systemd تا deploy.sh خودش ری‌استارت کند
# TADRISINO_SERVICE=tadrisino

View File

@@ -87,6 +87,30 @@ def _check_integrations() -> dict:
}
def _warnings() -> list[str]:
"""
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند.
اینجا هستند چون تنها راهِ دیدنشان تا امروز این بود که کسی تنظیماتِ سرور را
از نزدیک بخواند — و ما به سرور دسترسی نداریم.
"""
out = []
if settings.DEBUG:
out.append(
'DEBUG روشن است: صفحه‌های خطا کلِ تنظیمات و traceback را نشان می‌دهند، '
'و کدِ ورودِ آزمایشیِ 0000 برای هر شماره‌ای پذیرفته می‌شود. '
'روی سرورِ عمومی باید خاموش باشد.'
)
if getattr(settings, 'DEV_MASTER_OTP', None) is not None:
out.append('کدِ یک‌سانِ ورود (DEV_MASTER_OTP) فعال است — هر کسی می‌تواند به‌جای هر کاربری وارد شود.')
backend = settings.CACHES['default']['BACKEND']
if not settings.DEBUG and 'LocMem' in backend:
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
if not getattr(settings, 'CORS_ALLOWED_ORIGINS', None):
out.append('هیچ origin ای برای CORS مجاز نیست — مرورگر همه‌ی درخواست‌های فرانت را بلاک می‌کند.')
return out
class HealthView(APIView):
"""
GET /api/health — وضعیتِ این نسخه‌ی مستقرشده.
@@ -110,10 +134,13 @@ class HealthView(APIView):
}
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
warnings = _warnings()
payload = {
'status': 'ok' if critical_ok else 'error',
'debug': bool(settings.DEBUG),
'warnings': warnings,
'checks': checks,
'integrations': _check_integrations(),
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
}
return Response(payload, status=200 if critical_ok else 503)

View File

@@ -61,15 +61,18 @@ CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').sp
# هدر است، پس origin ناشناس نباید بتواند از مرورگرِ کاربر به API ما بزند.
# CORS_ALLOW_CREDENTIALS هم عمداً خاموش می‌ماند — کوکی‌ای در کار نیست و روشن
# کردنش فقط سطحِ حمله اضافه می‌کند.
# origin های شناخته‌شده‌ی فرانت. عمداً اینجا و نه فقط در متغیرِ محیطی: اگر
# تنها راهش env بود، یک دیپلویِ فراموش‌شده یعنی بک‌اندِ سالم که مرورگر همه‌ی
# درخواست‌هایش را بلاک می‌کند — خطایی که در سمتِ سرور هیچ ردی نمی‌گذارد و فقط
# در کنسولِ مرورگر دیده می‌شود.
CORS_DEFAULT_ORIGINS = [
'https://math.tadrisino2.ir', # فرانتِ مستقر
'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
]
if DEBUG and not CORS_ALLOWED_ORIGINS:
# پورت‌های پیش‌فرضِ Vite/CRA برای توسعه‌ی محلی.
CORS_ALLOWED_ORIGINS = [
'http://localhost:3000', 'http://127.0.0.1:3000',
'http://localhost:5173', 'http://127.0.0.1:5173',
]
] or CORS_DEFAULT_ORIGINS
CORS_ALLOW_CREDENTIALS = False
# فقط مسیرهای API؛ /admin/ و فایل‌های استاتیک لازم نیست از origin دیگر خوانده شوند.
CORS_URLS_REGEX = r'^/api/.*$'

View File

@@ -60,6 +60,41 @@ python -m pip install "${PIP_ARGS[@]}" \
|| fail "نصبِ وابستگی‌ها شکست خورد. اگر pypi.org تایم‌اوت می‌دهد، این را امتحان کنید:
PIP_INDEX_URL=https://mirror-pypi.runflare.com/simple ./deploy.sh"
# ─── ۳.۵ بررسیِ متغیرهای محیطی، قبل از دست زدن به دیتابیس ──────────────────
# چرا اینجا: اگر SECRET_KEY یا CACHE_URL نباشد، جنگو موقعِ بالا آمدن می‌میرد و
# از بیرون فقط 502 دیده می‌شود. بهتر است همین‌جا و با پیامِ روشن متوقف شویم.
step "بررسیِ متغیرهای محیطی"
python - <<'PYCHECK' || fail "متغیرهای محیطیِ لازم ست نیستند؛ هیچ تغییری اعمال نشد."
import os, sys
debug = os.environ.get('DJANGO_DEBUG', '').strip().lower() in {'1', 'true', 'yes', 'on'}
missing = []
if not os.environ.get('DJANGO_ALLOWED_HOSTS', '').strip():
missing.append(('DJANGO_ALLOWED_HOSTS', 'دامنه‌هایی که سرو می‌شوند، مثلاً api.math.tadrisino2.ir'))
if not debug:
if not os.environ.get('DJANGO_SECRET_KEY', '').strip():
missing.append(('DJANGO_SECRET_KEY', 'کلیدِ امضا؛ با DEBUG خاموش اجباری است'))
if not os.environ.get('CACHE_URL', '').strip():
missing.append(('CACHE_URL', 'آدرسِ Redis؛ محدودیتِ نرخ بدونش بینِ workerها مشترک نیست'))
if missing:
print(' این متغیرها ست نیستند:')
for name, why in missing:
print(f' {name:24} {why}')
print()
print(' در فایلِ .env سرور اضافه‌شان کنید (نمونه: .env.production.example).')
sys.exit(1)
if debug:
print(' ⚠ DJANGO_DEBUG روشن است.')
print(' روی سرورِ عمومی یعنی: صفحه‌های خطا کلِ تنظیمات را نشان می‌دهند،')
print(' و کدِ ورودِ 0000 برای هر شماره‌ای پذیرفته می‌شود.')
else:
print(' ✓ حالتِ پروداکشن')
PYCHECK
# ─── ۴. دیتابیس ────────────────────────────────────────────────────────────
step "اعمالِ مهاجرت‌های دیتابیس"
python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ری‌استارت نشد."