From eaa4b761dd2a2360711dec812ef184893e80b76e Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 13 Sep 2026 20:46:23 +0330 Subject: [PATCH] stop a deploy from silently blocking the frontend or leaving 0000 open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit سه حالتِ خراب که هیچ‌کدام در لاگِ سرور ردی نمی‌گذارند و فقط وقتی کشف می‌شدند که یک آدم از بیرون اپ را دستی امتحان کند. 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 --- .env.production.example | 44 +++++++++++++++++++++++++++++++++++++++++ config/health.py | 27 +++++++++++++++++++++++++ config/settings.py | 17 +++++++++------- deploy.sh | 35 ++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 .env.production.example diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..33a66c0 --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/config/health.py b/config/health.py index bbbaae0..e047de8 100644 --- a/config/health.py +++ b/config/health.py @@ -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) diff --git a/config/settings.py b/config/settings.py index 405f1ac..71e9e15 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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/.*$' diff --git a/deploy.sh b/deploy.sh index 9b674a3..0b84001 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 شکست خورد؛ سرویس ری‌استارت نشد."