diff --git a/CLAUDE.md b/CLAUDE.md index 19b4e18..fb9ce58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ signing key is the one committed in this repo, seven fixed-code demo logins exis users sit in the production DB, and `/api/health`, the API schema, the leaderboard and `/admin/` are all public. 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**, -and record the date and commit of each fix in that file rather than deleting the item. +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). ## Commands @@ -661,7 +661,7 @@ Redirect URIِ ثبت‌شده در پنلِ بازار خودِ `/api/payment/b - **از این به بعد هر درخواستِ OTP پول است.** برای همین: شماره به `09XXXXXXXXX` نرمال می‌شود و غیرِ موبایلِ ایران ۴۰۰ می‌گیرد؛ cooldown و سقفِ روزانه‌ی هر شماره (۱۰) و سقفِ روزانه‌ی کلِ سامانه (۳۰۰، ضدِ SMS pumping) در دیتابیس شمرده می‌شوند نه کشِ per-worker. - جزئیات در بندِ ۱۷ِ `docs/security-before-production.md`، تست‌ها در `accounts/tests_otp_limits.py`. + تست‌ها در `accounts/tests_otp_limits.py`. - مسیرِ کاملِ کاربرِ تازه روی سرورِ زنده سبز شد، **بدونِ پیامکِ واقعی**: کد مستقیم در دیتابیس ساخته شد. کاربرِ تستیِ آن **کاربرِ ۱۷ با شماره‌ی `09000000001`** (نام «کاربر تست»، یک جزیره تمام‌شده) روی پروداکشن مانده و مثلِ داده‌ی دمو پیش از کاربرِ واقعی باید پاک شود. diff --git a/accounts/management/commands/seed_test_data.py b/accounts/management/commands/seed_test_data.py index 124b3ce..288a3ef 100644 --- a/accounts/management/commands/seed_test_data.py +++ b/accounts/management/commands/seed_test_data.py @@ -37,7 +37,7 @@ from django.db import transaction from django.utils import timezone from accounts.authentication import issue_access_token -from accounts.models import OTPCode, Profile, Session, User +from accounts.models import OTPCode, Profile, Session, User, hash_refresh_token # --- شناسه‌های ثابتِ داده‌ی seed -------------------------------------------- # شماره‌های 0912000000X عمداً خارج از بازه‌ی واقعیِ اپراتورهای ایران نیستن؛ @@ -177,7 +177,7 @@ class Command(BaseCommand): ) Session.objects.get_or_create( user=main_user, - refresh_token=f'seed-refresh-{MAIN_PHONE}', + refresh_token=hash_refresh_token(f'seed-refresh-{MAIN_PHONE}'), defaults=dict( device_id='seed-device-main', expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), @@ -192,7 +192,7 @@ class Command(BaseCommand): ) Session.objects.get_or_create( user=guest_user, - refresh_token=f'seed-refresh-{GUEST_DEVICE_ID}', + refresh_token=hash_refresh_token(f'seed-refresh-{GUEST_DEVICE_ID}'), defaults=dict( device_id=GUEST_DEVICE_ID, expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), diff --git a/accounts/migrations/0007_hash_refresh_tokens.py b/accounts/migrations/0007_hash_refresh_tokens.py new file mode 100644 index 0000000..66607ca --- /dev/null +++ b/accounts/migrations/0007_hash_refresh_tokens.py @@ -0,0 +1,28 @@ +""" +Session.refresh_token از این به بعد هشِ SHA-256 است (accounts/models.py، hash_refresh_token). +ردیف‌های موجود هش می‌شوند تا توکن‌هایی که کلاینت‌ها در دست دارند همچنان کار کنند. از هش نمی‌شود +خودِ توکن را ساخت؛ برگرداندنِ این migration هش‌ها را سرِ جایشان می‌گذارد و کدِ قبلی آن‌ها را +نمی‌شناسد، یعنی هر کاربر یک بار دوباره وارد می‌شود. +""" +import hashlib + +from django.db import migrations + + +def hash_existing(apps, schema_editor): + Session = apps.get_model('accounts', 'Session') + # اول مادی کردن، بعد نوشتن: کرسرِ بازِ SQLite وسطِ حلقه‌ی نوشتن قفل می‌سازد (CLAUDE.md). + rows = list(Session.objects.values_list('id', 'refresh_token')) + for pk, raw in rows: + Session.objects.filter(pk=pk).update(refresh_token=hashlib.sha256(raw.encode()).hexdigest()) + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0006_profile_birth_date_drop_parent_phone'), + ] + + operations = [ + migrations.RunPython(hash_existing, migrations.RunPython.noop), + ] diff --git a/accounts/models.py b/accounts/models.py index 7038f96..2a5c06f 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,3 +1,5 @@ +import hashlib + from django.db import models @@ -60,11 +62,18 @@ class Profile(models.Model): return f"profile-{self.user_id}" +def hash_refresh_token(raw: str) -> str: + """آنچه در Session.refresh_token ذخیره و با آن جست‌وجو می‌شود؛ خودِ توکن فقط دستِ کلاینت است.""" + return hashlib.sha256(raw.encode()).hexdigest() + + class Session(models.Model): """ - نگهداری refresh_token هر ورود. توکن دسترسی (access token) در دیتابیس - ذخیره نمی‌شه؛ به‌صورت stateless امضا (signed) می‌شه — دقیقاً طبق فیلدهای - همین مدل که فقط refresh_token داره، نه access token. + یک ورود. توکنِ دسترسی ذخیره نمی‌شود (امضاشده و stateless است، accounts/authentication.py). + + `refresh_token` هشِ SHA-256ِ توکنِ تمدید است، نه خودش (hash_refresh_token): هر کس دیتابیس، + یک بکاپ یا پنلِ ادمین را ببیند با آن نمی‌تواند به‌جای کاربر وارد شود. توکن ۶۴ نویسه‌ی تصادفی + است، پس هشِ سریعِ بی‌نمک کافی است. """ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions') refresh_token = models.CharField(max_length=200, unique=True) diff --git a/accounts/tests.py b/accounts/tests.py index 0d7abb4..e8f4004 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -13,7 +13,7 @@ from rest_framework.test import APIClient from rest_framework.throttling import ScopedRateThrottle from .authentication import issue_access_token -from .models import OTPCode, Profile, Session, User +from .models import OTPCode, Profile, Session, User, hash_refresh_token from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms @@ -286,9 +286,10 @@ class RefreshTokenReplayTests(TestCase): def setUp(self): self.client = APIClient() self.user = User.objects.create(is_guest=True, device_id='device-refresh-test') + self.raw = 'test-refresh-token-abc123' self.session = Session.objects.create( user=self.user, - refresh_token='test-refresh-token-abc123', + refresh_token=hash_refresh_token(self.raw), device_id='device-refresh-test', expires_at=timezone.now() + timezone.timedelta(days=30), revoked=False, @@ -296,19 +297,19 @@ class RefreshTokenReplayTests(TestCase): def test_valid_refresh_token_succeeds(self): response = self.client.post( - '/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json', + '/api/auth/refresh', {'refreshToken': self.raw}, format='json', ) self.assertEqual(response.status_code, 200) self.assertTrue(response.data['token']) def test_same_refresh_token_rejected_on_second_use(self): first_response = self.client.post( - '/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json', + '/api/auth/refresh', {'refreshToken': self.raw}, format='json', ) self.assertEqual(first_response.status_code, 200) second_response = self.client.post( - '/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json', + '/api/auth/refresh', {'refreshToken': self.raw}, format='json', ) self.assertEqual(second_response.status_code, 401) @@ -317,15 +318,15 @@ class RefreshTokenReplayTests(TestCase): def test_successful_refresh_returns_new_refresh_token(self): response = self.client.post( - '/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json', + '/api/auth/refresh', {'refreshToken': self.raw}, format='json', ) self.assertEqual(response.status_code, 200) new_refresh_token = response.data['refreshToken'] self.assertTrue(new_refresh_token) - self.assertNotEqual(new_refresh_token, self.session.refresh_token) + self.assertNotEqual(new_refresh_token, self.raw) self.assertTrue( Session.objects.filter( - refresh_token=new_refresh_token, user=self.user, revoked=False, + refresh_token=hash_refresh_token(new_refresh_token), user=self.user, revoked=False, ).exists() ) @@ -761,20 +762,23 @@ class RefreshTokenThrottleTests(TestCase): self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test') def _create_session(self, token_suffix): - return Session.objects.create( + raw = f'test-refresh-token-{token_suffix}' + session = Session.objects.create( user=self.user, - refresh_token=f'test-refresh-token-{token_suffix}', + refresh_token=hash_refresh_token(raw), device_id='device-refresh-throttle-test', expires_at=timezone.now() + timezone.timedelta(days=30), revoked=False, ) + session.raw = raw + return session def test_requests_within_the_limit_still_succeed(self): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}): for i in range(3): session = self._create_session(i) response = self.client.post( - '/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json', + '/api/auth/refresh', {'refreshToken': session.raw}, format='json', ) self.assertEqual(response.status_code, 200) @@ -783,13 +787,13 @@ class RefreshTokenThrottleTests(TestCase): for i in range(3): session = self._create_session(i) response = self.client.post( - '/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json', + '/api/auth/refresh', {'refreshToken': session.raw}, format='json', ) self.assertEqual(response.status_code, 200) extra_session = self._create_session('extra') blocked_response = self.client.post( - '/api/auth/refresh', {'refreshToken': extra_session.refresh_token}, format='json', + '/api/auth/refresh', {'refreshToken': extra_session.raw}, format='json', ) self.assertEqual(blocked_response.status_code, 429) @@ -797,7 +801,7 @@ class RefreshTokenThrottleTests(TestCase): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '1/hour'}): first_session = self._create_session('first') first_response = self.client.post( - '/api/auth/refresh', {'refreshToken': first_session.refresh_token}, format='json', + '/api/auth/refresh', {'refreshToken': first_session.raw}, format='json', ) self.assertEqual(first_response.status_code, 200) @@ -806,7 +810,7 @@ class RefreshTokenThrottleTests(TestCase): with patch('accounts.views.issue_access_token') as mock_issue_token: blocked_response = self.client.post( - '/api/auth/refresh', {'refreshToken': blocked_session.refresh_token}, format='json', + '/api/auth/refresh', {'refreshToken': blocked_session.raw}, format='json', ) self.assertEqual(blocked_response.status_code, 429) mock_issue_token.assert_not_called() @@ -1669,3 +1673,31 @@ class TestLoginPhoneParsingTests(TestCase): '09120000001:abcdef', '09120000001:1234567'): with self.subTest(raw=raw), self.assertRaises(ImproperlyConfigured): self._parse(raw) + + +class RefreshTokenStoredHashedTests(TestCase): + """دیتابیس، بکاپ یا پنلِ ادمین نباید خودِ توکنِ تمدید را داشته باشد؛ فقط هشش.""" + + def test_issued_tokens_are_stored_only_as_hash(self): + client = APIClient() + body = client.post('/api/auth/session', {'type': 'guest', 'deviceId': 'hash-dev'}, format='json').json() + raw = body['refreshToken'] + self.assertFalse(Session.objects.filter(refresh_token=raw).exists()) + self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(raw)).exists()) + + renewed = client.post('/api/auth/refresh', {'refreshToken': raw}, format='json').json()['refreshToken'] + self.assertFalse(Session.objects.filter(refresh_token=renewed).exists()) + self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(renewed), revoked=False).exists()) + + def test_migration_hashes_existing_rows(self): + from importlib import import_module + + from django.apps import apps as global_apps + + user = User.objects.create(is_guest=True, device_id='hash-mig') + Session.objects.create(user=user, refresh_token='old-raw-token', + expires_at=timezone.now() + timezone.timedelta(days=1)) + import_module('accounts.migrations.0007_hash_refresh_tokens').hash_existing(global_apps, None) + self.assertEqual(Session.objects.get(user=user).refresh_token, hash_refresh_token('old-raw-token')) + response = APIClient().post('/api/auth/refresh', {'refreshToken': 'old-raw-token'}, format='json') + self.assertEqual(response.status_code, 200) diff --git a/accounts/views.py b/accounts/views.py index ee57bc7..d562435 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -16,7 +16,7 @@ from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from .authentication import invalidate_user_cache, issue_access_token -from .models import OTPCode, Profile, Session, User +from .models import OTPCode, Profile, Session, User, hash_refresh_token from .services import OTPDeliveryError, send_otp_sms from .serializers import ( DeleteAccountRequestSerializer, @@ -101,7 +101,7 @@ class GuestSessionView(APIView): refresh_token = secrets.token_hex(32) Session.objects.create( user=user, - refresh_token=refresh_token, + refresh_token=hash_refresh_token(refresh_token), device_id=device_id, expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), ) @@ -385,7 +385,7 @@ class LoginView(APIView): refresh_token = uuid.uuid4().hex Session.objects.create( user=user, - refresh_token=refresh_token, + refresh_token=hash_refresh_token(refresh_token), device_id=request.data.get('deviceId', ''), expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), ) @@ -504,7 +504,7 @@ class LogoutView(APIView): with transaction.atomic(): if refresh_token: Session.objects.filter( - user_id=request.user.id, refresh_token=refresh_token, + user_id=request.user.id, refresh_token=hash_refresh_token(refresh_token), ).update(revoked=True) User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now()) invalidate_user_cache(request.user.id) @@ -536,13 +536,13 @@ class RefreshTokenView(APIView): # UPDATE اتمیک جلوی استفاده‌ی هم‌زمان از یک refresh token را می‌گیرد (replay protection). with transaction.atomic(): updated_count = Session.objects.filter( - refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now() + refresh_token=hash_refresh_token(refresh_token), revoked=False, expires_at__gte=timezone.now() ).update(revoked=True) if not updated_count: return Response({'detail': 'رفرش‌توکن نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_401_UNAUTHORIZED) - old_session = Session.objects.get(refresh_token=refresh_token) + old_session = Session.objects.get(refresh_token=hash_refresh_token(refresh_token)) # ثبتِ فعالیتِ کاربر برای رفرشِ موفقِ توکن. User.objects.filter(pk=old_session.user_id).update(last_activity_at=timezone.now()) @@ -550,7 +550,7 @@ class RefreshTokenView(APIView): new_refresh_token = secrets.token_hex(32) new_session = Session.objects.create( user_id=old_session.user_id, - refresh_token=new_refresh_token, + refresh_token=hash_refresh_token(new_refresh_token), device_id=old_session.device_id, expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), ) diff --git a/config/settings.py b/config/settings.py index 750b601..3bb2291 100644 --- a/config/settings.py +++ b/config/settings.py @@ -871,9 +871,10 @@ RAG_ANSWER_CACHE_TTL = int(os.environ.get('RAG_ANSWER_CACHE_TTL', '1800')) # در اجرای تست‌ها همیشه RAG خاموش است تا هیچ تستی به OpenAI/Qdrant واقعی # نزند (سرعت + پایداری). تست‌هایی که مسیرِ RAG را می‌آزمایند خودشان با # override_settings + mockِ کلاینت‌ها این را برمی‌گردانند (rag/tests.py). +# فقط خودِ دستورِ `manage.py test`؛ هر آرگومانی به اسمِ «test» (مثلاً نامِ یک collection) نه. import sys as _sys -if 'test' in _sys.argv: +if _sys.argv[1:2] == ['test']: RAG_FORCE_MOCK = True RAG_ANSWER_CACHE_TTL = 0 diff --git a/deploy.sh b/deploy.sh index 5d4d867..a6d85ec 100755 --- a/deploy.sh +++ b/deploy.sh @@ -172,12 +172,11 @@ if 'LocMem' in settings.CACHES['default']['BACKEND'] and not settings.DEBUG: PYCHECK # ─── ۳.۶ مجوزِ فایل‌های داده ─────────────────────────────────────────────── -# این ماشین بینِ چند تیم مشترک است. تا ۲۰۲۶-۱۰-۰۴ دیتابیس و همه‌ی بکاپ‌ها 0644 بودند و -# پوشه‌ها قابلِ پیمایش؛ کاربرِ nobody روی سرور توانست سرِ db.sqlite3 و یک بکاپ را بخواند -# (شماره‌ها، refresh tokenهای بی‌هش، گفتگوها). در هر انتشار دوباره بسته می‌شود، چون بکاپِ -# دستیِ تازه دوباره با umaskِ پیش‌فرض (0644) ساخته می‌شود. nginx فقط به سوکتِ /run وصل -# می‌شود و از این پوشه چیزی نمی‌خواند (sites-enabled/mathapp، همان روز خوانده شد)؛ اگر -# سرویسِ دیگری این دیتابیس را مستقیم می‌خواند، از این به بعد خطای دسترسی می‌گیرد. +# این ماشین بینِ چند تیم مشترک است و دیتابیس شماره‌ها و گفتگوهای کاربران را دارد؛ پس پوشه‌ی +# پروژه برای others بسته، دیتابیس 600 و بکاپ‌ها 700/600. در هر انتشار دوباره اعمال می‌شود، چون +# بکاپِ دستیِ تازه با umaskِ پیش‌فرض (0644) ساخته می‌شود. nginx فقط به سوکتِ /run وصل می‌شود و +# از این پوشه چیزی نمی‌خواند (sites-enabled/mathapp)؛ سرویسی که این دیتابیس را مستقیم بخواند +# خطای دسترسی می‌گیرد. step "مجوزِ فایل‌های داده" chmod o-rwx . || warn "مجوزِ پوشه‌ی پروژه عوض نشد." for f in db.sqlite3 db.sqlite3-wal db.sqlite3-shm db.sqlite3-journal; do diff --git a/docs/security-before-production.md b/docs/security-before-production.md index c13c9d0..333ea28 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -1,7 +1,6 @@ # پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز مانده‌اند -**تاریخِ ثبت: ۲۰۲۶-۰۹-۲۵ (۳ مهر ۱۴۰۵)، کامیتِ پایه `66066cb`.** بازبینیِ دوباره با سرورِ زنده: -۲۰۲۶-۱۰-۰۴ (مواردِ ۱۹ تا ۲۳ و بازتولیدِ مورد ۸). +**آخرین بازبینی با کد و سرورِ زنده: ۲۰۲۶-۱۰-۰۴ (۱۲ مهر ۱۴۰۵).** این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ @@ -10,8 +9,9 @@ **برای agentها و توسعه‌دهنده‌های بعدی:** این فهرست ادعاست، نه ضمانت؛ ممکن است از این تاریخ به بعد چیزی عوض شده باشد. پیش از بستنِ هر مورد، وضعیتِ فعلی‌اش را با خودِ سرور یا -کد بسنجید (ستونِ «چطور بسنجید»). بعد از بستنِ هر مورد، همین‌جا تاریخ و کامیتِ رفعش را -بنویسید؛ مورد را پاک نکنید. +کد بسنجید (ستونِ «چطور بسنجید»). مورد که بسته شد، از فهرست برداشته می‌شود و یک خط در +«حفاظ‌هایی که هست» (پایینِ همین فایل) می‌گیرد، با نامِ تستی که آن را نگه می‌دارد. تاریخ و +دلیلِ تغییر در پیامِ کامیت است، نه این‌جا. هر آزمونِ «از بیرون» با `curl` بدونِ توکن انجام شده و در تاریخِ بالا تأیید شده است. @@ -26,22 +26,16 @@ | ۳ | داده‌ی دمو و حسابِ مهمانِ عمومیِ `frontend-test-device` | بالا | دیتابیسِ سرور | | ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان می‌دهد | متوسط | کد | | ۵ | Swagger، ReDoc و schemaی کاملِ API عمومی‌اند | متوسط | کد | -| ۶ | جدولِ رتبه‌بندی نام و شناسه‌ی کودکان را بدونِ ورود نشان می‌دهد | متوسط (حریمِ خصوصیِ کودک) | کد | | ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد | | ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور | | ۹ | کشِ مشترک (Redis) نیست؛ محدودیت‌ها per-worker‌اند | متوسط | `.env` سرور | | ۱۰ | ورودِ مهمان فقط با `deviceId` است | کم (طراحی) | کد | -| ۱۱ | توکنِ refresh و کدِ OTP بی‌هش در دیتابیس | کم | کد | +| ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد | | ۱۲ | هزینه‌ی دستیارِ AI با ساختِ حسابِ مهمان قابلِ بالا بردن است | کم (مالی) | کد | -| ۱۳ | ~~محدودیتِ قلب در سرور خاموش است (`HEARTS_ENFORCED=0`)~~ **بسته شد ۲۰۲۶-۱۰-۰۲** | کم (کسب‌وکار) | `.env` + فرانت | | ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت | | ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور | | ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت | -| ۱۷ | ~~شماره‌ی موبایل نرمال نمی‌شود؛ سقف‌های per-phone با املای دیگر دور زده می‌شوند~~ **بسته شد ۲۰۲۶-۰۹-۲۵** | متوسط | کد | -| ۱۸ | `/api/payment/orders/{id}/pay` بدونِ ورود و بدونِ سنجشِ مالکیت | کم | کد | -| ۱۹ | دیتابیس و بکاپ‌ها برای همه‌ی کاربرانِ ماشینِ مشترک خواندنی بودند | بالا | کد آماده (`deploy.sh`)؛ اعمال با دیپلوی | -| ۲۰ | یونیتِ systemd هنوز `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور | -| ۲۱ | `urllib3 2.7.0` و `python-dotenv 1.0.1` آسیب‌پذیریِ شناخته‌شده داشتند | کم | کد آماده؛ اعمال با دیپلوی | +| ۲۰ | یونیتِ systemd `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور | | ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت | | ۲۳ | سقفِ دستیار برای مهمان‌ها بر اساسِ IP مشترک است؛ کلاسِ پشتِ یک IP بعد از ۲۰ سوال در ساعت ۴۲۹ می‌گیرد | متوسط (دسترس‌پذیری) | تصمیمِ محصولی | @@ -117,19 +111,9 @@ `IsAdminUser` (staffِ جنگو) محدود کنید، یا مسیر را در nginx به IPهای تیم ببندید. - **چطور بسنجید:** `curl -o /dev/null -w "%{http_code}" …/api/schema/` باید ۴۰۱/۴۰۳ بدهد. -## ۶. جدولِ رتبه‌بندیِ عمومی — حریمِ خصوصیِ کودک - -- **چیست:** `GET /api/leaderboard` بدونِ توکن (تأیید: HTTP 200) برای هر ردیف `rank`، - `userId`، `name` و `xpInPeriod` می‌دهد. -- **خطر:** نامِ کودکان و شناسه‌ی داخلی‌شان برای همه‌ی اینترنت قابلِ جمع‌آوری است. - پاک‌سازیِ نام (حذفِ شماره و لینک، `clean_display_name`) فقط راهِ تماس را می‌گیرد، نه خودِ نام را. -- **رفع:** `IsAuthenticated`؛ حذفِ `userId` از پاسخ (جز برای ردیفِ خودِ کاربر)؛ نمایشِ - نامِ کوچک یا نامِ مستعار. - ## ۷. پنلِ ادمین روی دامنه‌ی عمومی -- **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نام‌کاربری/رمزِ جنگو. از - ۲۰۲۶-۰۹-۲۵ سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)، +- **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نام‌کاربری/رمزِ جنگو. سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)، ولی: شمارش per-worker است (مورد ۹)، IP با XFF جعل‌پذیر است (مورد ۸)، و ۲FA ندارد. - **خطر:** ادمین همه‌ی محتوا، پلن‌ها، کدهای تخفیف، سفارش‌ها و کاربرها را کنترل می‌کند. - **رفع:** محدود کردنِ `/admin/` در nginx به IPهای تیم یا VPN؛ تغییرِ مسیرِ پیش‌فرض؛ ۲FA؛ @@ -168,38 +152,26 @@ - **رفع:** برای مهمان هم یک رازِ تصادفیِ سمتِ سرور صادر شود و ورودِ دوباره با همان راز باشد، نه با `deviceId`ِ خام. -## ۱۱. داده‌ی حساس بی‌هش در دیتابیس +## ۱۱. کدِ OTP بی‌هش در دیتابیس -- **چیست:** `Session.refresh_token` و `OTPCode.code` به‌صورتِ متنِ ساده ذخیره می‌شوند - (`accounts/models.py`) و در پنلِ ادمین (`Session`, `OTPCode`) دیده می‌شوند. -- **خطر:** هر کس بکاپِ دیتابیس یا پنلِ ادمین را ببیند نشستِ هر کاربر را می‌رباید. - بکاپ‌ها در `.deploy-backups/` کنارِ خودِ پروژه روی یک ماشینِ مشترک‌اند. -- **رفع:** ذخیره‌ی هشِ SHA-256 و مقایسه با هش؛ حذفِ این دو فیلد از نمایشِ ادمین. +- **چیست:** `OTPCode.code` متنِ ساده است و در فهرستِ پنلِ ادمین (`OTPCodeAdmin`) دیده می‌شود. + توکنِ تمدید (`Session.refresh_token`) هش‌شده است و این‌جا شامل نیست. +- **چرا باز مانده:** تیم برای آزمایش بی‌پیامک یک ردیفِ `OTPCode` در پنل می‌سازد و کدش را می‌بیند. +- **خطر:** کسی که پنل یا دیتابیس را ببیند، در چند دقیقه‌ی عمرِ هر کد می‌تواند با آن وارد شود. +- **رفع:** ذخیره‌ی هش و حذفِ ستون از فهرستِ پنل، همراه با راهِ دیگری برای ورودِ آزمایشی. ## ۱۲. هزینه‌ی دستیارِ AI -- **چیست:** از ۲۰۲۶-۱۰-۰۲ هر کاربر روزی ۳ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`؛ پیش‌تر ۱۵) +- **چیست:** هر کاربر روزی ۳ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`) و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. مهمان‌ها بر اساسِ IP محدود می‌شوند ولی هر IP ساعتی - ۲۰ حسابِ مهمانِ تازه می‌سازد (`guest_session`)، و هر حسابِ تازه ۳ سؤالِ رایگانِ تازه دارد. خطر - کمتر شد (۳ به‌جای ۱۵)، ولی بسته نیست. طولِ سؤال از ۲۰۲۶-۰۹-۲۵ حداکثر ۲۰۰۰ نویسه است. + ۶۰ حسابِ مهمانِ تازه می‌سازد (`guest_session`)، و هر حسابِ تازه ۳ سؤالِ رایگانِ تازه دارد. سقفِ + روزانه‌ی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`، ۳۰۰۰) خرجِ کل را محدود می‌کند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است. - **خطر:** با چند IP، اعتبارِ GapGPT در چند ساعت تمام می‌شود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵ به صفر رسید، البته به دلیلِ دیگر). - **رفع:** سقفِ سراسریِ روزانه برای کلِ اپ؛ دستیار فقط برای کاربرِ واردشده با شماره یا با سقفِ بسیار کمتر برای مهمان؛ هشدار وقتی `aiProvider.state` در `/api/health` برابرِ `quota_exhausted` شد. -## ۱۳. ~~محدودیتِ قلب خاموش است~~ — بسته شد ۲۰۲۶-۱۰-۰۲ - -- **چیست:** با صفر قلب، سرور همچنان جواب را می‌پذیرد، مگر `HEARTS_ENFORCED=1` - (`content/views.py::QuestionAnswerView`). «قلبِ نامحدود» تنها مزیتِ اشتراک است. -- **خطر:** مزیتِ پولی فقط در ظاهرِ اپ اعمال می‌شود؛ با صدا زدنِ مستقیمِ API دور زده می‌شود. -- **چرا باز مانده:** فرانت برای ۴۰۳ِ `no_hearts` هنوز صفحه ندارد؛ روشن کردنش دانش‌آموز را به - صفحه‌ی خطا می‌برد. رفتار با صفر قلب هم تصمیمِ محصولیِ باز است. -- **رفع:** بعد از صفحه‌ی «قلب‌ها تمام شد» در فرانت، `HEARTS_ENFORCED=1` در `.env`. -- **بسته شد ۲۰۲۶-۱۰-۰۲** (بک‌اند `4700ccb`، فرانت `33f5f8d`): `HEARTS_ENFORCED` پیش‌فرض روشن؛ چکِ - قلب داخلِ تراکنش؛ پایانِ درس/جزیره‌ی بی‌جواب با قلبِ صفر هم بسته؛ صفحه‌ی «قلب‌هات تموم شد» با - شمارشِ معکوس. تست‌ها: `content/tests_hearts_gate.py`. - ## ۱۴. دیتابیس و بکاپ - **چیست:** SQLite در `~/mathapp-v2/db.sqlite3`، روی دیسکی که بینِ چند سرویس مشترک است و @@ -229,58 +201,7 @@ - **خطر:** هر XSS در سایت هر دو را می‌خواند. - **رفع:** CSPِ سخت‌گیرانه روی `math.tadrisino2.ir`؛ در بلندمدت refresh در کوکیِ `HttpOnly`. -## ۱۷. شماره‌ی موبایل نرمال نمی‌شود (افزوده در بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵) - -- **چیست:** `phone` در ورود، درخواستِ OTP و دعوتِ خانواده رشته‌ی خام است - (`accounts/serializers.py`، `max_length=15` بدونِ اعتبارسنجی). `09121234567`، - `+989121234567`، `989121234567` و همان شماره با ارقامِ فارسی چهار «شماره»ی جدا‌اند. -- **خطر:** cooldownِ ۲ دقیقه‌ای و سقفِ ۵ در ساعتِ هر شماره به‌ازای هر املا جداست، پس با - چند IP می‌شود یک گوشی را با پیامک بمباران کرد (وقتی کاوه‌نگار وصل شود، به هزینه‌ی ما)؛ - یک آدم با دو املا دو حسابِ جدا می‌سازد؛ دعوتِ والد با املای دیگر ۴۰۳ می‌گرفت (بخشِ والدین ۲۰۲۶-۱۰-۰۱ حذف شد). - (بازتولید شد با تستِ موقت؛ پیامکِ واقعی فرستاده نشد.) -- **رفع:** یک تابعِ `normalize_phone` (ارقامِ فارسی/عربی → لاتین، `+98`/`0098`/`98` → `0`، - فقط `09\d{9}`) در هر سه serializer، و migrationِ داده برای ردیف‌های موجود. -- **چطور بسنجید:** دو درخواستِ OTP پشتِ‌سرِهم با `0912…` و `+98912…` باید دومی ۴۲۹ بگیرد. -- **✅ بسته شد ۲۰۲۶-۰۹-۲۵** (همان روزی که کلیدِ کاوه‌نگار روی سرور رفت؛ کامیت `0580aec`، - روی سرور از ۱۲:۳۲). `accounts/phone.py::normalize_phone` در ورود، درخواستِ OTP، دعوتِ - خانواده و کلیدهای `TEST_LOGIN_PHONES`؛ migrationِ `accounts.0005` شماره‌های موجود را - نرمال می‌کند (ردیفی که شکلِ نرمالش مالِ کاربرِ دیگری است دست نمی‌خورد و فقط شمرده می‌شود). - `parentPhone` در پروفایل هنوز نرمال **نمی‌شود**. همراهش، چون از این تاریخ پیامکِ واقعی - خرجِ اعتبار است: - - cooldownِ ۲ دقیقه‌ای و سقفِ روزانه‌ی هر شماره (`OTP_REQUEST_PHONE_DAILY_MAX=10`) - حالا در **دیتابیس** هم شمرده می‌شوند، پس روی ۳ workerِ سرور سه‌برابر نمی‌شوند (بندِ ۹ - برای این دو دیگر صدق نمی‌کند؛ برای throttleهای IP هنوز می‌کند). - - سقفِ روزانه‌ی کلِ پیامکِ OTP (`OTP_SMS_GLOBAL_DAILY_MAX=300`) جلوی SMS pumping؛ - رسیدن به آن ۵۰۳ و لاگِ ERROR می‌دهد. با رشدِ کاربرِ واقعی از `.env` بالا ببرید. - - سقف‌های per-IP گشاده‌تر شدند (کد ۲۰، ورود ۳۰، مهمان ۶۰ در ساعت، همه از `.env` قابلِ - تغییر) چون پشتِ CGNAT و وای‌فایِ مدرسه ده‌ها نفر یک IP دارند. - - تست‌ها: `accounts/tests_otp_limits.py`. هم‌زمانیِ واقعیِ دو پروسه‌ی gunicorn تست نشده. - -## ۱۸. صفحه‌ی پرداخت مالکیت را نمی‌سنجد (افزوده در بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵) - -- **چیست:** `PaymentRedirectView` (`AllowAny`) برای هر شناسه‌ی سفارشِ ترتیبی از زرین‌پال - authority می‌گیرد. -- **خطر:** غریبه با پیمایشِ `/orders/1/pay`، `/orders/2/pay`… روی سفارش‌های باز authority - می‌نشاند؛ از آن به بعد صاحبِ سفارش «کدِ تخفیف را پیش از درگاه وارد کنید» می‌گیرد - (بازتولید شد، با زرین‌پالِ mock). وضعیتِ paid/failedِ سفارشِ دیگران هم از ریدایرکت پیداست. -- **رفع:** در `paymentUrl` یک توکنِ امضاشده‌ی کوتاه‌عمر (`signing`) با `order_id` بگذارید و - همان‌جا بسنجید؛ یا شناسه‌ی غیرترتیبی. - -## ۱۹. دیتابیس و بکاپ‌ها برای همه‌ی کاربرانِ ماشین خواندنی بودند (۲۰۲۶-۱۰-۰۴) - -- **چیست:** `/home/administrator` با مجوزِ 755، `~/mathapp-v2` و `.deploy-backups` با 775، و - `db.sqlite3` و هر ۳۵ بکاپ با 644. `.env` درست 600 بود. -- **بازتولید شد:** `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` و همین برای یک بکاپ، هر دو - `SQLite format 3` چاپ کردند. هر حسابِ دیگرِ این ماشین (aria، artina، behdad، zahra، git، gitea، - metabase) یا هر سرویسِ نفوذشده‌ی دیگری شماره‌ها، گفتگوهای کودکان و refresh tokenهای بی‌هش (مورد ۱۱، - یعنی ورود به‌جای هر کاربر) را می‌خواند. -- **رفع در کد:** مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`: پوشه‌ی پروژه بی‌دسترسیِ others، - دیتابیس 600، پوشه‌ی بکاپ 700 و فایل‌هایش 600، در هر انتشار (بکاپِ دستیِ تازه دوباره 644 ساخته - می‌شود). خودِ `/home/administrator` دست نخورد چون nginx فرانت را از `~/mathapp_frontend_new/dist` - می‌خواند. بعد از دیپلوی باید دوباره با `sudo -u nobody` سنجیده شود. هش کردنِ refresh token (مورد ۱۱) - هنوز باز است. - -## ۲۰. یونیتِ systemd: DEBUG و کلیدِ ناامن (۲۰۲۶-۱۰-۰۴) +## ۲۰. یونیتِ systemd: DEBUG و کلیدِ ناامن - **چیست:** `systemctl cat gunicorn-mathapp` هنوز `Environment="DJANGO_DEBUG=1"` و `Environment="DJANGO_SECRET_KEY=django-insecure-…"` دارد. `EnvironmentFile=.env` بعد از آن می‌آید و @@ -292,17 +213,7 @@ ری‌استارت. حذفِ خطِ کلید به‌تنهایی کسی را بیرون نمی‌اندازد اگر `.env` همان کلید را داشته باشد؛ عوض کردنِ خودِ کلید (مورد ۱) جداست. -## ۲۱. کتابخانه‌های آسیب‌پذیر (۲۰۲۶-۱۰-۰۴) - -- **چیست:** پایگاهِ OSV برای ۴۸ بسته‌ی نصب‌شده: `urllib3 2.7.0` (GHSA-8988-9cw3-xx77 و - GHSA-vxq7-64xx-v4gw با شدتِ بالا، GHSA-gh4c-6fx4-qh6g متوسط) و `python-dotenv 1.0.1` - (GHSA-mf9w-mj56-hr94، فقط در `set_key` که پروژه صدا نمی‌زند). اثرِ عملی کم است: سناریوی هر سه‌ی - urllib3 پاسخِ مخربِ سرورِ بالادستی یا پراکسی است (بازار، زرین‌پال، کاوه‌نگار، Qdrant). `npm audit` روی - وابستگی‌های اجراییِ فرانت صفر. -- **رفع در کد:** `python-dotenv==1.2.2`، `urllib3>=2.8.0`، و `gunicorn==26.2.0` (تا امروز فقط دستی - روی سرور نصب بود). هر سه با پایتونِ ۳.۱۰ِ سرور سازگارند و سرور به PyPI دسترسی دارد (سنجیده شد). - -## ۲۲. هدرهای امنیتیِ فرانت (۲۰۲۶-۱۰-۰۴) +## ۲۲. هدرهای امنیتیِ فرانت - **چیست:** `https://math.tadrisino2.ir/` فقط `x-xss-protection` دارد؛ نه CSP، نه `X-Frame-Options`/ `frame-ancestors`، نه HSTS، نه `X-Content-Type-Options`. API همه‌ی این‌ها را جز HSTS و CSP دارد. @@ -312,26 +223,33 @@ - **رفع:** در nginxِ `mathapp-front` یا در آروان: `Content-Security-Policy` با `frame-ancestors 'none'` و `connect-src` به دامنه‌ی API، و `X-Content-Type-Options: nosniff`. -## ۲۳. سقفِ دستیار برای مهمان‌ها روی IPِ مشترک (۲۰۲۶-۱۰-۰۴) +## ۲۳. سقفِ دستیار برای مهمان‌ها روی IPِ مشترک - **چیست:** `GuestAwareAIThrottle` برای مهمان‌ها سطلِ `ai_ask` (۲۰ در ساعت) را بر اساسِ IP می‌سازد تا ساختنِ مهمانِ تازه سقف را دور نزند. - **بازتولید شد:** ۲۲ مهمانِ متفاوت از یک IP، هر کدام اولین سوالش: ۲۰ تا ۲۰۰، ۲۱اُم و ۲۲اُم ۴۲۹. دانش‌آموزانِ مهمانِ یک کلاس پشتِ وای‌فایِ مدرسه یا CGNAT با هم ساعتی ۲۰ سوال دارند (روی سرور با ۳ workerِ بی‌Redis تا حدودِ ۶۰). -- **چرا تصمیمِ محصولی است:** سقفِ IP جلوی «مهمان بساز، سه سوالِ رایگان بگیر» را می‌گیرد. از - ۲۰۲۶-۱۰-۰۳ سقفِ سراسریِ `AI_GLOBAL_DAILY_MAX` (۳۰۰۰ در روز، حدودِ ۶ دلار) هزینه را جدا محدود +- **چرا تصمیمِ محصولی است:** سقفِ IP جلوی «مهمان بساز، سه سوالِ رایگان بگیر» را می‌گیرد. + سقفِ سراسریِ `AI_GLOBAL_DAILY_MAX` (۳۰۰۰ در روز، حدودِ ۶ دلار) هزینه را جدا محدود می‌کند، پس می‌شود سطل را per-user کرد و فقط ساختِ مهمان را محدود نگه داشت. تا تصمیم، دست نخورد. --- -## چیزهایی که بسته شده‌اند (برای اینکه دوباره باز نشوند) +## حفاظ‌هایی که هست (تا دوباره باز نشوند) -در بازبینیِ ۲۰۲۶-۰۹-۲۵ (کامیتِ `42783f9`) بسته شدند؛ جزئیات در `docs/architecture.md`: -پلنِ هر خرید روی سفارش، دست نخوردنِ اشتراکِ فعال هنگامِ خرید، یک کدِ تخفیف پیش از درگاه، -حذفِ `/api/payment/webhook`، امتیاز فقط از جوابِ ثبت‌شده، OTPِ ۶ رقمی با ۵ حدس، خروج از حساب، -حفظِ مهمان هنگامِ ثبت‌نام، `select_for_update` مؤثر با `IMMEDIATE`، سقفِ ورودِ ادمین، سقفِ -طولِ سؤالِ AI، مقایسه‌ی زمان‌ثابتِ `X-Internal-Key`. +هر تغییری در این مسیرها باید تست‌های نام‌برده را سبز نگه دارد. جزئیاتِ پرداخت و تخفیف در +`docs/architecture.md`. -هر تغییری در این مسیرها باید تست‌های `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`) | +| توکنِ تمدید فقط به‌صورتِ هشِ SHA-256 ذخیره می‌شود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) | +| جدولِ رتبه‌بندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) | +| شماره‌ی موبایل نرمال می‌شود؛ cooldown، سقفِ روزانه‌ی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده می‌شوند | `accounts/tests_otp_limits.py` | +| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکن‌های قبلی را باطل می‌کند | `accounts/tests_audit_fixes.py` | +| قلبِ صفر جوابِ سوال و پایانِ درس/جزیره را می‌بندد؛ چکش داخلِ تراکنش است | `content/tests_hearts_gate.py` | +| `select_for_update` مؤثر با `transaction_mode: IMMEDIATE`؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` | +| دیتابیس و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | با `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` روی سرور باید خطای دسترسی بدهد | +| کتابخانه‌ها بدونِ آسیب‌پذیریِ شناخته‌شده (`urllib3>=2.8.0`، `python-dotenv 1.2.2`) | پایگاهِ OSV روی `pip freeze` | diff --git a/docs/technical-handbook.md b/docs/technical-handbook.md index d197ae6..b8f9447 100644 --- a/docs/technical-handbook.md +++ b/docs/technical-handbook.md @@ -932,7 +932,7 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ← ### ۱۴-۳ مقیاسِ اجرا: کجا گلوگاه است و بعد چه -وضعِ فعلی: ۳ worker × ۳۲ نخ = ۹۶ درخواستِ هم‌زمان (تا ۲۰۲۶-۱۰-۰۴ ۸ نخ بود)؛ درخواستِ عادی چند ده میلی‌ثانیه، جوابِ دستیار روی سرور میانه‌ی ۵ تا ۸ ثانیه و گاهی ۴۰ ثانیه، که تمامش یک نخ را نگه می‌دارد. لودتستِ همان روز: با ۸ نخ از حدودِ ۲۰ پرسشِ هم‌زمان صفحه‌های عادی چند ثانیه صف کشیدند؛ با ۳۲ نخ تا ۷۵ پرسش نه. توکن stateless است، پس هر تعداد نسخه‌ی دیگر از بک‌اند پشتِ nginx بی‌تغییرِ کد کار می‌کند — به شرطِ دیتابیس و کشِ مشترک. +وضعِ فعلی: ۳ worker × ۳۲ نخ = ۹۶ درخواستِ هم‌زمان؛ درخواستِ عادی چند ده میلی‌ثانیه، جوابِ دستیار روی سرور میانه‌ی ۵ تا ۸ ثانیه و گاهی ۴۰ ثانیه، که تمامش یک نخ را نگه می‌دارد. در لودتست تا ۷۵ پرسشِ هم‌زمان صفحه‌های عادی صف نکشیدند و از ۱۰۰ به بعد کشیدند. توکن stateless است، پس هر تعداد نسخه‌ی دیگر از بک‌اند پشتِ nginx بی‌تغییرِ کد کار می‌کند — به شرطِ دیتابیس و کشِ مشترک. | # | گلوگاه | راه‌حلِ ساده‌ی بعدی | نیاز | |---|---|---|---| diff --git a/gunicorn.conf.py b/gunicorn.conf.py index 2d78164..f4ca5c3 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -15,13 +15,12 @@ gthread هر worker را چند نخی می‌کند: ۳ worker × ۸ نخ = ۲ تحمل می‌کند. timeout در gthread به ضربانِ خودِ worker است، نه طولِ هر درخواست، پس استریمِ طولانی دیگر کشته نمی‌شود. -۸ → ۳۲ نخ (۲۰۲۶-۱۰-۰۴، لودتست): هر جوابِ دستیار یک نخ را تا آخرِ جواب نگه می‌دارد -(روی سرور میانه ۵ و p90 ۱۳ ثانیه). با ۳×۸ از حدودِ ۲۰ پرسشِ هم‌زمان، صفحه‌های عادیِ اپ -پشتِ استریم‌ها صف کشیدند (تا ۲۳ ثانیه در ۵۰ پرسش؛ فرانت بعد از ۱۵ ثانیه خطا می‌دهد)، چون -gthread اتصال را حتی وقتی همه‌ی نخ‌هایش مشغول‌اند می‌پذیرد و workerها یکنواخت پر نمی‌شوند. -با ۳×۳۲ تا ۷۵ پرسشِ هم‌زمان صفحه‌ها زیرِ ۱۱۰ میلی‌ثانیه ماندند. این روی لپ‌تاپ با مدل و -Qdrantِ شبیه‌سازی‌شده سنجیده شد؛ CPUِ مشترکِ سرور و رفتارِ GapGPT زیرِ این بار جدا -سنجیده نشده‌اند. نخِ منتظرِ شبکه تقریباً هزینه ندارد؛ هر نخ اتصالِ SQLiteِ خودش را دارد. +۳۲ نخ: هر جوابِ دستیار یک نخ را تا آخرِ جواب نگه می‌دارد (روی سرور میانه‌ی ۳ تا ۶ ثانیه، +گاهی ۴۰). gthread اتصال را حتی وقتی همه‌ی نخ‌هایش مشغول‌اند می‌پذیرد و workerها یکنواخت پر +نمی‌شوند، پس با نخِ کم، صفحه‌های عادیِ اپ پشتِ استریم‌های دستیار صف می‌کشند. در لودتستِ +۲۰۲۶-۱۰-۰۴ (لپ‌تاپ، مدل و Qdrantِ شبیه‌سازی‌شده با زمان‌بندیِ سرور) ۳×۳۲ تا ۷۵ پرسشِ هم‌زمان +صفحه‌ها را زیرِ ۰٫۱ ثانیه نگه داشت و از ۱۰۰ به بعد صف پیدا شد. CPUِ مشترکِ سرور در این بار +سنجیده نشده. نخِ منتظرِ شبکه تقریباً هزینه ندارد؛ هر نخ اتصالِ SQLiteِ خودش را دارد. """ worker_class = 'gthread' @@ -39,10 +38,9 @@ max_requests_jitter = 200 def post_worker_init(worker): """ کتابخانه‌های Qdrant و OpenAI را همان لحظه‌ی بالا آمدنِ worker بار می‌کند، نه سرِ اولین - سوالِ دستیار. در لودتستِ ۲۰۲۶-۱۰-۰۴ اولین دورِ پرسش بعد از ری‌استارت روی سرور ~۶ ثانیه - پیش از رسیدن به مدل معطل ماند و دورهای بعد ~۱ ثانیه؛ روی لپ‌تاپ همین تفاوت ۱٫۷ ثانیه بود - (import ِ qdrant_client به‌تنهایی ~۰٫۷ ثانیه). با max_requests هر worker گاه‌به‌گاه از نو - بالا می‌آید، پس بدونِ این هر چند هزار درخواست یک دانش‌آموز همین تأخیر را می‌دید. + سوالِ دستیار. بدونِ این، اولین پرسش‌ها بعد از هر شروعِ worker (ری‌استارت، یا تعویض با + max_requests) چند ثانیه پیش از رسیدن به مدل معطل می‌مانند: روی سرور ~۶ ثانیه در برابرِ ~۱، + روی لپ‌تاپ ۱٫۷ ثانیه (import ِ qdrant_client به‌تنهایی ~۰٫۷). ساختنِ کلاینت تماسِ شبکه‌ای ندارد. شکستِ این گرم‌کردن نباید worker را بخواباند (کلِ اپ به‌خاطرِ یک بهینه‌سازی پایین نمی‌آید)؛ ثبت می‌شود و اولین سوال همان مسیرِ عادی را می‌رود. diff --git a/payment/services.py b/payment/services.py index 5a5aee8..5d557e1 100644 --- a/payment/services.py +++ b/payment/services.py @@ -5,6 +5,7 @@ پیاده‌سازی شده، نه یک HTTP call به خودمون. """ from django.conf import settings +from django.core import signing from django.db import IntegrityError from django.db import transaction as db_transaction from django.utils import timezone @@ -53,7 +54,29 @@ def call_payment_gateway(order: Order) -> str: باری که کاربر واقعاً این URL را باز کند زده می‌شود (نگاه کنید به payment/zarinpal.py و PaymentRedirectView در views.py). """ - return f'{settings.PUBLIC_BASE_URL}/api/payment/orders/{order.id}/pay' + return f'{settings.PUBLIC_BASE_URL}/api/payment/orders/{order.id}/pay?t={pay_link_token(order.id)}' + + +# لینکِ پرداخت بی‌توکنِ ورود باز می‌شود (مرورگر هدرِ Bearer نمی‌فرستد) و شناسه‌ی سفارش ترتیبی +# است؛ امضای کوتاه‌عمر جلوی این را می‌گیرد که غریبه با پیمایشِ شناسه‌ها روی سفارشِ دیگران +# authority بسازد (و کدِ تخفیفِ صاحبش را ببندد) یا وضعیتِ سفارششان را از ریدایرکت بخواند. +# امضا با همان DJANGO_SECRET_KEY است؛ تا وقتی آن کلید روی سرور همان کلیدِ داخلِ ریپوست، +# این امضا هم برای کسی که ریپو را دیده جعل‌شدنی است. یک روز: لینک بلافاصله بعد از ساختِ +# سفارش باز می‌شود؛ یک روز صفحه‌ای را هم که باز مانده پوشش می‌دهد. +PAY_LINK_SALT = 'tadrisino-pay-link' +PAY_LINK_MAX_AGE_SECONDS = 24 * 3600 + + +def pay_link_token(order_id: int) -> str: + return signing.TimestampSigner(salt=PAY_LINK_SALT).sign(str(order_id)) + + +def pay_link_is_valid(order_id: int, token: str) -> bool: + try: + value = signing.TimestampSigner(salt=PAY_LINK_SALT).unsign(token, max_age=PAY_LINK_MAX_AGE_SECONDS) + except signing.BadSignature: # SignatureExpired هم زیرکلاسِ همین است + return False + return value == str(order_id) def create_order(*, user_id: int, amount: int, currency: str = 'IRT', diff --git a/payment/tests.py b/payment/tests.py index dd1fb66..3b0382b 100644 --- a/payment/tests.py +++ b/payment/tests.py @@ -1,5 +1,6 @@ import importlib import os +import time from unittest.mock import MagicMock, patch from django.core.cache import cache @@ -14,7 +15,7 @@ from . import bazaar, zarinpal from .bazaar import BazaarPurchaseResult from .models import Order, Transaction from .permissions import HasInternalServiceKey -from .services import call_payment_gateway +from .services import PAY_LINK_MAX_AGE_SECONDS, call_payment_gateway, pay_link_token class SecretDefaultsFailClosedTests(TestCase): @@ -467,7 +468,13 @@ class CallPaymentGatewayTests(TestCase): url = call_payment_gateway(order) - self.assertEqual(url, f'https://api.tadrisino.ir/api/payment/orders/{order.id}/pay') + self.assertTrue(url.startswith(f'https://api.tadrisino.ir/api/payment/orders/{order.id}/pay?t=')) + + +def _pay_path(order): + """همان paymentUrlی که کلاینت می‌گیرد، بی‌دامنه.""" + url = call_payment_gateway(order) + return url[url.index('/api/'):] class PaymentRedirectViewTests(TestCase): @@ -495,14 +502,14 @@ class PaymentRedirectViewTests(TestCase): mock_request_payment.return_value = zarinpal.ZarinpalRequestResult( authority='A-ref', payment_url='https://www.zarinpal.com/pg/StartPay/A-ref', ) - response = self.client.get(f'/api/payment/orders/{self.order.id}/pay') + response = self.client.get(_pay_path(self.order)) self.assertEqual(response.status_code, 302) self.assertEqual(response['Referrer-Policy'], 'strict-origin-when-cross-origin') self.assertEqual(self.client.get('/api/health')['Referrer-Policy'], 'same-origin') @override_settings(ZARINPAL_MERCHANT_ID='', ZARINPAL_CALLBACK_URL='') def test_unconfigured_gateway_returns_503(self): - response = self.client.get(f'/api/payment/orders/{self.order.id}/pay') + response = self.client.get(_pay_path(self.order)) self.assertEqual(response.status_code, 503) @override_settings( @@ -515,7 +522,7 @@ class PaymentRedirectViewTests(TestCase): authority='A-first', payment_url='https://www.zarinpal.com/pg/StartPay/A-first', ) - response = self.client.get(f'/api/payment/orders/{self.order.id}/pay') + response = self.client.get(_pay_path(self.order)) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-first') @@ -532,7 +539,7 @@ class PaymentRedirectViewTests(TestCase): self.order.gateway_authority = 'A-existing' self.order.save(update_fields=['gateway_authority']) - response = self.client.get(f'/api/payment/orders/{self.order.id}/pay') + response = self.client.get(_pay_path(self.order)) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-existing') @@ -543,13 +550,35 @@ class PaymentRedirectViewTests(TestCase): self.order.status = 'paid' self.order.save(update_fields=['status']) - response = self.client.get(f'/api/payment/orders/{self.order.id}/pay') + response = self.client.get(_pay_path(self.order)) self.assertEqual(response.status_code, 302) mock_request_payment.assert_not_called() + @patch('payment.views.zarinpal.request_payment') + def test_link_without_or_with_foreign_signature_is_404(self, mock_request_payment): + # شناسه‌ی سفارش ترتیبی است؛ غریبه نباید با پیمایشِ /orders//pay روی سفارشِ دیگران + # authority بسازد یا وضعیتش را از ریدایرکت بخواند. + other = Order.objects.create( + user_id=2, amount=100000, currency='IRT', provider='gateway', idempotency_key='pay-view-other', + ) + for path in ( + f'/api/payment/orders/{self.order.id}/pay', + f'/api/payment/orders/{self.order.id}/pay?t={pay_link_token(other.id)}', + f'/api/payment/orders/{self.order.id}/pay?t=garbage', + ): + self.assertEqual(self.client.get(path).status_code, 404, path) + mock_request_payment.assert_not_called() + self.order.refresh_from_db() + self.assertEqual(self.order.gateway_authority, '') + + def test_expired_link_is_404(self): + path = _pay_path(self.order) + with patch('django.core.signing.time.time', return_value=time.time() + PAY_LINK_MAX_AGE_SECONDS + 5): + self.assertEqual(self.client.get(path).status_code, 404) + def test_unknown_order_is_404(self): - response = self.client.get('/api/payment/orders/999999/pay') + response = self.client.get(f'/api/payment/orders/999999/pay?t={pay_link_token(999999)}') self.assertEqual(response.status_code, 404) def test_bazaar_order_is_404_on_gateway_pay_endpoint(self): @@ -557,7 +586,7 @@ class PaymentRedirectViewTests(TestCase): user_id=1, amount=100000, provider='bazaar', bazaar_product_sku='sku', idempotency_key='pay-view-bazaar-1', ) - response = self.client.get(f'/api/payment/orders/{bazaar_order.id}/pay') + response = self.client.get(_pay_path(bazaar_order)) self.assertEqual(response.status_code, 404) @@ -690,7 +719,7 @@ class PaymentReturnsToTheAppTests(TestCase): status='paid', idempotency_key='ret-2', ) - response = self.client.get(f'/api/payment/orders/{order.id}/pay') + response = self.client.get(_pay_path(order)) self.assertEqual(response.status_code, 302) self.assertEqual( diff --git a/payment/views.py b/payment/views.py index f8bff64..e376ea5 100644 --- a/payment/views.py +++ b/payment/views.py @@ -2,7 +2,7 @@ from urllib.parse import urlsplit from django.conf import settings from django.db import transaction -from django.http import HttpResponse, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseRedirect from django.utils.html import escape from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes @@ -25,7 +25,7 @@ from .serializers import ( OrderDetailResponseSerializer, ZarinpalCallbackResponseSerializer, ) -from .services import apply_order_payment_result, create_order, reconcile_pending_order +from .services import apply_order_payment_result, create_order, pay_link_is_valid, reconcile_pending_order def _redirect_to_configured_url(url: str) -> HttpResponseRedirect: @@ -142,10 +142,10 @@ class PaymentRedirectView(APIView): (مثلاً دکمه‌ی back مرورگر) همان authority قبلی را دوباره استفاده می‌کند، نه یک PaymentRequest تازه. - ⚠️ AllowAny است و مالکیتِ سفارش را نمی‌سنجد (مرورگر هدرِ Bearer نمی‌فرستد)؛ - شناسه‌ی سفارش ترتیبی است. هر کسی با باز کردنِ `/orders//pay` برای سفارشِ - دیگران authority می‌سازد و از آن به بعد صاحبِ سفارش دیگر نمی‌تواند کدِ - تخفیف بزند (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد). + AllowAny است چون مرورگر هدرِ Bearer نمی‌فرستد؛ به‌جایش پارامترِ `t`، امضای + کوتاه‌عمری که فقط در paymentUrlِ صاحبِ سفارش می‌آید (payment/services.py، + pay_link_token)، سنجیده می‌شود. بی‌امضا یا امضای سفارشِ دیگر همان ۴۰۴ِ سفارشِ + ناموجود را می‌گیرد تا وجودِ شناسه هم لو نرود. """ permission_classes = [AllowAny] @@ -164,11 +164,17 @@ class PaymentRedirectView(APIView): return response @extend_schema( + parameters=[OpenApiParameter( + 't', OpenApiTypes.STR, required=True, + description='امضای همراهِ paymentUrl؛ کلاینت خودش نمی‌سازد، همان آدرسِ کامل را باز می‌کند.', + )], responses={302: OpenApiTypes.NONE}, description='به درگاهِ زرین‌پال ریدایرکت می‌کند؛ بدنه‌ی JSON ندارد.', tags=['Payment (payment)'], ) def get(self, request, order_id): + if not pay_link_is_valid(order_id, request.query_params.get('t', '')): + raise Http404 order = get_object_or_404(Order, pk=order_id, provider='gateway') if order.status != 'pending': diff --git a/progress/tests.py b/progress/tests.py index c8c4c85..d003dda 100644 --- a/progress/tests.py +++ b/progress/tests.py @@ -269,7 +269,21 @@ class FourCompanionsCatalogTests(TestCase): class LeaderboardPeriodValidationTests(TestCase): - def test_unknown_period_is_a_400(self): + def setUp(self): + from accounts.authentication import issue_access_token + from accounts.models import User from rest_framework.test import APIClient - self.assertEqual(APIClient().get('/api/leaderboard?period=xyz').status_code, 400) - self.assertEqual(APIClient().get('/api/leaderboard?period=all_time').status_code, 200) + + self.client = APIClient() + user = User.objects.create(device_id='lb-dev', is_guest=True) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') + + def test_unknown_period_is_a_400(self): + self.assertEqual(self.client.get('/api/leaderboard?period=xyz').status_code, 400) + self.assertEqual(self.client.get('/api/leaderboard?period=all_time').status_code, 200) + + def test_requires_login(self): + # نامِ کاربران (بیشترشان کودک) نباید بی‌ورود قابلِ جمع‌آوری باشد؛ مهمان هم توکن دارد. + from rest_framework.test import APIClient + + self.assertEqual(APIClient().get('/api/leaderboard').status_code, 401) diff --git a/progress/tests_live_gamification.py b/progress/tests_live_gamification.py index 9aa265d..d632cb1 100644 --- a/progress/tests_live_gamification.py +++ b/progress/tests_live_gamification.py @@ -91,7 +91,7 @@ class ActivityFeedsStreakAndLeaderboardTests(TestCase): ) for period in ('weekly', 'all_time'): - entries = self.client.get(f'/api/leaderboard?period={period}').json()['entries'] + entries = self.client.get(f'/api/leaderboard?period={period}', **self.auth).json()['entries'] self.assertEqual(entries[0]['userId'], str(self.user.id), period) self.assertEqual(entries[0]['name'], 'سارا') self.assertEqual(entries[0]['xpInPeriod'], 30, period) diff --git a/progress/views.py b/progress/views.py index 8e0aaa1..a061395 100644 --- a/progress/views.py +++ b/progress/views.py @@ -127,8 +127,14 @@ LEADERBOARD_CACHE_TTL = 60 class LeaderboardView(APIView): - """GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه""" + """ + GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه + فقط برای کاربرِ واردشده (مهمان هم توکن دارد): پاسخ نامِ کاربران، که بیشترشان کودک‌اند، + و شناسه‌ی داخلی‌شان را دارد و بدونِ ورود برای هر کسی در اینترنت قابلِ جمع‌آوری بود. + """ + + permission_classes = [IsAuthenticated] throttle_scope = 'read' throttle_classes = [GuestAwareReadThrottle] diff --git a/requirements.txt b/requirements.txt index 55befd7..86bdd3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,11 +6,10 @@ requests==2.33.1 redis==5.2.1 python-dotenv==1.2.2 whitenoise==6.11.0 -# همان نسخه‌ای که سرور با آن اجرا می‌شود (یونیتِ systemd آن را از venv صدا می‌زند). تا -# ۲۰۲۶-۱۰-۰۴ در این فایل نبود و فقط دستی روی سرور نصب شده بود. +# سرور با همین اجرا می‌شود (یونیتِ systemd آن را از venv صدا می‌زند؛ تنظیمات در gunicorn.conf.py). gunicorn==26.2.0 -# وابستگیِ غیرمستقیمِ requests و qdrant-client. ۲.۷.۰ سه آسیب‌پذیریِ شناخته‌شده داشت -# (GHSA-8988-9cw3-xx77، GHSA-vxq7-64xx-v4gw، GHSA-gh4c-6fx4-qh6g؛ OSV، ۲۰۲۶-۱۰-۰۴). +# وابستگیِ غیرمستقیمِ requests و qdrant-client؛ نسخه‌های پایین‌تر آسیب‌پذیریِ شناخته‌شده دارند +# (GHSA-8988-9cw3-xx77، GHSA-vxq7-64xx-v4gw، GHSA-gh4c-6fx4-qh6g). urllib3>=2.8.0 # RAG / AI tutor (اپ rag). کلاینت‌ها lazy import می‌شن؛ نبودِ این‌ها فقط diff --git a/tools/loadtest/README.md b/tools/loadtest/README.md new file mode 100644 index 0000000..574dead --- /dev/null +++ b/tools/loadtest/README.md @@ -0,0 +1,41 @@ +# لودتستِ دستیار + +سه اسکریپت، برای اینکه «چند دانش‌آموزِ هم‌زمان» هر بار با یک روش سنجیده شود. خروجیِ هر اجرا یک +JSON است با زمانِ هر پرسش و هر درخواستِ صفحه‌ی عادی؛ جمع‌بندی در خودِ خروجیِ ترمینال چاپ می‌شود. + +| فایل | کار | +|---|---| +| `mkusers.py` | کاربرانِ آزمایشیِ غیرمهمان (شماره‌ی جعلیِ `0900…`، بی‌پیامک، بی‌سیگنال) با توکن و سه سوالِ متفاوت از بانکِ سوال برای هر کدام | +| `loadtest.py` | N نفر هم‌زمان می‌پرسند (استریم، دقیقاً مثلِ فرانت) و B نفرِ دیگر در خانه/آزمون/خلاصه/سهمیه می‌چرخند | +| `fake_upstream.py` | جایگزینِ GapGPT و Qdrant برای آزمایشگاه: embedding، جوابِ استریم با زمانِ لاگ‌نرمال (پیش‌فرض: میانه‌ی ۵٫۲ و صدکِ ۹۰ِ ۱۳ ثانیه، از جواب‌های واقعیِ سرور) و جست‌وجوی Qdrant | + +## آزمایشگاه (بی‌هزینه، روی لپ‌تاپ) + +روی یک کپی از پروژه اجرا کنید تا دیتابیسِ اصلیِ لوکال پر نشود. Qdrant هم شبیه‌سازی می‌شود، چون +از لپ‌تاپ پهنای باندِ بالارفتن (هر بردار ~۳۰ کیلوبایت) جست‌وجوها را سریالی نشان می‌دهد؛ از خودِ +سرور ۲۰ جست‌وجوی هم‌زمان ۰٫۲۶ ثانیه است. + +```bash +python tools/loadtest/fake_upstream.py & # :8901 (FAKE_SIGMA=0 یعنی همیشه ۵٫۲ ثانیه) +export DJANGO_DEBUG=0 CACHE_URL= NUM_PROXIES=0 AI_DAILY_QUOTA_FREE=1000 HEARTS_ENFORCED=0 RAG_FORCE_MOCK=0 \ + DJANGO_ALLOWED_HOSTS=127.0.0.1 OPENAI_BASE_URL=http://127.0.0.1:8901/v1 OPENAI_API_KEY=x \ + QDRANT_URL=http://127.0.0.1:8901 QDRANT_API_KEY=x +LT_N=150 python manage.py shell -c "exec(open('tools/loadtest/mkusers.py').read())" | grep ^LTJSON | cut -c7- > users.json +gunicorn --bind 127.0.0.1:8800 config.wsgi:application & # همان gunicorn.conf.py ِ سرور +python tools/loadtest/loadtest.py http://127.0.0.1:8800 users.json 50 10 0 out.json --writes +``` + +پیش از هر اجرا gunicorn را از نو بالا بیاورید (کشِ جوابِ هر worker خالی شود) و مطمئن شوید پروسه‌ی +قبلی واقعاً رفته است؛ وگرنه سوال‌های تکراری از کش در ۰٫۱ ثانیه جواب می‌گیرند و عدد دروغ می‌گوید. + +## سرورِ اصلی (هزینه‌ی واقعی: ~۰٫۱۸ سنت برای هر جواب) + +۱. بکاپ از دیتابیس (`docs/operations.md`). ۲. کاربرانِ آزمایشی را روی سرور با `mkusers.py` بسازید +(`LT_PREFIX` یک پیشوندِ جعلی که روی سرور نیست) و خروجی را فقط در یک فایلِ 600 نگه دارید. +۳. `loadtest.py` را با `https://mathapi.tadrisino2.ir` و بدونِ `--writes` اجرا کنید. +۴. زمانِ داخلِ GapGPT برای هر پیام در `ai_tutor.Message.latency_ms` است؛ اختلافِ آن با زمانِ کلاینت +یعنی کارِ سرورِ ما و صفِ انتظار برای نخِ آزاد. ۵. `manage.py delete_users --ids … --yes` و پاک کردنِ +فایلِ توکن‌ها. + +سقفِ ساعتیِ دستیار برای مهمان‌ها بر اساسِ IP است؛ با کاربرِ مهمان از یک ماشین بیش از ~۲۰ پرسش در +ساعت ممکن نیست، برای همین `mkusers.py` کاربرِ غیرمهمان می‌سازد. diff --git a/tools/loadtest/fake_upstream.py b/tools/loadtest/fake_upstream.py new file mode 100644 index 0000000..b950ac9 --- /dev/null +++ b/tools/loadtest/fake_upstream.py @@ -0,0 +1,130 @@ +""" +Fake GapGPT + Qdrant for load testing (embeddings, streaming chat, Qdrant points/query). Local only. + +Timing profile is calibrated from the last 54 real assistant answers on the server +(Message.latency_ms, gpt-4.1-mini via GapGPT): median 5.2 s, p90 13.1 s, max 30 s. +Lognormal(median=FAKE_MEDIAN, sigma=FAKE_SIGMA), capped at FAKE_CAP. + +FAKE_MODE=buffered -> wait the whole latency, then emit all chunks (what GapGPT was + observed to do: first chunk arrives with the last). +FAKE_MODE=stream -> first token after 20% of latency, rest spread over the remainder. +""" +import json +import math +import os +import random +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +MEDIAN = float(os.environ.get('FAKE_MEDIAN', '5.2')) +SIGMA = float(os.environ.get('FAKE_SIGMA', '0.715')) +CAP = float(os.environ.get('FAKE_CAP', '30')) +MODE = os.environ.get('FAKE_MODE', 'buffered') +EMB_DELAY = float(os.environ.get('FAKE_EMB_DELAY', '0.35')) +QDRANT_DELAY = float(os.environ.get('FAKE_QDRANT_DELAY', '0.08')) +PORT = int(os.environ.get('FAKE_PORT', '8901')) + +ANSWER = ('برای حلِ این مسئله اول داده‌ها را می‌نویسیم. ' + 'بعد قدم‌به‌قدم جلو می‌رویم و هر مرحله را وارسی می‌کنیم. ') * 14 + '✅ جواب: ۱۲' + +active = 0 +peak = 0 +lock = threading.Lock() + + +class H(BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + + def log_message(self, *a): + pass + + def _json(self, obj): + body = json.dumps(obj).encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + self._json({'active': active, 'peak': peak}) + + def do_POST(self): + global active, peak + n = int(self.headers.get('Content-Length') or 0) + req = json.loads(self.rfile.read(n) or b'{}') + if '/points/query' in self.path: + time.sleep(QDRANT_DELAY) + pts = [{'id': i + 1, 'version': 1, 'score': 0.5 - i * 0.01, + 'payload': {'lesson_title': 'درس نمونه', 'problem': 'مسئله‌ی نمونه ' * 30, + 'generalSolution': 'روشِ کلی ' * 40, 'solution': 'راه‌حل ' * 60}} + for i in range(int(req.get('limit') or 4))] + self._json({'result': {'points': pts}, 'status': 'ok', 'time': 0.001}) + return + if self.path.endswith('/embeddings'): + time.sleep(EMB_DELAY) + vec = [random.uniform(-1, 1) for _ in range(1536)] + norm = math.sqrt(sum(v * v for v in vec)) + self._json({'object': 'list', 'model': req.get('model'), + 'data': [{'object': 'embedding', 'index': 0, 'embedding': [v / norm for v in vec]}], + 'usage': {'prompt_tokens': 20, 'total_tokens': 20}}) + return + with lock: + active += 1 + peak = max(peak, active) + try: + latency = min(CAP, MEDIAN * math.exp(random.gauss(0, SIGMA))) + words = ANSWER.split(' ') + pieces = [' '.join(words[i:i + 5]) + ' ' for i in range(0, len(words), 5)] + if not req.get('stream'): + time.sleep(latency) + self._json({'id': 'x', 'object': 'chat.completion', 'created': int(time.time()), + 'model': req.get('model'), + 'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': ANSWER}, + 'finish_reason': 'stop'}], + 'usage': {'prompt_tokens': 1400, 'completion_tokens': 400, 'total_tokens': 1800}}) + return + self.send_response(200) + self.send_header('Content-Type', 'text/event-stream') + self.send_header('Transfer-Encoding', 'chunked') + self.end_headers() + + def send(obj): + data = f'data: {json.dumps(obj, ensure_ascii=False)}\n\n'.encode() + self.wfile.write(f'{len(data):x}\r\n'.encode() + data + b'\r\n') + self.wfile.flush() + + def chunk(text, finish=None): + return {'id': 'x', 'object': 'chat.completion.chunk', 'created': int(time.time()), + 'model': req.get('model'), + 'choices': [{'index': 0, 'delta': {'content': text} if text else {}, + 'finish_reason': finish}]} + + if MODE == 'buffered': + time.sleep(latency) + gap = 0.0 + else: + time.sleep(latency * 0.2) + gap = latency * 0.8 / len(pieces) + for p in pieces: + send(chunk(p)) + if gap: + time.sleep(gap) + send(chunk('', 'stop')) + send({'id': 'x', 'object': 'chat.completion.chunk', 'created': int(time.time()), + 'model': req.get('model'), 'choices': [], + 'usage': {'prompt_tokens': 1400, 'completion_tokens': 400, 'total_tokens': 1800}}) + last = b'data: [DONE]\n\n' + self.wfile.write(f'{len(last):x}\r\n'.encode() + last + b'\r\n0\r\n\r\n') + self.wfile.flush() + finally: + with lock: + active -= 1 + + +if __name__ == '__main__': + ThreadingHTTPServer.daemon_threads = True + ThreadingHTTPServer.request_queue_size = 256 + print(f'fake llm on :{PORT} mode={MODE} median={MEDIAN}', flush=True) + ThreadingHTTPServer(('127.0.0.1', PORT), H).serve_forever() diff --git a/tools/loadtest/loadtest.py b/tools/loadtest/loadtest.py new file mode 100644 index 0000000..a7ce33e --- /dev/null +++ b/tools/loadtest/loadtest.py @@ -0,0 +1,128 @@ +""" +Load test: N students ask the assistant at the same moment (stream=true, exactly like the +frontend), while B other students keep browsing the app (home, quiz, summary, quota). + +usage: python loadtest.py BASE_URL users.json N_ASKERS N_BROWSERS ROUND OUT.json [--stagger S] + users.json: output of mkusers.py; the first N_ASKERS users ask, the next N_BROWSERS browse. + ROUND: which of each user's prepared questions to ask (0..2) — distinct text, so no answer-cache hits. +Timing per ask: t_headers (HTTP status line), t_first (first `chunk` event), t_done (`done` event). +""" +import asyncio +import json +import sys +import time + +import httpx + +BASE, USERS, N, B, ROUND, OUT = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]), int(sys.argv[5]), sys.argv[6] +WRITES = '--writes' in sys.argv +STAGGER = float(sys.argv[sys.argv.index('--stagger') + 1]) if '--stagger' in sys.argv else 0.0 +users = json.load(open(USERS)) +askers, browsers = users[:N], users[N:N + B] +T0 = None +stop = asyncio.Event() + + +async def ask(client, u, idx): + await asyncio.sleep(idx * STAGGER) + a = u['asks'][ROUND] + rec = {'userId': u['userId'], 'grade': u['grade'], 'start': time.monotonic() - T0} + t = time.monotonic() + try: + async with client.stream('POST', f'{BASE}/api/ai/ask', json={ + 'userQuestion': a['question'], 'lessonId': str(a['lessonId']), 'stream': True, + }, headers={'Authorization': f"Bearer {u['token']}"}, timeout=httpx.Timeout(180, connect=30)) as r: + rec['status'] = r.status_code + rec['t_headers'] = time.monotonic() - t + if r.status_code != 200: + rec['body'] = (await r.aread()).decode(errors='replace')[:300] + return rec + buf, nchunks = '', 0 + async for text in r.aiter_text(): + buf += text + while '\n\n' in buf: + part, buf = buf.split('\n\n', 1) + ev = next((l[7:] for l in part.split('\n') if l.startswith('event: ')), '') + if ev == 'chunk': + nchunks += 1 + rec.setdefault('t_first', time.monotonic() - t) + elif ev == 'done': + rec['t_done'] = time.monotonic() - t + d = json.loads(next(l[6:] for l in part.split('\n') if l.startswith('data: '))) + rec['chunksUsed'] = len(d.get('usedChunks') or []) + rec['messageId'] = d.get('messageId') + elif ev == 'error': + rec['error'] = part[-200:] + rec['nchunks'] = nchunks + rec['t_end'] = time.monotonic() - t + except Exception as exc: # recorded, never hidden: a timeout is a result here + rec['exc'] = f'{type(exc).__name__}: {exc}'[:200] + rec['t_end'] = time.monotonic() - t + return rec + + +async def browse(client, u): + recs = [] + paths = ['/api/home', f"/api/nodes/{u['nodeId']}/quiz", '/api/user/summary', '/api/ai/quota'] + if WRITES: + paths.append('answer') + quiz = None + i = 0 + while not stop.is_set(): + p = paths[i % len(paths)] + i += 1 + t = time.monotonic() + try: + if p == 'answer': + q = quiz['questions'][i % len(quiz['questions'])] + r = await client.post(f"{BASE}/api/questions/{q['id']}/answer", json={'selectedOptionId': int(q['options'][0]['id'])}, + headers={'Authorization': f"Bearer {u['token']}"}, timeout=60) + p = '/x/answer' + else: + r = await client.get(f'{BASE}{p}', headers={'Authorization': f"Bearer {u['token']}"}, timeout=60) + if '/quiz' in p and r.status_code == 200: + quiz = r.json() + recs.append({'path': p.split('/')[2], 'status': r.status_code, 'dt': time.monotonic() - t, 'at': t - T0}) + except Exception as exc: + recs.append({'path': p.split('/')[2], 'status': 0, 'exc': type(exc).__name__, 'dt': time.monotonic() - t, 'at': t - T0}) + await asyncio.sleep(1.0) + return recs + + +async def main(): + global T0 + limits = httpx.Limits(max_connections=200, max_keepalive_connections=200) + async with httpx.AsyncClient(limits=limits, http2=False) as client: + T0 = time.monotonic() + btasks = [asyncio.create_task(browse(client, u)) for u in browsers] + await asyncio.sleep(3 if browsers else 0) # baseline browsing before the burst + burst_at = time.monotonic() - T0 + results = await asyncio.gather(*(ask(client, u, i) for i, u in enumerate(askers))) + burst_end = time.monotonic() - T0 + await asyncio.sleep(2 if browsers else 0) + stop.set() + brecs = [r for t in btasks for r in await t] + json.dump({'base': BASE, 'n': N, 'browsers': B, 'round': ROUND, 'stagger': STAGGER, + 'burst_at': burst_at, 'burst_end': burst_end, 'asks': results, 'browse': brecs}, + open(OUT, 'w'), ensure_ascii=False, indent=1) + ok = [r for r in results if r.get('t_done')] + td = sorted(r['t_done'] for r in ok) + tf = sorted(r['t_first'] for r in ok if r.get('t_first')) + q = lambda a, p: a[min(len(a) - 1, int(p * len(a)))] if a else None + print(f'N={N} ok={len(ok)}/{N} done p50={q(td, .5) and round(q(td, .5), 1)} p90={q(td, .9) and round(q(td, .9), 1)} ' + f'max={td and round(td[-1], 1)} first p50={q(tf, .5) and round(q(tf, .5), 1)}') + bad = [r for r in results if not r.get('t_done')] + for r in bad: + print(' FAIL', {k: r.get(k) for k in ('userId', 'status', 'body', 'error', 'exc', 't_end')}) + if brecs: + during = sorted(r['dt'] for r in brecs if burst_at <= r['at'] <= burst_end) + before = sorted(r['dt'] for r in brecs if r['at'] < burst_at) + errs = [r for r in brecs if r['status'] != 200] + w = sorted(r['dt'] for r in brecs if r['path'] == 'answer' and burst_at <= r['at'] <= burst_end) + if w: + print(f' writes during: n={len(w)} p50={round(q(w, .5) * 1000)}ms max={round(w[-1] * 1000)}ms statuses={sorted(set(r["status"] for r in brecs if r["path"] == "answer"))}') + print(f' browse before p50={q(before, .5) and round(q(before, .5) * 1000)}ms | during p50={q(during, .5) and round(q(during, .5) * 1000)}ms ' + f'p90={q(during, .9) and round(q(during, .9) * 1000)}ms max={during and round(during[-1] * 1000)}ms n={len(during)} errors={len(errs)}') + + +asyncio.run(main()) diff --git a/tools/loadtest/mkusers.py b/tools/loadtest/mkusers.py new file mode 100644 index 0000000..4b4135e --- /dev/null +++ b/tools/loadtest/mkusers.py @@ -0,0 +1,30 @@ +# Run with: manage.py shell -c "exec(open('tools/loadtest/mkusers.py').read())" env: LT_N, LT_PREFIX, LT_SEED +# Creates N non-guest test users (fake 0900… numbers, no signals, no SMS) and prints +# [{userId, token, grade, asks:[{lessonId, question}], nodeId, questionIds}] as JSON. +import json +import os +import random + +from accounts.authentication import issue_access_token +from accounts.models import User +from content.models import Lesson, Question + +N = int(os.environ['LT_N']) +PREFIX = os.environ.get('LT_PREFIX', '0900990') +GRADES = [8, 9, 10, 11, 12, 4, 5, 6, 7, 3] +rnd = random.Random(int(os.environ.get('LT_SEED', '7'))) + +out = [] +for i in range(N): + grade = GRADES[i % len(GRADES)] + phone = f'{PREFIX}{i:04d}' + user, _ = User.objects.get_or_create(phone=phone, defaults={'is_guest': False, 'name': f'تست بار {i}', 'grade_id': grade}) + qs = list(Question.objects.filter(lesson__node__unit__grade_id=grade).select_related('lesson__node') + .values_list('id', 'prompt', 'lesson_id', 'lesson__node_id')[:4000]) + picks = rnd.sample(qs, 6) + out.append({ + 'userId': user.id, 'token': issue_access_token(user.id), 'grade': grade, + 'asks': [{'lessonId': p[2], 'question': f'این سوال رو قدم به قدم توضیح بده: {p[1][:300]}'} for p in picks[:3]], + 'nodeId': picks[3][3], 'answerQuestionIds': [p[0] for p in picks[3:]], + }) +print('LTJSON' + json.dumps(out, ensure_ascii=False))