Close /api/health details and API docs to the public
Health: public gets only ok/error; details need X-Internal-Key. Docs/schema: admin session only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,8 +71,7 @@ paid on the server on 2026-10-03); orders without the `question_credits`/`hearts
|
|||||||
## Before any real user: read `docs/security-before-production.md`
|
## Before any real user: read `docs/security-before-production.md`
|
||||||
|
|
||||||
The server is deliberately left in demo mode for the internship presentation: seven
|
The server is deliberately left in demo mode for the internship presentation: seven
|
||||||
fixed-code demo logins exist, demo users sit in the production DB, and `/api/health`, the API
|
fixed-code demo logins exist, demo users sit in the production DB, and the leaderboard and `/admin/` are public (`/api/health` shows only ok/error without `X-Internal-Key`; Swagger/ReDoc/schema need an admin session since 2026-10-08). (The token signing key is no longer the
|
||||||
schema, the leaderboard and `/admin/` are all public. (The token signing key is no longer the
|
|
||||||
committed one: replaced 2026-10-07, see the status block below.) Each is listed there with its risk, why it was left, and the
|
committed one: replaced 2026-10-07, see the status block below.) Each is listed there with its risk, why it was left, and the
|
||||||
fix. **Do not treat this deployment as production-ready until every item there is closed**,
|
fix. **Do not treat this deployment as production-ready until every item there is closed**,
|
||||||
and when one is closed, move it to that file's «حفاظهایی که هست» table with the test that holds it (the date lives in the commit message; the owner wants docs to describe the current state only).
|
and when one is closed, move it to that file's «حفاظهایی که هست» table with the test that holds it (the date lives in the commit message; the owner wants docs to describe the current state only).
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ def _warnings() -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
from payment.permissions import HasInternalServiceKey # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
class HealthView(APIView):
|
class HealthView(APIView):
|
||||||
"""
|
"""
|
||||||
GET /api/health — وضعیتِ این نسخهی مستقرشده.
|
GET /api/health — وضعیتِ این نسخهی مستقرشده.
|
||||||
@@ -213,6 +216,13 @@ class HealthView(APIView):
|
|||||||
}
|
}
|
||||||
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
|
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
|
||||||
|
|
||||||
|
status = 200 if critical_ok else 503
|
||||||
|
# عموم فقط ok/error میبینند؛ جزئیات (هشدارها، ترافیک، سرویسهای وصلشده، IP) پشتِ هدرِ
|
||||||
|
# X-Internal-Key. کد ۲۰۰/۵۰۳ عمومی میماند تا پایشِ بیرونی کار کند. اگر INTERNAL_SERVICE_KEY
|
||||||
|
# در .env نباشد، هیچکس جزئیات را نمیبیند (نه اینکه همه ببینند).
|
||||||
|
if not HasInternalServiceKey().has_permission(request, self):
|
||||||
|
return Response({'status': 'ok' if critical_ok else 'error'}, status=status)
|
||||||
|
|
||||||
warnings = _warnings()
|
warnings = _warnings()
|
||||||
payload = {
|
payload = {
|
||||||
'status': 'ok' if critical_ok else 'error',
|
'status': 'ok' if critical_ok else 'error',
|
||||||
@@ -228,4 +238,4 @@ class HealthView(APIView):
|
|||||||
# را به خودش برمیگرداند، پس چیزی لو نمیدهد.
|
# را به خودش برمیگرداند، پس چیزی لو نمیدهد.
|
||||||
'clientIp': ScopedRateThrottle().get_ident(request),
|
'clientIp': ScopedRateThrottle().get_ident(request),
|
||||||
}
|
}
|
||||||
return Response(payload, status=200 if critical_ok else 503)
|
return Response(payload, status=status)
|
||||||
|
|||||||
@@ -636,6 +636,11 @@ SPECTACULAR_SETTINGS = {
|
|||||||
{'name': 'System', 'description': 'سلامتِ سرویس'},
|
{'name': 'System', 'description': 'سلامتِ سرویس'},
|
||||||
],
|
],
|
||||||
'SERVE_INCLUDE_SCHEMA': False,
|
'SERVE_INCLUDE_SCHEMA': False,
|
||||||
|
# مستندات فقط برای ادمینِ واردشده به /admin/. IsAdminUser بهتنهایی کافی نیست: احرازِ پیشفرضِ DRF
|
||||||
|
# فقط Bearer است و ادمینِ پنل ۴۰۱ میگرفت؛ SessionAuthentication کوکیِ پنل را میشناسد.
|
||||||
|
# فایلِ schema برای فرانت: `python manage.py spectacular --file schema.yaml` (بینیاز از HTTP).
|
||||||
|
'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAdminUser'],
|
||||||
|
'SERVE_AUTHENTICATION': ['rest_framework.authentication.SessionAuthentication'],
|
||||||
'COMPONENT_SPLIT_REQUEST': True,
|
'COMPONENT_SPLIT_REQUEST': True,
|
||||||
# دو فیلدِ «kind» با دو مجموعهی مقدار: نوعِ درس و نوعِ پلن (بسته/اشتراک، از ۲۰۲۶-۱۰-۰۲).
|
# دو فیلدِ «kind» با دو مجموعهی مقدار: نوعِ درس و نوعِ پلن (بسته/اشتراک، از ۲۰۲۶-۱۰-۰۲).
|
||||||
# بدونِ نامِ صریح، drf-spectacular یکی را «KindEd8Enum» مینامید و هشدار میداد.
|
# بدونِ نامِ صریح، drf-spectacular یکی را «KindEd8Enum» مینامید و هشدار میداد.
|
||||||
|
|||||||
@@ -467,6 +467,15 @@ class ApiDocsRtlShellTests(TestCase):
|
|||||||
دیگری نمیگیردش.
|
دیگری نمیگیردش.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from django.contrib.auth.models import User as AuthUser
|
||||||
|
self.client.force_login(AuthUser.objects.create_superuser('docs-admin', 'd@x.ir', 'pw'))
|
||||||
|
|
||||||
|
def test_docs_are_closed_to_anonymous_visitors(self):
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
for path in ('/api/docs/', '/api/redoc/', '/api/schema/'):
|
||||||
|
self.assertIn(APIClient().get(path).status_code, (401, 403), path)
|
||||||
|
|
||||||
def test_swagger_ui_carries_the_rtl_shell(self):
|
def test_swagger_ui_carries_the_rtl_shell(self):
|
||||||
html = self.client.get('/api/docs/').content.decode()
|
html = self.client.get('/api/docs/').content.decode()
|
||||||
self.assertIn('direction: rtl', html)
|
self.assertIn('direction: rtl', html)
|
||||||
@@ -490,3 +499,20 @@ class InsecureSecretKeyWarningTests(TestCase):
|
|||||||
self.assertIn('django-insecure', ' '.join(health._warnings()))
|
self.assertIn('django-insecure', ' '.join(health._warnings()))
|
||||||
with override_settings(DEBUG=False, SECRET_KEY='a-real-random-key'):
|
with override_settings(DEBUG=False, SECRET_KEY='a-real-random-key'):
|
||||||
self.assertNotIn('django-insecure', ' '.join(health._warnings()))
|
self.assertNotIn('django-insecure', ' '.join(health._warnings()))
|
||||||
|
|
||||||
|
|
||||||
|
class HealthIsMinimalForThePublicTests(TestCase):
|
||||||
|
"""/api/health بیکلید فقط ok/error میدهد؛ با کلیدِ درست جزئیات."""
|
||||||
|
|
||||||
|
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||||
|
def test_public_sees_only_status(self):
|
||||||
|
body = self.client.get('/api/health').json()
|
||||||
|
self.assertEqual(set(body), {'status'})
|
||||||
|
wrong = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='nope').json()
|
||||||
|
self.assertEqual(set(wrong), {'status'})
|
||||||
|
full = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
|
||||||
|
self.assertIn('traffic', full)
|
||||||
|
|
||||||
|
@override_settings(INTERNAL_SERVICE_KEY='')
|
||||||
|
def test_without_a_configured_key_nobody_gets_details(self):
|
||||||
|
self.assertEqual(set(self.client.get('/api/health', HTTP_X_INTERNAL_KEY='').json()), {'status'})
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class _Quiet:
|
|||||||
|
|
||||||
|
|
||||||
@override_settings(CACHES=_caches('redis://127.0.0.1:1/0'))
|
@override_settings(CACHES=_caches('redis://127.0.0.1:1/0'))
|
||||||
|
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||||
class RedisDownTests(_Quiet, TestCase):
|
class RedisDownTests(_Quiet, TestCase):
|
||||||
"""پورتِ ۱ بسته است: هر اتصال فوراً رد میشود."""
|
"""پورتِ ۱ بسته است: هر اتصال فوراً رد میشود."""
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ class RedisDownTests(_Quiet, TestCase):
|
|||||||
self.assertNotEqual(response.status_code, 500)
|
self.assertNotEqual(response.status_code, 500)
|
||||||
|
|
||||||
def test_health_says_so(self):
|
def test_health_says_so(self):
|
||||||
response = APIClient().get('/api/health')
|
response = APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k')
|
||||||
self.assertEqual(response.status_code, 503)
|
self.assertEqual(response.status_code, 503)
|
||||||
self.assertIs(response.json()['checks']['cache']['ok'], False)
|
self.assertIs(response.json()['checks']['cache']['ok'], False)
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ def _free_port():
|
|||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(shutil.which('redis-server'), 'redis-server نصب نیست')
|
@unittest.skipUnless(shutil.which('redis-server'), 'redis-server نصب نیست')
|
||||||
|
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||||
class RealRedisTests(_Quiet, TestCase):
|
class RealRedisTests(_Quiet, TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
@@ -155,7 +157,7 @@ class RealRedisTests(_Quiet, TestCase):
|
|||||||
|
|
||||||
def test_throttle_is_shared_and_health_is_ok(self):
|
def test_throttle_is_shared_and_health_is_ok(self):
|
||||||
with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1')):
|
with override_settings(CACHES=_caches(f'redis://127.0.0.1:{self.port}/1')):
|
||||||
response = APIClient().get('/api/health')
|
response = APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k')
|
||||||
self.assertIs(response.json()['checks']['cache']['ok'], True)
|
self.assertIs(response.json()['checks']['cache']['ok'], True)
|
||||||
|
|
||||||
def test_redis_dying_mid_flight_and_coming_back_without_restarting_the_app(self):
|
def test_redis_dying_mid_flight_and_coming_back_without_restarting_the_app(self):
|
||||||
@@ -168,9 +170,9 @@ class RealRedisTests(_Quiet, TestCase):
|
|||||||
with self.assertLogs('config.cache', 'ERROR'):
|
with self.assertLogs('config.cache', 'ERROR'):
|
||||||
self.assertEqual(c.get('k', 'miss'), 'miss')
|
self.assertEqual(c.get('k', 'miss'), 'miss')
|
||||||
self.assertEqual(APIClient().get('/api/grades').status_code, 200)
|
self.assertEqual(APIClient().get('/api/grades').status_code, 200)
|
||||||
self.assertEqual(APIClient().get('/api/health').status_code, 503)
|
self.assertEqual(APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 503)
|
||||||
|
|
||||||
self.start() # برمیگردد، بدونِ ریاستارتِ برنامه
|
self.start() # برمیگردد، بدونِ ریاستارتِ برنامه
|
||||||
c.set('k2', 'after', 60)
|
c.set('k2', 'after', 60)
|
||||||
self.assertEqual(c.get('k2'), 'after')
|
self.assertEqual(c.get('k2'), 'after')
|
||||||
self.assertEqual(APIClient().get('/api/health').status_code, 200)
|
self.assertEqual(APIClient().get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 200)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import override_settings, TestCase
|
||||||
|
|
||||||
from content.models import Grade, Lesson, Node, Question, Unit
|
from content.models import Grade, Lesson, Node, Question, Unit
|
||||||
from content.question_io import ImportError_, export_questions, import_questions, parse
|
from content.question_io import ImportError_, export_questions, import_questions, parse
|
||||||
@@ -77,9 +77,10 @@ class QuestionImportTests(TestCase):
|
|||||||
self.assertEqual(Question.objects.get().choices.filter(is_correct=True).get().value, '۵')
|
self.assertEqual(Question.objects.get().choices.filter(is_correct=True).get().value, '۵')
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||||
class HealthEndpointTests(TestCase):
|
class HealthEndpointTests(TestCase):
|
||||||
def test_reports_ok_and_counts_content(self):
|
def test_reports_ok_and_counts_content(self):
|
||||||
response = self.client.get('/api/health')
|
response = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
body = response.json()
|
body = response.json()
|
||||||
self.assertEqual(body['status'], 'ok')
|
self.assertEqual(body['status'], 'ok')
|
||||||
@@ -88,12 +89,12 @@ class HealthEndpointTests(TestCase):
|
|||||||
self.assertIn('questions', body['checks']['content'])
|
self.assertIn('questions', body['checks']['content'])
|
||||||
|
|
||||||
def test_never_leaks_a_secret(self):
|
def test_never_leaks_a_secret(self):
|
||||||
raw = self.client.get('/api/health').content.decode()
|
raw = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').content.decode()
|
||||||
for needle in ('sk-', 'SECRET', 'API_KEY', 'password'):
|
for needle in ('sk-', 'SECRET', 'API_KEY', 'password'):
|
||||||
self.assertNotIn(needle, raw)
|
self.assertNotIn(needle, raw)
|
||||||
|
|
||||||
def test_needs_no_authentication(self):
|
def test_needs_no_authentication(self):
|
||||||
self.assertEqual(self.client.get('/api/health').status_code, 200)
|
self.assertEqual(self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
class CurriculumImportTests(TestCase):
|
class CurriculumImportTests(TestCase):
|
||||||
@@ -169,19 +170,21 @@ class CurriculumImportTests(TestCase):
|
|||||||
self.assertEqual(Lesson.objects.get().title, 'ضرب توانها')
|
self.assertEqual(Lesson.objects.get().title, 'ضرب توانها')
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||||
class HealthWarningTests(TestCase):
|
class HealthWarningTests(TestCase):
|
||||||
def test_debug_mode_is_reported_as_a_warning(self):
|
def test_debug_mode_is_reported_as_a_warning(self):
|
||||||
with self.settings(DEBUG=True, DEV_MASTER_OTP='0000'):
|
with self.settings(DEBUG=True, DEV_MASTER_OTP='0000'):
|
||||||
body = self.client.get('/api/health').json()
|
body = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
|
||||||
joined = ' '.join(body['warnings'])
|
joined = ' '.join(body['warnings'])
|
||||||
self.assertIn('DEBUG', joined)
|
self.assertIn('DEBUG', joined)
|
||||||
self.assertIn('0000', joined)
|
self.assertIn('0000', joined)
|
||||||
|
|
||||||
def test_allowed_origins_include_the_deployed_frontend(self):
|
def test_allowed_origins_include_the_deployed_frontend(self):
|
||||||
body = self.client.get('/api/health').json()
|
body = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
|
||||||
self.assertIn('https://math.tadrisino2.ir', body['allowedOrigins'])
|
self.assertIn('https://math.tadrisino2.ir', body['allowedOrigins'])
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||||
class MigrationCheckTests(TestCase):
|
class MigrationCheckTests(TestCase):
|
||||||
"""
|
"""
|
||||||
این بررسی مهمترین چیزیست که /api/health میگوید، و تنها وقتی اهمیت دارد
|
این بررسی مهمترین چیزیست که /api/health میگوید، و تنها وقتی اهمیت دارد
|
||||||
@@ -231,6 +234,7 @@ class ReplaceCountTests(TestCase):
|
|||||||
self.assertEqual(result['removed'], 1, 'باید ۱ باشد نه ۵')
|
self.assertEqual(result['removed'], 1, 'باید ۱ باشد نه ۵')
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||||
class HealthTrafficTests(TestCase):
|
class HealthTrafficTests(TestCase):
|
||||||
"""
|
"""
|
||||||
سیگنالِ «کسی وصل شده یا نه» — تنها راهی که از بیرون میشود فهمید فرانت
|
سیگنالِ «کسی وصل شده یا نه» — تنها راهی که از بیرون میشود فهمید فرانت
|
||||||
@@ -243,7 +247,7 @@ class HealthTrafficTests(TestCase):
|
|||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
|
|
||||||
User.objects.create(phone='09120000009', last_activity_at=timezone.now())
|
User.objects.create(phone='09120000009', last_activity_at=timezone.now())
|
||||||
traffic = self.client.get('/api/health').json()['traffic']
|
traffic = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()['traffic']
|
||||||
|
|
||||||
self.assertEqual(traffic['users'], 1)
|
self.assertEqual(traffic['users'], 1)
|
||||||
self.assertEqual(traffic['activeLast24h'], 1)
|
self.assertEqual(traffic['activeLast24h'], 1)
|
||||||
@@ -258,7 +262,7 @@ class HealthTrafficTests(TestCase):
|
|||||||
|
|
||||||
User.objects.create(phone='09120000010',
|
User.objects.create(phone='09120000010',
|
||||||
last_activity_at=timezone.now() - timedelta(days=3))
|
last_activity_at=timezone.now() - timedelta(days=3))
|
||||||
traffic = self.client.get('/api/health').json()['traffic']
|
traffic = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()['traffic']
|
||||||
self.assertEqual(traffic['users'], 1)
|
self.assertEqual(traffic['users'], 1)
|
||||||
self.assertEqual(traffic['activeLast24h'], 0)
|
self.assertEqual(traffic['activeLast24h'], 0)
|
||||||
|
|
||||||
@@ -266,7 +270,7 @@ class HealthTrafficTests(TestCase):
|
|||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
|
|
||||||
User.objects.create(phone='09121234567', name='علی')
|
User.objects.create(phone='09121234567', name='علی')
|
||||||
raw = self.client.get('/api/health').content.decode()
|
raw = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').content.decode()
|
||||||
self.assertNotIn('09121234567', raw)
|
self.assertNotIn('09121234567', raw)
|
||||||
self.assertNotIn('علی', raw)
|
self.assertNotIn('علی', raw)
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ poll کردنِ `/api/health`.
|
|||||||
## بررسیِ سلامت
|
## بررسیِ سلامت
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl https://mathapi.tadrisino2.ir/api/health
|
curl https://mathapi.tadrisino2.ir/api/health # عمومی: فقط {"status": "ok"|"error"}
|
||||||
|
curl -H "X-Internal-Key: <INTERNAL_SERVICE_KEY>" https://mathapi.tadrisino2.ir/api/health # جزئیات
|
||||||
```
|
```
|
||||||
|
|
||||||
|
کلید در `.env` سرور است (`INTERNAL_SERVICE_KEY`)؛ جایی ننویسید و در چت نفرستید. مستنداتِ API (`/api/docs/`، `/api/redoc/`، `/api/schema/`) فقط برای ادمینِ واردشده به
|
||||||
|
`/admin/` باز است؛ برای فرانت: `python manage.py spectacular --file schema.yaml --format openapi`.
|
||||||
|
|
||||||
بدونِ احرازِ هویت و بدونِ SSH میگوید: دیتابیس سالم است یا نه، مهاجرتِ
|
بدونِ احرازِ هویت و بدونِ SSH میگوید: دیتابیس سالم است یا نه، مهاجرتِ
|
||||||
اعمالنشده مانده یا نه، کش کار میکند یا نه، چند پایه/درس/سوال روی آن سرور
|
اعمالنشده مانده یا نه، کش کار میکند یا نه، چند پایه/درس/سوال روی آن سرور
|
||||||
هست، کدام سرویسِ بیرونی تنظیم شده (فقط بله/خیر)، و چه هشدارهای امنیتیای
|
هست، کدام سرویسِ بیرونی تنظیم شده (فقط بله/خیر)، و چه هشدارهای امنیتیای
|
||||||
@@ -111,7 +115,7 @@ curl https://mathapi.tadrisino2.ir/api/health
|
|||||||
| `DJANGO_SECRET_KEY` | یک کلیدِ تصادفیِ قوی ساخته و در `.django_secret_key` (مجوز `0600`) ذخیره میشود |
|
| `DJANGO_SECRET_KEY` | یک کلیدِ تصادفیِ قوی ساخته و در `.django_secret_key` (مجوز `0600`) ذخیره میشود |
|
||||||
| `PUBLIC_BASE_URL` · `FRONTEND_URL` | دامنهی فعلی (`mathapi.tadrisino2.ir` / `math.tadrisino2.ir`). بقیهی تنظیمهای دامنه از همین دو ساخته میشوند؛ بخشِ «عوض کردنِ دامنه» |
|
| `PUBLIC_BASE_URL` · `FRONTEND_URL` | دامنهی فعلی (`mathapi.tadrisino2.ir` / `math.tadrisino2.ir`). بقیهی تنظیمهای دامنه از همین دو ساخته میشوند؛ بخشِ «عوض کردنِ دامنه» |
|
||||||
| `DJANGO_ALLOWED_HOSTS` · `CSRF_TRUSTED_ORIGINS` · `CORS_ALLOWED_ORIGINS` | از دو آدرسِ بالا ساخته میشوند؛ مقدارِ env فقط دامنهی *اضافه* است |
|
| `DJANGO_ALLOWED_HOSTS` · `CSRF_TRUSTED_ORIGINS` · `CORS_ALLOWED_ORIGINS` | از دو آدرسِ بالا ساخته میشوند؛ مقدارِ env فقط دامنهی *اضافه* است |
|
||||||
| `CACHE_URL` | سرویس بالا میآید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار میدهد. **روی سرور خالی است عمداً**: Redisِ آن ماشین مالِ تیمهای دیگر است و به آن وصل نمیشویم؛ بعد از ساختنِ Redisِ اختصاصیِ خودمان این را پر کنید. با آن، قطعیِ Redis API را ۵۰۰ نمیکند ولی تا برگشتنش سقفهای مبتنی بر کش اعمال نمیشوند و health ۵۰۳ میدهد (`config/cache.py`) |
|
| `CACHE_URL` | سرویس بالا میآید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار میدهد. روی سرور به Redisِ اختصاصیِ خودمان (`zareian-redis`، پورت ۶۳۹۰) اشاره میکند، نه Redisِ مشترکِ ماشین. با آن، قطعیِ Redis API را ۵۰۰ نمیکند ولی تا برگشتنش سقفهای مبتنی بر کش اعمال نمیشوند و health ۵۰۳ میدهد (`config/cache.py`) |
|
||||||
| `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` میدهد، نه موفقیتِ ساختگی |
|
| `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` میدهد، نه موفقیتِ ساختگی |
|
||||||
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed میماند (`503`) |
|
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed میماند (`503`) |
|
||||||
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشهی کاری میخواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ همزمانِ دستیار کلِ API را قفل میکرد |
|
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشهی کاری میخواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ همزمانِ دستیار کلِ API را قفل میکرد |
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور |
|
| ۲ | ۷ حسابِ دمو با کدِ ورودِ ثابت روی پروداکشن | بالا | `.env` سرور |
|
||||||
| ۳ | دادهی دمو روی دیتابیسِ پروداکشن | بالا | دیتابیسِ سرور |
|
| ۳ | دادهی دمو روی دیتابیسِ پروداکشن | بالا | دیتابیسِ سرور |
|
||||||
| ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان میدهد | متوسط | کد |
|
|
||||||
| ۵ | Swagger، ReDoc و schemaی کاملِ API عمومیاند | متوسط | کد |
|
|
||||||
| ۷ | پنلِ `/admin/` روی دامنهی عمومی | متوسط | nginx + کد |
|
| ۷ | پنلِ `/admin/` روی دامنهی عمومی | متوسط | nginx + کد |
|
||||||
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب میدهد | متوسط | ادمینِ سرور |
|
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب میدهد | متوسط | ادمینِ سرور |
|
||||||
| ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقفهای مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis |
|
| ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقفهای مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis |
|
||||||
@@ -63,36 +61,6 @@
|
|||||||
`purge_demo_data` اینها را **نمیگیرد** (از مسیرِ واقعیِ viewها ساخته شدهاند).
|
`purge_demo_data` اینها را **نمیگیرد** (از مسیرِ واقعیِ viewها ساخته شدهاند).
|
||||||
- **چطور بسنجید:** `/api/health` → `traffic.users` فقط کاربرانِ واقعی را بشمارد.
|
- **چطور بسنجید:** `/api/health` → `traffic.users` فقط کاربرانِ واقعی را بشمارد.
|
||||||
|
|
||||||
## ۴. `/api/health` عمومی و پرجزئیات
|
|
||||||
|
|
||||||
- **چیست:** `config/health.py`، `AllowAny`. بدونِ توکن برمیگرداند: `debug`، `warnings`
|
|
||||||
(مثلاً «شمارههای تست فعالاند»، «محدودیتِ نرخ بینِ workerها مشترک نیست»)، `integrations`
|
|
||||||
(کدام سرویسها پیکربندیاند، وضعیتِ اعتبارِ AI، نامِ مدل)، `traffic` (تعدادِ کاربر، فعالِ
|
|
||||||
۲۴ ساعتِ اخیر، زمانِ آخرین فعالیت)، `allowedOrigins`، `clientIp`، شمارِ محتوا و مهاجرتها.
|
|
||||||
- **خطر:** نقشهی حمله: مهاجم میفهمد محدودیتها سهبرابر دورزدنیاند، حسابِ کدِ ثابت
|
|
||||||
هست، اعتبارِ AI کی تمام شده، و ترافیکِ واقعی چقدر است.
|
|
||||||
- **چرا باز مانده:** صاحبِ پروژه و agentها بدونِ دسترسیِ SSH از همین صفحه سلامتِ سرور را
|
|
||||||
میبینند.
|
|
||||||
- **رفع:** پاسخِ عمومی فقط `{"status": "ok"|"degraded"}`؛ بقیه پشتِ هدرِ
|
|
||||||
`X-Internal-Key` (`INTERNAL_SERVICE_KEY`) یا فقط برای staff.
|
|
||||||
- **چطور بسنجید:** `curl https://mathapi.tadrisino2.ir/api/health` بدونِ هدر نباید
|
|
||||||
`warnings` و `traffic` داشته باشد.
|
|
||||||
|
|
||||||
## ۵. مستنداتِ API عمومی
|
|
||||||
|
|
||||||
- **چیست:** `/api/docs/` (Swagger)، `/api/redoc/` و `/api/schema/` بدونِ ورود بازاند
|
|
||||||
(تأیید: HTTP 200). schema همهی endpointها، پارامترها، کدهای خطا و هدرهای داخلی
|
|
||||||
(`X-Internal-Key`) را فهرست میکند.
|
|
||||||
- **خطر:** شناساییِ کاملِ سطحِ حمله بدونِ زحمت.
|
|
||||||
- **چرا باز مانده:** قراردادِ مشترک با فرانت همینجاست؛ فرانتکار و ارائه به آن نیاز دارند.
|
|
||||||
- **رفع:** مسیر را در nginx به IPهای تیم ببندید، یا در `SPECTACULAR_SETTINGS` هر دوِ
|
|
||||||
`SERVE_PERMISSIONS = ['rest_framework.permissions.IsAdminUser']` و
|
|
||||||
`SERVE_AUTHENTICATION = ['rest_framework.authentication.SessionAuthentication']` را بگذارید.
|
|
||||||
`IsAdminUser` بهتنهایی کار نمیکند: احرازِ پیشفرضِ DRF فقط توکنِ Bearer است، پس ادمینِ واردشده
|
|
||||||
به پنل هم ۴۰۱ میگیرد (آزموده شد). اسکریپتِ Swagger/ReDoc نسخهی ثابت دارد (نه `@latest`)، چون
|
|
||||||
روی همان دامنهی `/admin/` اجرا میشود.
|
|
||||||
- **چطور بسنجید:** `curl -o /dev/null -w "%{http_code}" …/api/schema/` باید ۴۰۱/۴۰۳ بدهد.
|
|
||||||
|
|
||||||
## ۷. پنلِ ادمین روی دامنهی عمومی
|
## ۷. پنلِ ادمین روی دامنهی عمومی
|
||||||
|
|
||||||
- **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نامکاربری/رمزِ جنگو. سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)،
|
- **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نامکاربری/رمزِ جنگو. سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)،
|
||||||
@@ -259,6 +227,8 @@
|
|||||||
| پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمیزند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبتشده | `payment/tests_audit_fixes.py`، `accounts/tests_audit_fixes.py` |
|
| پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمیزند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبتشده | `payment/tests_audit_fixes.py`، `accounts/tests_audit_fixes.py` |
|
||||||
| لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاهعمرِ همراهِ `paymentUrl` باز میشود | `payment/tests.py` (`PaymentRedirectViewTests`) |
|
| لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاهعمرِ همراهِ `paymentUrl` باز میشود | `payment/tests.py` (`PaymentRedirectViewTests`) |
|
||||||
| توکنِ تمدید فقط بهصورتِ هشِ SHA-256 ذخیره میشود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) |
|
| توکنِ تمدید فقط بهصورتِ هشِ SHA-256 ذخیره میشود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) |
|
||||||
|
| `/api/health` بیکلید فقط `ok`/`error` میدهد؛ جزئیات با هدرِ `X-Internal-Key` (کلید در `.env` سرور). بیکلیدِ تنظیمشده هیچکس جزئیات نمیبیند | `config/tests.py` (`HealthIsMinimalForThePublicTests`) |
|
||||||
|
| Swagger، ReDoc و schema فقط برای ادمینِ واردشده به `/admin/` | `config/tests.py` (`ApiDocsRtlShellTests.test_docs_are_closed_to_anonymous_visitors`) |
|
||||||
| جدولِ رتبهبندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) |
|
| جدولِ رتبهبندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) |
|
||||||
| شمارهی موبایل نرمال میشود؛ cooldown، سقفِ روزانهی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده میشوند | `accounts/tests_otp_limits.py` |
|
| شمارهی موبایل نرمال میشود؛ cooldown، سقفِ روزانهی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده میشوند | `accounts/tests_otp_limits.py` |
|
||||||
| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکنهای قبلی را باطل میکند | `accounts/tests_audit_fixes.py` |
|
| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکنهای قبلی را باطل میکند | `accounts/tests_audit_fixes.py` |
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ class ConversationMemoryTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
@override_settings(**RAG_ON)
|
@override_settings(**RAG_ON)
|
||||||
|
@override_settings(INTERNAL_SERVICE_KEY='k')
|
||||||
class ProviderStateTests(TestCase):
|
class ProviderStateTests(TestCase):
|
||||||
"""اعتبارِ تمامشده باید در /api/health دیده شود، نه فقط در لاگ."""
|
"""اعتبارِ تمامشده باید در /api/health دیده شود، نه فقط در لاگ."""
|
||||||
|
|
||||||
@@ -247,17 +248,17 @@ class ProviderStateTests(TestCase):
|
|||||||
quota = RuntimeError("Error code: 403 - insufficient user quota, remaining quota: $-0.001")
|
quota = RuntimeError("Error code: 403 - insufficient user quota, remaining quota: $-0.001")
|
||||||
self.assertEqual(self._ask({'side_effect': quota}).status_code, 503)
|
self.assertEqual(self._ask({'side_effect': quota}).status_code, 503)
|
||||||
|
|
||||||
health = self.client.get('/api/health').json()
|
health = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
|
||||||
self.assertEqual(health['integrations']['aiProvider']['state'], 'quota_exhausted')
|
self.assertEqual(health['integrations']['aiProvider']['state'], 'quota_exhausted')
|
||||||
self.assertTrue(any('GapGPT' in w for w in health['warnings']))
|
self.assertTrue(any('GapGPT' in w for w in health['warnings']))
|
||||||
|
|
||||||
self.assertEqual(self._ask({'return_value': ('جواب', [], {})}).status_code, 200)
|
self.assertEqual(self._ask({'return_value': ('جواب', [], {})}).status_code, 200)
|
||||||
health = self.client.get('/api/health').json()
|
health = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
|
||||||
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
|
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
|
||||||
|
|
||||||
def test_an_ordinary_network_error_does_not_claim_the_credit_is_gone(self):
|
def test_an_ordinary_network_error_does_not_claim_the_credit_is_gone(self):
|
||||||
self.assertEqual(self._ask({'side_effect': ConnectionError('timeout')}).status_code, 503)
|
self.assertEqual(self._ask({'side_effect': ConnectionError('timeout')}).status_code, 503)
|
||||||
health = self.client.get('/api/health').json()
|
health = self.client.get('/api/health', HTTP_X_INTERNAL_KEY='k').json()
|
||||||
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
|
self.assertEqual(health['integrations']['aiProvider']['state'], 'ok')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user