diff --git a/.env.example b/.env.example index ddce9a5..9caafb9 100644 --- a/.env.example +++ b/.env.example @@ -100,8 +100,10 @@ BAZAAR_REFRESH_TOKEN= # ═══════════════════════════════════════════════════════════════════════ # ۶) اختیاری — پیش‌فرض‌ها معمولاً کافی‌اند # ═══════════════════════════════════════════════════════════════════════ -# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی روی سرور = آن مسیرها بسته‌اند. +# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی = آن مسیرها بسته‌اند (در DEBUG هم پیش‌فرضی ندارد). # INTERNAL_SERVICE_KEY= +# مهمان‌های نمایشی که با «حذفِ حساب» پاک نمی‌شوند (با کاما). پیش‌فرض: frontend-test-device. +# DEMO_GUEST_DEVICE_IDS= # با صفر قلب جلوی جواب دادن گرفته شود؟ پیش‌فرض روشن (از ۲۰۲۶-۱۰-۰۲)؛ ۰ خاموشش می‌کند. # HEARTS_ENFORCED=1 # سقف‌های کدِ ورود: هر شماره در روز، و کلِ سامانه در روز (ضدِ SMS pumping). diff --git a/.gitignore b/.gitignore index 4ec2cd5..71cff48 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ data/textbooks/* !data/textbooks/README.md # کلیدِ امضای تولیدشده‌ی خودکار (هرگز کامیت نشود) -.django_secret_key +.django_secret_key* # خروجیِ collectstatic — تولیدی است، در هر دیپلوی از نو ساخته می‌شود. staticfiles/ diff --git a/CLAUDE.md b/CLAUDE.md index fb9ce58..647abbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,9 @@ default hosts/CORS/cache/secret key (`/api/health` warns); `LocMemCache` without OAuth path beside the Pishkhan token (server still uses OAuth); `birthYear` alone in `PATCH /api/user/profile` (frontend type still allows it); `totalQuestions` accepted but ignored (frontend sends it); orders with no `plan_id` from before 2026-09-25 (none -paid on the server on 2026-10-03). +paid on the server on 2026-10-03); orders without the `question_credits`/`hearts_days`/ +`duration_days` snapshot (`payment.0011`) grant and refund from the plan's current values; +`DEMO_GUEST_DEVICE_IDS` defaults to `frontend-test-device` (remove with the demo data). ## Before any real user: read `docs/security-before-production.md` diff --git a/accounts/admin.py b/accounts/admin.py index d26d9c2..a010794 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -49,8 +49,27 @@ class SendSMSForm(forms.Form): return cleaned_data +class DeviceIdForSuperuserOnly: + """ + شناسه‌ی دستگاه برای مهمان تنها رمزِ ورود است (`POST /api/auth/session`)؛ پس فقط ابرکاربر + آن را در فهرست، جستجو و فرم می‌بیند، نه کارمندی با اجازه‌ی دیدن (گروهِ Supervisor). + """ + + def get_list_display(self, request): + cols = super().get_list_display(request) + return cols if request.user.is_superuser else tuple(c for c in cols if c != 'device_id') + + def get_search_fields(self, request): + cols = super().get_search_fields(request) + return cols if request.user.is_superuser else tuple(c for c in cols if c != 'device_id') + + def get_exclude(self, request, obj=None): + exclude = tuple(super().get_exclude(request, obj) or ()) + return exclude if request.user.is_superuser else exclude + ('device_id',) + + @admin.register(User) -class UserAdmin(admin.ModelAdmin): +class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin): list_display = ('id', 'public_id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at') search_fields = ('name', 'phone', 'device_id') @@ -165,7 +184,7 @@ class ProfileAdmin(admin.ModelAdmin): @admin.register(Session) -class SessionAdmin(admin.ModelAdmin): +class SessionAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin): list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked') list_filter = ('revoked',) diff --git a/accounts/management/commands/delete_users.py b/accounts/management/commands/delete_users.py index 12c3cea..a313637 100644 --- a/accounts/management/commands/delete_users.py +++ b/accounts/management/commands/delete_users.py @@ -68,15 +68,20 @@ def plan(user_ids): return counts -def delete_users(user_ids, keep=()): - """keep: برچسبِ مدل‌هایی که نمی‌مانند پاک شوند (مثلاً 'payment.Order' برای حذفِ حسابِ خودِ کاربر).""" +def delete_users(user_ids, keep=(), delete_otp_codes=True): + """ + keep: برچسبِ مدل‌هایی که نباید پاک شوند (مثلاً 'payment.Order' برای حذفِ حسابِ خودِ کاربر). + delete_otp_codes: این دستور (کارِ ادمین) کدهای شماره را پاک می‌کند تا آن شماره از صفر شروع کند؛ + حذفِ حساب به دستِ خودِ کاربر این کار را نمی‌کند تا سقفِ پیامکِ شماره دور زده نشود. + """ phones = list(User.objects.filter(pk__in=user_ids).exclude(phone=None).values_list('phone', flat=True)) with transaction.atomic(): for model, column in user_scoped_models(): if model._meta.label in keep: continue model.objects.filter(**{f'{column}__in': user_ids}).delete() - OTPCode.objects.filter(phone__in=phones).delete() + if delete_otp_codes: + OTPCode.objects.filter(phone__in=phones).delete() User.objects.filter(pk__in=user_ids).delete() diff --git a/accounts/serializers.py b/accounts/serializers.py index 036def7..3b34276 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -5,16 +5,23 @@ from rest_framework import serializers from .phone import normalize_phone # لینک، آیدیِ شبکه‌ی اجتماعی، و هر رشته‌ی ۵ رقمی یا بیشتر (شماره‌ی تلفن با -# فاصله/خط‌تیره هم) — ارقامِ فارسی و عربی هم. +# فاصله/خط‌تیره هم) — ارقامِ فارسی و عربی هم. این نام در رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود. _CONTACT = re.compile( r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+' - r'|(?:[0-9۰-۹٠-٩][\s\-.]?){5,})', + r'|(?:[0-9۰-۹٠-٩][\s\-._/\\|,،]*){5,})', + re.IGNORECASE, +) +# «ig: name»، «insta name»، «آیدی name» و مانندش: کلیدواژه و هر چه بعدش آمده حذف می‌شود. +_HANDLE = re.compile( + r'(?:\b(?:ig|insta(?:gram)?|tele(?:gram)?|tg|id|whats ?app|rubika|eitaa|bale)\b' + r'|اینستا\S*|(?:تلگرام|تل|آیدی|ایدی|واتساپ|روبیکا|ایتا)(?!\w)|بله\s*:)\s*[::]?.*', re.IGNORECASE, ) def clean_display_name(value: str) -> str: - return ' '.join(_CONTACT.sub(' ', value or '').split())[:30] + value = _HANDLE.sub(' ', value or '') + return ' '.join(_CONTACT.sub(' ', value).split())[:30] class IranMobileField(serializers.CharField): diff --git a/accounts/services.py b/accounts/services.py index 2adfeb2..446865d 100644 --- a/accounts/services.py +++ b/accounts/services.py @@ -134,8 +134,10 @@ def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None: 'phone_suffix=%s method=%s', _phone_suffix(phone), method, ) raise SMSDeliveryUncertain('پاسخِ سرویسِ پیامک دیر رسید.') - except requests.RequestException: - logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone)) + except requests.RequestException as exc: + # فقط نوعِ خطا: متنِ خطای requests آدرسِ کامل را دارد و کلیدِ API بخشی از همان آدرس است. + logger.error('SMS request to provider failed: phone_suffix=%s method=%s error=%s', + _phone_suffix(phone), method, type(exc).__name__) raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.') if response.status_code != 200: diff --git a/accounts/tests_security_review.py b/accounts/tests_security_review.py new file mode 100644 index 0000000..6e82abb --- /dev/null +++ b/accounts/tests_security_review.py @@ -0,0 +1,179 @@ +""" +حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (هویت و حساب). هر تست رفتاری را نگه می‌دارد که پیش‌تر غلط بود. +""" +import logging +import socket +from unittest import mock + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.core.cache import cache +from django.core.management import call_command +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import OTPCode, User +from config.log_formatters import JSONLogFormatter + + +def _bearer(token): + c = APIClient() + c.credentials(HTTP_AUTHORIZATION=f'Bearer {token}') + return c + + +def _otp_row(phone, code='482913'): + OTPCode.objects.create(phone=phone, code=code, expires_at=timezone.now() + timezone.timedelta(minutes=5)) + return code + + +def _guest(device): + return APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': device}, format='json').json() + + +class GuestUpgradeRevokesEarlierSessionsTests(TestCase): + """کسی که پیش از ثبتِ شماره با شناسه‌ی دستگاه نشست گرفته بود، بعد از آن به حسابِ شماره‌دار نمی‌رسید.""" + + def setUp(self): + cache.clear() + + def test_pre_upgrade_tokens_stop_working(self): + attacker = _guest('shared-device') + victim = _guest('shared-device') + login = _bearer(victim['token']).post('/api/auth/login', + {'phone': '09121230001', 'otp': _otp_row('09121230001')}, + format='json') + self.assertEqual(login.status_code, 200) + self.assertEqual(login.json()['userId'], victim['userId']) + + refresh = APIClient().post('/api/auth/refresh', {'refreshToken': attacker['refreshToken']}, format='json') + self.assertEqual(refresh.status_code, 401) + # توکنِ دسترسیِ پیش از ثبت هم (اگر همان ثانیه صادر نشده باشد) رد می‌شود؛ نشستِ تازه کار می‌کند. + self.assertEqual(_bearer(login.json()['token']).get('/api/user/profile').status_code, 200) + fresh = APIClient().post('/api/auth/refresh', {'refreshToken': login.json()['refreshToken']}, format='json') + self.assertEqual(fresh.status_code, 200) + + +class AccountDeletionKeepsLimitsTests(TestCase): + def setUp(self): + cache.clear() + from discount.models import DiscountCode + from subscription.models import Plan + self.plan = Plan.objects.create(title='pack20', price=45000, kind='pack', + question_credits=20, hearts_days=7, duration_days=0) + DiscountCode.objects.create(code='FREE1', type='percent', value=100, max_uses=1) + + def _guest_redeems(self, device): + s = _guest(device) + c = _bearer(s['token']) + p = c.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json') + return c, c.post('/api/discount/redeem', {'code': 'FREE1', 'orderId': p.json()['orderId']}, format='json') + + def test_single_use_code_stays_used_after_delete(self): + c1, first = self._guest_redeems('g-1') + self.assertEqual(first.status_code, 200) + self.assertEqual(c1.post('/api/user/delete', {'confirm': 'حذف'}, format='json').status_code, 200) + _, again = self._guest_redeems('g-2') + self.assertEqual(again.status_code, 400) + + @override_settings(DEBUG=False, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0) + def test_per_phone_sms_cap_survives_delete(self): + phone = '09121230009' + user = User.objects.create(phone=phone, is_guest=False) + for i in range(10): + OTPCode.objects.create(phone=phone, code=f'{i:06d}', is_used=True, + expires_at=timezone.now() - timezone.timedelta(minutes=i + 1)) + with mock.patch('accounts.views.send_otp_sms'): + OTPCode.objects.create(phone=phone, code='777777', + expires_at=timezone.now() + timezone.timedelta(minutes=5)) + d = _bearer(issue_access_token(user.id)).post('/api/user/delete', {'otp': '777777'}, format='json') + self.assertEqual(d.status_code, 200) + cache.clear() + r = APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json') + self.assertEqual(r.status_code, 429) + + def test_public_demo_guest_cannot_be_deleted(self): + demo = User.objects.create(device_id='frontend-test-device', is_guest=True, name='demo') + s = _guest('frontend-test-device') + self.assertEqual(int(s['userId']), demo.id) + r = _bearer(s['token']).post('/api/user/delete', {'confirm': 'حذف'}, format='json') + self.assertEqual(r.status_code, 403) + self.assertTrue(User.objects.filter(pk=demo.id).exists()) + + +class DisplayNameContactFilterTests(TestCase): + def test_contacts_are_removed_and_names_kept(self): + from accounts.serializers import clean_display_name as clean + + for sample in ('0912/123/4567', '۰۹۱۲/۱۲۳/۴۵۶۷', '0912 _123_ 4567', 'ig: ali_kid_2014', 'آیدی: kid2014'): + self.assertEqual(clean(sample), '', sample) + for name in ('علی رضایی', 'آیدین', 'Sara 2014', 'ایتالیا', 'تلما'): + self.assertEqual(clean(name), name) + + +class _Capture(logging.Handler): + def __init__(self): + super().__init__() + self.lines = [] + self.setFormatter(JSONLogFormatter()) + + def emit(self, record): + self.lines.append(self.format(record)) + + +FAKE_KEY = '4E6F74415265616C4B6176656E656761724B6579414243444546' + + +@override_settings(KAVENEGAR_API_KEY=FAKE_KEY, KAVENEGAR_OTP_TEMPLATE='SMSVerify') +class KavenegarKeyNotLoggedTests(TestCase): + """کلیدِ کاوه‌نگار بخشی از آدرس است و متنِ خطای requests آدرس را دارد.""" + + def test_connection_error_log_has_no_key(self): + from accounts import services + + cap = _Capture() + log = logging.getLogger('accounts.services') + log.addHandler(cap) + + def no_dns(*args, **kwargs): + raise socket.gaierror(socket.EAI_NONAME, 'network disabled in tests') + + try: + with mock.patch('socket.getaddrinfo', no_dns), self.assertRaises(services.SMSDeliveryError): + services.send_verify_lookup(phone='09120000000', template='SMSVerify', token='123456') + finally: + log.removeHandler(cap) + out = '\n'.join(cap.lines) + self.assertIn('SMS request to provider failed', out) + self.assertNotIn(FAKE_KEY, out) + + +class SupervisorDoesNotSeeDeviceIdsTests(TestCase): + """شناسه‌ی دستگاه برای مهمان رمزِ ورود است؛ کارمندِ فقط‌خواندنی آن را نمی‌بیند.""" + + def setUp(self): + call_command('setup_supervisor_group', verbosity=0) + self.guest = User.objects.create(device_id='secret-device-123', is_guest=True) + from accounts.models import Session + Session.objects.create(user=self.guest, refresh_token='h' * 64, device_id='secret-device-123', + expires_at=timezone.now() + timezone.timedelta(days=1)) + staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True) + staff.groups.add(Group.objects.get(name='Supervisor')) + # Supervisor نشست‌ها را نمی‌بیند؛ این‌جا اجازه‌ی دیدن داده می‌شود تا ستونِ آن صفحه هم سنجیده شود. + from django.contrib.auth.models import Permission + staff.user_permissions.add(Permission.objects.get(codename='view_session', content_type__app_label='accounts')) + self.client.force_login(staff) + + def test_hidden_in_list_search_and_form(self): + listing = self.client.get('/admin/accounts/user/') + self.assertEqual(listing.status_code, 200) + self.assertNotContains(listing, 'secret-device-123') + # متنِ جستجو در خودِ صفحه تکرار می‌شود؛ نتیجه نباید همان مهمان باشد. + search = self.client.get('/admin/accounts/user/', {'q': 'secret-device-123'}) + self.assertNotContains(search, f'/admin/accounts/user/{self.guest.pk}/change/') + form = self.client.get(f'/admin/accounts/user/{self.guest.pk}/change/') + self.assertNotContains(form, 'secret-device-123') + sessions = self.client.get('/admin/accounts/session/') + self.assertNotContains(sessions, 'secret-device-123') diff --git a/accounts/views.py b/accounts/views.py index d562435..9f7981d 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -362,7 +362,11 @@ class LoginView(APIView): # جزیره‌ها و گفتگوها همه با همین user_id ذخیره شده‌اند. پیش‌تر یک # کاربرِ تازه و خالی ساخته می‌شد و پیشرفتِ مهمان جا می‌ماند. guest.phone = phone - guest.save(update_fields=['phone']) + # هر نشستی که پیش از این با شناسه‌ی دستگاه گرفته شده بود (گوشیِ مشترک، کسی که + # شناسه را دیده) نباید به حسابِ شماره‌دار برسد؛ فقط نشستِ همین ورود می‌ماند. + guest.tokens_valid_after = timezone.now() + guest.save(update_fields=['phone', 'tokens_valid_after']) + Session.objects.filter(user=guest, revoked=False).update(revoked=True) user, created = guest, True elif user is None: user, created = User.objects.get_or_create( @@ -426,7 +430,10 @@ DELETE_CONFIRM_WORD = 'حذف' # سوابقِ پرداخت سندِ مالی‌اند و با حذفِ حساب نمی‌روند (تراکنش‌ها با سفارش می‌مانند). # Conversion هم می‌ماند: ردیفِ «کاربرِ معرفی‌شده» سابقه‌ی پورسانتِ *معرف* است، نه داده‌ی این # کاربر؛ تا ۲۰۲۶-۱۰-۰۳ با حذفِ حسابِ معرفی‌شده پورسانتِ معرف هم پاک می‌شد. -KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion') +# Redemption و UserOffer هم می‌مانند: سقفِ مصرفِ کدِ تخفیف ردیف‌های Redemption را می‌شمارد، پس پاک +# شدنشان یک کدِ یک‌بارمصرف را با «بساز، بخر، حذف کن» دوباره آزاد می‌کرد. +KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion', + 'discount.Redemption', 'discount.UserOffer') class DeleteAccountView(APIView): @@ -439,7 +446,7 @@ class DeleteAccountView(APIView): بتواند حساب را برای همیشه پاک کند. - مهمان شماره ندارد؛ باید کلمه‌ی «حذف» را تایپ کرده باشد. حسابش فقط به همین دستگاه وصل است. - داده در همه‌ی اپ‌ها پاک می‌شود (delete_users)، جز سفارش‌ها و تراکنش‌هایشان که سندِ مالی‌اند. - - حساب‌های دمو (TEST_LOGIN_PHONES) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند. + - حساب‌های دمو (TEST_LOGIN_PHONES و مهمان‌های DEMO_GUEST_DEVICE_IDS) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند. - بعد از حذف، توکن‌های همین کاربر کار نمی‌کنند چون کاربری با آن شناسه نمانده. """ @@ -460,7 +467,8 @@ class DeleteAccountView(APIView): v = serializer.validated_data user = request.user - if user.phone and user.phone in settings.TEST_LOGIN_PHONES: + if (user.phone and user.phone in settings.TEST_LOGIN_PHONES) or ( + user.is_guest and user.device_id in settings.DEMO_GUEST_DEVICE_IDS): return Response({'detail': 'حسابِ نمایشی را نمی‌شود حذف کرد.'}, status=status.HTTP_403_FORBIDDEN) if user.phone: if not v.get('otp'): @@ -474,7 +482,8 @@ class DeleteAccountView(APIView): status=status.HTTP_400_BAD_REQUEST) logger.info('Account deleted by its owner: user_id=%s guest=%s', user.id, user.is_guest) - delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE) + # کدهای پیامکیِ شماره می‌مانند: سقفِ روزانه‌ی پیامکِ هر شماره از همین ردیف‌ها شمرده می‌شود. + delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE, delete_otp_codes=False) return Response(DeleteAccountResponseSerializer({'success': True}).data) diff --git a/ai_tutor/tests_security_review.py b/ai_tutor/tests_security_review.py new file mode 100644 index 0000000..d9af357 --- /dev/null +++ b/ai_tutor/tests_security_review.py @@ -0,0 +1,138 @@ +""" +حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (دستیار). مدل و Qdrant همه ساختگی‌اند. +""" +import json +from types import SimpleNamespace +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import User +from ai_tutor.models import Message +from rag.services.clients import reset_clients_cache + +RAG_ON = dict(OPENAI_API_KEY='t', QDRANT_API_KEY='t', RAG_FORCE_MOCK=False, AI_DAILY_QUOTA_FREE=3) + +ANSWER = '۱) دو به‌علاوه‌ی دو می‌شود چهار.\n✅ جواب: ۴\n' +OVERFLOW_DIAGRAM = '{"kind":"numberline","min":1' + '0' * 400 + '*2,"max":5}' +DEEP_DIAGRAM = '' + '[' * 3000 + ']' * 3000 + '' + + +class FakeOpenAI: + def __init__(self, reply): + self.reply = reply + self.calls = 0 + self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create)) + self.embeddings = SimpleNamespace( + create=lambda **k: SimpleNamespace(data=[SimpleNamespace(embedding=[0.01] * 1536)])) + + def _create(self, **kw): + self.calls += 1 + if kw.get('stream'): + parts = [self.reply[i:i + 25] for i in range(0, len(self.reply), 25)] + events = [SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=p))], usage=None) + for p in parts] + events.append(SimpleNamespace(choices=[], usage=SimpleNamespace(prompt_tokens=900, completion_tokens=300))) + return iter(events) + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=self.reply))], + usage=SimpleNamespace(prompt_tokens=900, completion_tokens=300)) + + +def sse(resp): + out = [] + for block in b''.join(resp.streaming_content).decode().split('\n\n'): + ev = data = None + for line in block.strip().splitlines(): + if line.startswith('event: '): + ev = line[7:] + elif line.startswith('data: '): + data = json.loads(line[6:]) + if ev: + out.append((ev, data)) + return out + + +@override_settings(**RAG_ON) +class _Base(TestCase): + def setUp(self): + cache.clear() + reset_clients_cache() + self.user = User.objects.create(phone='09120009901', is_guest=False) + self.c = APIClient() + self.c.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') + + def ask(self, q, **extra): + return self.c.post('/api/ai/ask', {'userQuestion': q, **extra}, format='json') + + def remaining(self): + return self.c.get('/api/ai/quota').data['remainingQuota'] + + +class BrokenDiagramDoesNotMakeAnswersFreeTests(_Base): + """پارسرِ شکل OverflowError/RecursionError را بالا می‌داد و جوابِ استریم‌شده رایگان و بی‌ثبت می‌ماند.""" + + def test_stream_and_json_answers_are_charged(self): + for diagram in (OVERFLOW_DIAGRAM, DEEP_DIAGRAM): + with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER + diagram)): + events = sse(self.ask(f'دو به‌علاوه‌ی دو؟ {len(diagram)}', stream=True)) + self.assertEqual(events[-1][0], 'done') + self.assertIsNone(events[-1][1]['diagram']) + with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER + OVERFLOW_DIAGRAM)): + self.assertEqual(self.ask('دو به‌علاوه‌ی دو؟').status_code, 200) + self.assertEqual(self.remaining(), 0) + self.assertEqual(Message.objects.filter(role='assistant').count(), 3) + + +@override_settings(RAG_ANSWER_CACHE_TTL=300) +class LateOffTopicMarkerTests(_Base): + def test_rest_is_not_streamed_stored_or_cached(self): + from rag.services.guardrails import OFF_TOPIC_MARKER, REFUSAL + + reply = ('۱) این یک جمله‌ی ریاضیِ بلند برای عبور از چهل نویسه است.\n' + f'{OFF_TOPIC_MARKER} متنِ غیرِ ریاضی: یک انشای کامل درباره‌ی فوتبال.\n') + fake = FakeOpenAI(reply) + q = 'سوال درباره‌ی چیزی' + with patch('rag.services.clients.get_openai_client', return_value=fake): + streamed = ''.join(d['delta'] for e, d in sse(self.ask(q, stream=True)) if e == 'chunk') + other = User.objects.create(phone='09120009902', is_guest=False) + c2 = APIClient() + c2.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(other.id)}') + second = c2.post('/api/ai/ask', {'userQuestion': q}, format='json') + self.assertNotIn('فوتبال', streamed) + self.assertIn(REFUSAL, streamed) + self.assertEqual(Message.objects.filter(role='assistant').first().text, REFUSAL) + self.assertNotIn('فوتبال', second.data['answer']) + self.assertEqual(fake.calls, 2) # جوابِ ردشده در کش نرفت + + +class DisconnectMidStreamTests(_Base): + def test_partial_answer_is_saved(self): + with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER * 10)): + resp = self.ask('سوال', stream=True) + it = iter(resp.streaming_content) + next(it) + next(it) + resp._iterator.close() # همان GeneratorExit که بستنِ صفحه می‌سازد + saved = Message.objects.filter(role='assistant') + self.assertEqual(saved.count(), 1) + self.assertTrue(saved.first().text.endswith('…')) + self.assertEqual(self.remaining(), 2) + + +class UnknownCollectionIsNotSearchedTests(_Base): + def test_client_chosen_collection_is_ignored(self): + seen = [] + + def fake_search(collection, vec, **kw): + seen.append(collection) + return [{'score': 0.9, 'payload': {'title': 'doc', 'text': 'PAYLOAD-OF-UNRELATED-COLLECTION'}}] + + with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI('پاسخ')), \ + patch('rag.services.retrieval.search', side_effect=fake_search): + r = self.ask('هر چیزی', subject='any', grade='other collection') + self.assertEqual(r.status_code, 200) + self.assertEqual(seen, []) + self.assertEqual(r.data['usedChunks'], []) diff --git a/ai_tutor/views.py b/ai_tutor/views.py index b416227..a1192af 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -152,7 +152,9 @@ def _is_db_ref_id(value) -> bool: """ if value in (None, ''): return False - return str(value).strip().lstrip('-').isdigit() + # isascii: '²'.isdigit() هم True است ولی int('²') خطا می‌دهد. + text = str(value).strip().lstrip('-') + return text.isascii() and text.isdigit() def _db_ref_or_none(value): @@ -606,7 +608,7 @@ class AskAIView(APIView): event: error data: {"detail": "..."} فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده. """ - from rag.services import answer_cache + from rag.services import answer_cache, guardrails from rag.services.generation import ( PROMPT_VERSION, chat_model_for, default_follow_ups, stream_answer, ) @@ -654,12 +656,18 @@ class AskAIView(APIView): stream_result: dict = {} started = time.monotonic() effective_grade = retrieval_meta.get('gradeTitle') or grade_title or '' - for delta in stream_answer( - v['userQuestion'], used_chunks or None, effective_grade, - history=history, focus=focus, result=stream_result, age=age, - ): - parts.append(delta) - yield _sse('chunk', {'delta': delta}) + try: + for delta in stream_answer( + v['userQuestion'], used_chunks or None, effective_grade, + history=history, focus=focus, result=stream_result, age=age, + ): + parts.append(delta) + yield _sse('chunk', {'delta': delta}) + except GeneratorExit: + # کاربر صفحه را وسطِ جواب بست. سوال خرج می‌ماند (مدل هزینه‌اش را گرفته)، ولی + # همان بخشی که ساخته شد ثبت می‌شود تا گفتگو سوالِ بی‌جواب نداشته باشد. + self._save_partial_answer(conversation, user, v, ''.join(parts)) + raise provider_status.record_success() answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.' follow_ups = default_follow_ups() @@ -678,6 +686,7 @@ class AskAIView(APIView): } if stream_result.get('refused'): used_chunks = [] + answer_text = guardrails.REFUSAL elif cacheable: answer_cache.set(cache_key, { 'answer': answer_text, 'followUps': follow_ups, @@ -752,6 +761,20 @@ class AskAIView(APIView): response['X-Accel-Buffering'] = 'no' return response + @staticmethod + def _save_partial_answer(conversation, user, v, text): + logger.info('stream closed by client mid-answer: user_id=%s conversation_id=%s chars=%s', + user.id, conversation.id, len(text)) + if not text.strip(): + return + try: + Message.objects.create( + conversation=conversation, role='assistant', text=text.strip() + ' …', + question_id=_db_ref_or_none(v.get('questionId')), + ) + except Exception: + logger.exception('saving partial answer failed: conversation_id=%s', conversation.id) + def _release_reserved_slot(self, conversation, user_message, conversation_is_new): """پاک‌کردن رزرو فاز ۱ وقتی فاز بعدی شکست می‌خورد، تا نه پیام یتیم بماند و نه کوتا مصرف‌شده حساب شود.""" from subscription.services import refund_credit diff --git a/config/settings.py b/config/settings.py index 3bb2291..19d8325 100644 --- a/config/settings.py +++ b/config/settings.py @@ -53,21 +53,49 @@ if not SECRET_KEY: else: import secrets + import time + + # سه workerِ gunicorn هم‌زمان به این‌جا می‌رسند. فایل فقط یک بار و اتمی ساخته می‌شود + # (O_EXCL، و نوشتن در فایلِ موقت + rename)، و بقیه همان را می‌خوانند؛ در غیرِ این صورت هر + # worker کلیدِ خودش را می‌ساخت و توکن فقط روی یکی از آن‌ها معتبر بود. سنجیده با سه + # پروسه‌ی هم‌زمان، نه با gunicornِ واقعی. _key_file = BASE_DIR / '.django_secret_key' - try: - SECRET_KEY = _key_file.read_text().strip() - except OSError: - SECRET_KEY = '' - if not SECRET_KEY: - SECRET_KEY = secrets.token_urlsafe(64) + _lock_file = BASE_DIR / '.django_secret_key.lock' + SECRET_KEY = '' + for _attempt in range(50): try: - _key_file.write_text(SECRET_KEY) - _key_file.chmod(0o600) + SECRET_KEY = _key_file.read_text().strip() + except FileNotFoundError: + SECRET_KEY = '' + except OSError as exc: + raise ImproperlyConfigured(f'خواندنِ {_key_file} ممکن نشد ({exc}).') from exc + if SECRET_KEY: + break + try: + _fd = os.open(_lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + time.sleep(0.1) # پروسه‌ی دیگری در حالِ ساختنِ کلید است + continue except OSError as exc: raise ImproperlyConfigured( - f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). ' + f'DJANGO_SECRET_KEY ست نیست و ساختنِ {_key_file} هم ممکن نشد ({exc}). ' 'یا متغیر را ست کنید یا اجازه‌ی نوشتن در پوشه‌ی پروژه بدهید.' ) from exc + try: + os.close(_fd) + if not _key_file.exists(): + _tmp = BASE_DIR / '.django_secret_key.tmp' + _tmp_fd = os.open(_tmp, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600) + with os.fdopen(_tmp_fd, 'w') as fh: + fh.write(secrets.token_urlsafe(64)) + os.replace(_tmp, _key_file) + finally: + _lock_file.unlink(missing_ok=True) + if not SECRET_KEY: + raise ImproperlyConfigured( + f'کلیدِ امضا ساخته نشد: {_lock_file} مانده است. اگر هیچ پروسه‌ای در حالِ اجرا نیست، ' + 'آن را پاک کنید و دوباره راه‌اندازی کنید.' + ) def _env_list(name: str) -> list[str]: return [v.strip() for v in os.environ.get(name, '').split(',') if v.strip()] @@ -607,6 +635,12 @@ SPECTACULAR_SETTINGS = { 'filter': True, 'persistAuthorization': True, }, + # نسخه‌ی ثابت، نه @latest: این صفحه‌ها روی همان دامنه‌ی /admin/ اجرا می‌شوند، و نسخه‌ی منتشرشده + # در npm عوض‌شدنی نیست؛ با @latest هر نسخه‌ی آلوده‌ی تازه‌ی این بسته‌ها در مرورگرِ کارمند اجرا می‌شد. + # SRI ندارد. ارتقا یعنی عوض کردنِ همین دو عدد. + 'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1', + 'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/favicon-32x32.png', + 'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@2.5.4', } # اعتبار توکن دسترسی (ثانیه) و رفرش‌توکن (روز) @@ -702,6 +736,11 @@ def _parse_test_login_phones(raw: str) -> dict[str, str]: TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', '')) +# مهمان‌های نمایشی که با حذفِ حساب پاک نمی‌شوند (همان کاری که TEST_LOGIN_PHONES برای شماره‌ها می‌کند). +# پیش‌فرض: حسابِ مهمانِ تستِ فرانت (کاربرِ ۶ روی سرور)، که شناسه‌اش در ریپو و مستندات آمده و هر کسی +# می‌تواند با آن وارد شود. با پاک شدنِ داده‌ی دمو پیش از کاربرِ واقعی، این پیش‌فرض هم برداشته شود. +DEMO_GUEST_DEVICE_IDS = _env_list('DEMO_GUEST_DEVICE_IDS') or ['frontend-test-device'] + # ----------------------------------------------------------------------- # OTP SMS delivery (accounts/services.py: send_otp_sms) # ----------------------------------------------------------------------- @@ -745,10 +784,9 @@ ADMIN_LOGIN_MAX_ATTEMPTS = 10 # POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛ # کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در # پروداکشن مقدار خالی می‌ماند و HasInternalServiceKey صراحتاً رد می‌کند. -INTERNAL_SERVICE_KEY = os.environ.get( - 'INTERNAL_SERVICE_KEY', - 'dev-internal-service-key-change-me' if DEBUG else '', -) +# هیچ پیش‌فرضی، حتی در DEBUG: رشته‌ی پیش‌فرضِ داخلِ ریپو هر لپ‌تاپِ توسعه‌ای را که روی شبکه +# باز است به ingest در Qdrant راه می‌داد. +INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', '') # ----------------------------------------------------------------------- diff --git a/config/urls.py b/config/urls.py index dc97cb8..2dea47d 100644 --- a/config/urls.py +++ b/config/urls.py @@ -13,6 +13,7 @@ from django.views import defaults as django_default_views from drf_spectacular.utils import extend_schema from config.health import HealthView +from rest_framework.throttling import ScopedRateThrottle from drf_spectacular.views import ( SpectacularAPIView, SpectacularRedocView, @@ -46,6 +47,13 @@ def handler500(request): return django_default_views.server_error(request) +class ThrottledSchemaView(SpectacularAPIView): + """بی‌ورود و بی‌کش: هر بار ~۵۶ms CPU و ۱۱۰KB؛ بی‌throttle چند ده درخواست در ثانیه workerها را پر می‌کرد.""" + + throttle_classes = [ScopedRateThrottle] + throttle_scope = 'read' + + urlpatterns = [ path('admin/', admin.site.urls), @@ -67,7 +75,7 @@ urlpatterns = [ path('api/health/', HealthViewSlashAlias.as_view(), name='health-slash'), # OpenAPI schema + Swagger UI + Redoc - path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/schema/', ThrottledSchemaView.as_view(), name='schema'), # قالبِ خودمان فقط یک پوسته‌ی راست‌به‌چپ روی قالبِ drf-spectacular است؛ # بدونش متنِ فارسی در یک بلوکِ LTR رندر می‌شود و نقطه‌ی جمله و ترتیبِ # ستون‌های جدول جابه‌جا می‌شوند. توضیح در خودِ قالب. diff --git a/content/admin.py b/content/admin.py index 554996b..ce9de26 100644 --- a/content/admin.py +++ b/content/admin.py @@ -1,6 +1,7 @@ import json from django.contrib import admin, messages +from django.core.exceptions import PermissionDenied from django.db.models import Count, Q from django.http import HttpResponse from django.shortcuts import redirect, render @@ -54,6 +55,9 @@ class GradeAdmin(admin.ModelAdmin): return response def import_curriculum_view(self, request): + # admin_view فقط is_staff را می‌سنجد؛ این صفحه پایه/فصل/درس می‌سازد و متنِ درس را عوض می‌کند. + if not (self.has_add_permission(request) and self.has_change_permission(request)): + raise PermissionDenied if request.method == 'POST': uploaded = request.FILES.get('file') if uploaded is None: @@ -178,6 +182,9 @@ class QuestionAdmin(admin.ModelAdmin): return response def import_view(self, request): + # admin_view فقط is_staff را می‌سنجد؛ «جایگزینی» سوال‌ها و جواب‌های دانش‌آموزان به آن‌ها را پاک می‌کند. + if not self.has_add_permission(request): + raise PermissionDenied if request.method == 'POST': uploaded = request.FILES.get('file') if uploaded is None: @@ -185,6 +192,8 @@ class QuestionAdmin(admin.ModelAdmin): return redirect(request.path) replace = request.POST.get('replace') == 'on' + if replace and not self.has_delete_permission(request): + raise PermissionDenied try: rows = parse(uploaded.read()) result = import_questions(rows, replace=replace) @@ -238,7 +247,7 @@ class QuestionReportAdmin(admin.ModelAdmin): autocomplete_fields = ['question'] actions = ['mark_resolved'] - @admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده') + @admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده', permissions=['change']) def mark_resolved(self, request, queryset): updated = queryset.update(status='resolved', resolved_at=timezone.now()) self.message_user(request, f'{updated} گزارش بررسی‌شده علامت خورد.', messages.SUCCESS) diff --git a/content/admin_browser.py b/content/admin_browser.py index fa329a9..4ad09be 100644 --- a/content/admin_browser.py +++ b/content/admin_browser.py @@ -10,9 +10,12 @@ گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط — یعنی همان سیگنالی که می‌گوید کدام سوال ایراد دارد یا بیش از حد سخت است. +فقط برای کارمندی که اجازه‌ی دیدنِ سوال دارد، چون کلیدِ جواب‌ها را نشان می‌دهد. + وابستگیِ تازه‌ای اضافه نمی‌کند: چند اندپوینتِ کوچکِ JSON و JavaScript ساده. """ from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth.decorators import permission_required from django.db.models import Count, Q from django.http import JsonResponse from django.shortcuts import render @@ -22,6 +25,7 @@ from .models import Grade, Lesson, Node, Question, Unit @staff_member_required +@permission_required('content.view_question', raise_exception=True) def browser_view(request): return render(request, 'admin/content/browser.html', { 'title': 'مرورِ محتوا', @@ -30,6 +34,7 @@ def browser_view(request): @staff_member_required +@permission_required('content.view_question', raise_exception=True) def units_json(request, grade_id: int): rows = ( Unit.objects.filter(grade_id=grade_id) @@ -41,6 +46,7 @@ def units_json(request, grade_id: int): @staff_member_required +@permission_required('content.view_question', raise_exception=True) def nodes_json(request, unit_id: int): rows = ( Node.objects.filter(unit_id=unit_id) @@ -52,6 +58,7 @@ def nodes_json(request, unit_id: int): @staff_member_required +@permission_required('content.view_question', raise_exception=True) def lessons_json(request, node_id: int): rows = ( Lesson.objects.filter(node_id=node_id) @@ -63,6 +70,7 @@ def lessons_json(request, node_id: int): @staff_member_required +@permission_required('content.view_question', raise_exception=True) def questions_json(request, lesson_id: int): """ هر سوال با دو سیگنال: گزارشِ بازِ کاربران، و درصدِ پاسخِ غلط. diff --git a/content/tests_security_review.py b/content/tests_security_review.py new file mode 100644 index 0000000..44d0741 --- /dev/null +++ b/content/tests_security_review.py @@ -0,0 +1,80 @@ +""" +حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (محتوا، پنل و ورودی‌ها). هر تست رفتاری را نگه می‌دارد که +پیش‌تر غلط بود. +""" +import io +import json + +from django.contrib.auth.models import Group, User as AuthUser +from django.core.cache import cache +from django.core.management import call_command +from django.test import TestCase +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import User +from content.models import Choice, Grade, Lesson, Node, Question, QuestionReport, Unit, UserAnswer + + +class SupervisorCannotEditContentTests(TestCase): + """admin_view فقط is_staff را می‌سنجد؛ صفحه‌های ورودِ فایل اجازه‌ی مدل را جدا چک نمی‌کردند.""" + + def setUp(self): + cache.clear() + call_command('setup_supervisor_group', stdout=io.StringIO()) + sup = AuthUser.objects.create_user('sup', 's@x.c', 'pw12345!', is_staff=True) + sup.groups.add(Group.objects.get(name='Supervisor')) + self.client.force_login(sup) + + grade = Grade.objects.create(title='G', icon='', color='', number=55) + unit = Unit.objects.create(grade=grade, title='U', order=1) + node = Node.objects.create(unit=unit, title='N', order=1) + self.lesson = Lesson.objects.create(node=node, title='L', intro_text='x') + self.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='p?') + ok = Choice.objects.create(question=self.q, value='1', is_correct=True) + Choice.objects.create(question=self.q, value='2', is_correct=False) + self.student = User.objects.create(phone='09120009999', is_guest=False) + UserAnswer.objects.create(user_id=self.student.id, question=self.q, choice=ok, correct=True) + + def test_question_import_with_replace_is_refused(self): + f = io.BytesIO(json.dumps([{ + 'lessonId': self.lesson.id, 'prompt': 'HACKED', 'type': 'multiple_choice', + 'choices': ['a', 'b'], 'correct': 0, + }]).encode()) + f.name = 'q.json' + r = self.client.post('/admin/content/question/import/', {'file': f, 'replace': 'on'}) + self.assertEqual(r.status_code, 403) + self.assertEqual(list(Question.objects.filter(lesson=self.lesson).values_list('prompt', flat=True)), ['p?']) + self.assertEqual(UserAnswer.objects.filter(user_id=self.student.id).count(), 1) + + def test_curriculum_import_and_content_browser_are_refused(self): + self.assertEqual(self.client.get('/admin/content/grade/import-curriculum/').status_code, 403) + self.assertEqual(self.client.get('/admin/content/question/import/').status_code, 403) + self.assertEqual(self.client.get(f'/admin/content/question/browse/questions/{self.lesson.id}/').status_code, 403) + + def test_mark_resolved_action_is_refused(self): + report = QuestionReport.objects.create(question=self.q, reason='wrong_answer', user_id=self.student.id) + self.client.post('/admin/content/questionreport/', {'action': 'mark_resolved', '_selected_action': [report.pk]}) + report.refresh_from_db() + self.assertNotEqual(report.status, 'resolved') + + +class NonAsciiDigitsAre400Tests(TestCase): + """'²'.isdigit() هم True است ولی int('²') خطا می‌دهد؛ این ورودی‌ها ۵۰۰ می‌دادند.""" + + def setUp(self): + cache.clear() + user = User.objects.create(device_id='digits', is_guest=True) + self.app = APIClient() + self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') + + def test_character_and_unit_ids(self): + self.assertEqual(self.app.post('/api/characters/select', {'characterId': 'abc'}, format='json').status_code, 400) + self.assertEqual(self.app.post('/api/characters/select', {'characterId': '²'}, format='json').status_code, 400) + self.assertEqual(self.app.get('/api/progress/nodes', {'unit': '²'}).status_code, 400) + + def test_ticket_text_is_capped(self): + r = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5001}, format='json') + self.assertEqual(r.status_code, 400) + ok = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5000}, format='json') + self.assertIn(ok.status_code, (200, 201)) diff --git a/content/views.py b/content/views.py index 8c71f8a..1164da3 100644 --- a/content/views.py +++ b/content/views.py @@ -741,7 +741,7 @@ class NodeProgressListView(APIView): qs = NodeProgress.objects.filter(user_id=request.user.id).select_related('node') unit_id = request.query_params.get('unit') if unit_id: - if not unit_id.isdigit(): + if not (unit_id.isascii() and unit_id.isdigit()): raise ValidationError({'unit': 'شناسه‌ی فصل باید عدد باشد.'}) qs = qs.filter(node__unit_id=int(unit_id)) diff --git a/docker-compose.yml b/docker-compose.yml index afd212a..5b7d593 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,8 +27,10 @@ services: # idempotent است (مطابقِ همون workflow ای که در README برای اجرای # محلی/غیرِ Docker هم توصیه شده). command: sh -c "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000" + # فقط روی خودِ لپ‌تاپ: این پشته DEBUG=1 دارد (کدِ 0000 برای هر شماره) و Grafana بی‌رمز است؛ + # روی 0.0.0.0 هر همسایه‌ی وای‌فای به آن‌ها می‌رسید. ports: - - "8000:8000" + - "127.0.0.1:8000:8000" environment: # این مقادیر فقط برای اجرای محلیِ Docker/دِمو هستن؛ برای پروداکشن # واقعی جایگزین بشن (به .env.example نگاه کنید). @@ -53,7 +55,7 @@ services: image: grafana/loki:2.9.6 container_name: tadrisino_loki ports: - - "3100:3100" + - "127.0.0.1:3100:3100" command: -config.file=/etc/loki/loki-config.yml volumes: - ./docker/loki/loki-config.yml:/etc/loki/loki-config.yml:ro @@ -71,7 +73,7 @@ services: - --storage.path=/var/lib/alloy/data - /etc/alloy/config.alloy ports: - - "12345:12345" # اختیاری: UI دیباگِ Alloy (وضعیتِ کامپوننت‌ها) + - "127.0.0.1:12345:12345" # اختیاری: UI دیباگِ Alloy (وضعیتِ کامپوننت‌ها) volumes: - ./docker/alloy/config.alloy:/etc/alloy/config.alloy:ro - django_logs:/var/log/django:ro # همون لاگ‌های Django رو می‌خونه (read-only) @@ -86,12 +88,12 @@ services: image: grafana/grafana:10.4.3 container_name: tadrisino_grafana ports: - - "3000:3000" + - "127.0.0.1:3000:3000" environment: # فقط برای دسترسیِ محلی/دِمو ساده شده (بدونِ لاگین)؛ برای هر چیزِ # عمومی/پروداکشن حتماً anonymous access رو خاموش و پسورد ست کنید. - GF_AUTH_ANONYMOUS_ENABLED=true - - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin} volumes: - ./docker/grafana/provisioning:/etc/grafana/provisioning:ro diff --git a/docs/docker.md b/docs/docker.md index 2622c1f..8ae1d0f 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -16,7 +16,7 @@ docker compose up --build | سرویس | آدرس | توضیح | |---|---|---| | اپ Django | `http://localhost:8000` | | -| Grafana | `http://localhost:3000` | دسترسیِ anonymous، فقط محیطِ محلی | +| Grafana | `http://localhost:3000` | دسترسیِ anonymous فقط با نقشِ Viewer، فقط روی خودِ لپ‌تاپ (`127.0.0.1`) | | Loki | `http://localhost:3100` | ذخیره‌سازیِ لاگ، در معرضِ کاربر نیست | | Alloy | `http://localhost:12345` | رابطِ دیباگِ اختیاری | diff --git a/docs/operations.md b/docs/operations.md index d54c085..ff3b1f2 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -112,7 +112,8 @@ curl https://mathapi.tadrisino2.ir/api/health | `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را می‌دهد | | `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) | | `HEARTS_ENFORCED` | روشن (از ۲۰۲۶-۱۰-۰۲): با صفر قلب نه جواب پذیرفته می‌شود نه پایانِ درس/جزیره‌ی بی‌جواب. `0` خاموشش می‌کند | -| `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بسته‌اند | +| `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بسته‌اند (در DEBUG هم؛ پیش‌فرض ندارد) | +| `DEMO_GUEST_DEVICE_IDS` | فقط `frontend-test-device` با «حذفِ حساب» پاک نمی‌شود. با پاک شدنِ داده‌ی دمو، پیش‌فرض هم از `config/settings.py` برداشته شود | **اتصال به بازار — روشِ جدید (ترجیحی):** در پیشخانِ بازار روی برنامه ← «API پیشخان بازار» ← «دریافت توکن جدید» (از حسابِ ناشر، نه کارپرداز). همان را در `.env` به‌عنوانِ diff --git a/docs/security-before-production.md b/docs/security-before-production.md index 333ea28..c99c38c 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -1,6 +1,6 @@ # پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز مانده‌اند -**آخرین بازبینی با کد و سرورِ زنده: ۲۰۲۶-۱۰-۰۴ (۱۲ مهر ۱۴۰۵).** +**آخرین بازبینی با کد: ۲۰۲۶-۱۰-۰۵ (۱۳ مهر ۱۴۰۵)؛ با سرورِ زنده: ۲۰۲۶-۱۰-۰۴.** این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ @@ -38,6 +38,14 @@ | ۲۰ | یونیتِ systemd `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور | | ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت | | ۲۳ | سقفِ دستیار برای مهمان‌ها بر اساسِ IP مشترک است؛ کلاسِ پشتِ یک IP بعد از ۲۰ سوال در ساعت ۴۲۹ می‌گیرد | متوسط (دسترس‌پذیری) | تصمیمِ محصولی | +| ۲۴ | سقفِ روزانه‌ی ۳۰۰ پیامکِ کلِ سامانه را یک ناشناس پر می‌کند و ورود با پیامک برای همه بسته می‌شود | بالا (دسترس‌پذیری، هزینه) | تصمیمِ طراحی | +| ۲۵ | با دانستنِ شماره‌ی کسی، ورودِ او تا ۲۴ ساعت بسته می‌شود | متوسط | تصمیمِ طراحی | +| ۲۶ | پایانِ درس و جزیره به گفته‌ی کلاینت است (جزیره‌ی قفل، قلبِ صفر، گزارشِ والدین) | متوسط | کد، بعد از ارائه | +| ۲۷ | کلیدِ جوابِ هر سوال با یک حسابِ دوم درمی‌آید؛ امتیازِ هر جوابِ درست دو بار داده می‌شود | کم | تصمیمِ محصولی | +| ۲۸ | استفاده‌ی دوباره از توکنِ تمدیدِ دزدیده‌شده تشخیص داده نمی‌شود؛ «خروج از همه‌ی دستگاه‌ها» نیست | کم | کد، بعد از ارائه | +| ۲۹ | پیشنهادِ ثبت‌نام با حذفِ حساب و ورودِ دوباره با همان شماره دوباره داده می‌شود | کم | کد | +| ۳۰ | چند مسیرِ خواندنی (از جمله بانکِ سوال، بی‌ورود) throttle ندارند | کم | کد، بعد از ارائه | +| ۳۱ | لپ‌تاپِ توسعه با `mobile-preview` روی شبکه‌ی محلی باز است و در DEBUG کدِ `0000` را می‌پذیرد | کم | لپ‌تاپِ توسعه | --- @@ -82,7 +90,9 @@ - **خطر:** داده‌ی ساختگی در آمار و جدولِ رتبه‌بندیِ واقعی؛ یک حسابِ عمومی که هر کس می‌تواند با آن کار کند (ثبتِ تیکت، گفتگو با AI به هزینه‌ی ما). - **رفع:** پیش از کاربرِ واقعی پاک شوند. `purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ - واقعیِ viewها ساخته شده‌اند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ. + واقعیِ viewها ساخته شده‌اند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ. همراهِ آن پیش‌فرضِ + `DEMO_GUEST_DEVICE_IDS` در `config/settings.py` (که حذفِ این مهمان را با `POST /api/user/delete` + می‌بندد) هم برداشته شود. - **چطور بسنجید:** `POST /api/auth/session` با `frontend-test-device` باید `isNewUser: true` بدهد. ## ۴. `/api/health` عمومی و پرجزئیات @@ -107,8 +117,12 @@ (`X-Internal-Key`) را فهرست می‌کند. - **خطر:** شناساییِ کاملِ سطحِ حمله بدونِ زحمت. - **چرا باز مانده:** قراردادِ مشترک با فرانت همین‌جاست؛ فرانت‌کار و ارائه به آن نیاز دارند. -- **رفع:** در پروداکشن `SERVE_PERMISSIONS` در `SPECTACULAR_SETTINGS` را به - `IsAdminUser` (staffِ جنگو) محدود کنید، یا مسیر را در nginx به IPهای تیم ببندید. +- **رفع:** مسیر را در 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/` باید ۴۰۱/۴۰۳ بدهد. ## ۷. پنلِ ادمین روی دامنه‌ی عمومی @@ -148,7 +162,9 @@ - **چیست:** `POST /api/auth/session` با هر `deviceId` یا حسابِ مهمانِ همان دستگاه را برمی‌گرداند یا می‌سازد (`accounts/views.py::GuestSessionView`). هیچ رازِ دیگری در کار نیست. - **خطر:** هر کس `deviceId`ِ یک مهمان را بداند (از `localStorage`، لاگ، اسکرین‌شات) وارد - حسابِ او می‌شود. بعد از ثبت‌نام با شماره این راه بسته می‌شود (مهمان دیگر مهمان نیست). + حسابِ او می‌شود. بعد از ثبت‌نام با شماره این راه بسته می‌شود (مهمان دیگر مهمان نیست) و همه‌ی + نشست‌هایی که پیش از آن گرفته شده بودند باطل می‌شوند. شناسه‌ی دستگاه در پنل فقط برای ابرکاربر + نمایش داده می‌شود. - **رفع:** برای مهمان هم یک رازِ تصادفیِ سمتِ سرور صادر شود و ورودِ دوباره با همان راز باشد، نه با `deviceId`ِ خام. @@ -234,6 +250,72 @@ سقفِ سراسریِ `AI_GLOBAL_DAILY_MAX` (۳۰۰۰ در روز، حدودِ ۶ دلار) هزینه را جدا محدود می‌کند، پس می‌شود سطل را per-user کرد و فقط ساختِ مهمان را محدود نگه داشت. تا تصمیم، دست نخورد. +## ۲۴. پر کردنِ سقفِ سراسریِ پیامک + +- **چیست:** `OTP_SMS_GLOBAL_DAILY_MAX` (۳۰۰ در ۲۴ ساعت) جلوی خرجِ بی‌سقفِ پیامک را می‌گیرد. هر + ناشناسی با درخواستِ کد برای شماره‌های تصادفی آن را پر می‌کند؛ از یک IP بی‌جعل ساعتی ۲۰ درخواست، + یعنی روزی ۴۸۰، پس یک نفر سقف را همیشه پر نگه می‌دارد. +- **بازتولید شد:** ۳۰۰ درخواست پذیرفته و ۳۰۰ پیامک فرستاده شد؛ بعد درخواستِ کاربرِ واقعی ۵۰۳. +- **خطر:** هیچ ثبت‌نامِ تازه، ورودِ دوباره یا حذفِ حسابی ممکن نیست؛ هزینه‌ی ۳۰۰ پیامک در روز. +- **رفع (تصمیمِ طراحی):** بعد از گذشتن از یک حد، CAPTCHA یا مانعِ مشابه پیش از ارسال؛ شمارشِ سقف + بر اساسِ IP/شبکه؛ هشدار در نیمه‌ی سقف. + +## ۲۵. بستنِ ورودِ یک شماره‌ی مشخص + +- **چیست:** ۱۰ درخواستِ کد در ~۲ ساعت سقفِ روزانه‌ی آن شماره را پر می‌کند (صاحبش تا ۲۴ ساعت کد + نمی‌گیرد)، و ۵ حدسِ غلطِ هر کسی کدِ تازه‌ی او را باطل می‌کند. هر دو بازتولید شد. +- **رفع (تصمیمِ طراحی):** کد به درخواست‌کننده گره بخورد (درخواستِ کد یک شناسه‌ی موقت برگرداند که + ورود لازمش دارد) و حدسِ غلط برای هر درخواست‌کننده جدا شمرده شود. + +## ۲۶. پایانِ درس و جزیره به گفته‌ی کلاینت + +- **چیست:** `POST /api/nodes/{id}/quiz/complete` و `POST /api/lessons/{id}/complete` تعدادِ سوال و + جوابِ درست را از کلاینت می‌گیرند. بازتولید شد: با قلبِ صفر و `totalQuestions=1` جزیره تمام + می‌شود؛ جزیره‌ی قفل با `correctCount=0` تمام می‌شود؛ درس و جزیره بی‌هیچ جوابی تمام می‌شوند، پس + `GET /api/user/weekly-report` پیشرفتِ ۱۰۰٪ نشان می‌دهد بی‌آن‌که سوالی جواب داده شده باشد. +- **چرا باز مانده:** رفتارِ آزمون در فرانت به همین شکل است؛ عوض کردنش پیش از ارائه جریانِ نقشه را + به خطر می‌اندازد. +- **رفع:** تعدادِ سوالِ آزمون را سرور تعیین کند (همان `/quiz` با seed)، همان تعداد جوابِ ثبت‌شده + لازم باشد، و جزیره‌ی قبلی تمام شده باشد. + +## ۲۷. کلیدِ جواب با حسابِ دوم، امتیازِ دوباره + +- **چیست:** جوابِ غلط `correctOptionId` را برمی‌گرداند و برای همه یکی است؛ پایانِ درس و پایانِ + جزیره هر کدام جدا امتیاز می‌دهند. یک مهمانِ دورریختنی کلید را درمی‌آورد و حسابِ اصلی همه را درست + می‌زند (۴ سوال، ۸۰ امتیاز). رتبه‌بندی و آمارِ درصدِ غلط آلوده می‌شوند. +- **رفع (تصمیمِ محصولی):** امتیازِ هر سوال یک بار؛ گزینه‌ی درست فقط بعد از پایانِ آزمون. + +## ۲۸. توکنِ تمدیدِ دزدیده‌شده + +- **چیست:** استفاده‌ی دوباره از یک توکنِ تمدیدِ باطل‌شده فقط ۴۰۱ می‌گیرد؛ اگر دزد اول تمدید کند، + صاحبِ حساب بیرون می‌افتد و دزد می‌ماند. «خروج از همه‌ی دستگاه‌ها» هم نیست. +- **چرا باز مانده:** باطل کردنِ کلِ زنجیره با استفاده‌ی دوباره، حساب‌های دمو را که در ارائه چند + نفر هم‌زمان با آن‌ها کار می‌کنند بیرون می‌اندازد. +- **رفع:** شناسه‌ی خانواده روی `Session`؛ با استفاده‌ی دوباره کلِ خانواده باطل شود؛ گزینه‌ی خروج از همه‌جا. + +## ۲۹. پیشنهادِ ثبت‌نام بعد از حذفِ حساب + +- **چیست:** پیشنهادِ خودکارِ ثبت‌نام به `user_id` گره خورده است؛ حذفِ حساب و ورودِ دوباره با همان + شماره یک کاربرِ تازه می‌سازد و پیشنهاد دوباره داده می‌شود. فقط وقتی اثر دارد که یک Offerِ + ثبت‌نام فعال باشد. +- **رفع:** گره زدنِ پیشنهادِ ثبت‌نام به هشِ شماره. + +## ۳۰. مسیرهای خواندنیِ بی‌throttle + +- **چیست:** `lessons/{id}/questions` و `lessons/{id}/intro` (بی‌ورود)، `progress/nodes`، + `user/hearts`، `notifications/{id}/read`، `support/tickets/{id}` و چند مسیرِ دیگر `throttle_scope` + ندارند؛ کلِ بانکِ سوال بی‌ورود قابلِ کپی است. (`/api/schema/` سقفِ `read` دارد.) +- **چرا باز مانده:** سقفِ مهمان‌ها بر اساسِ IP است (مورد ۲۳)؛ افزودنِ سقف به این مسیرها برای کلاسی + که پشتِ یک وای‌فای با مهمان کار می‌کند ۴۲۹ می‌سازد. همراهِ تصمیمِ مورد ۲۳ بسته شود. + +## ۳۱. لپ‌تاپِ توسعه روی شبکه‌ی محلی + +- **چیست:** `mobile-preview/serve.py` عمداً روی کلِ شبکه گوش می‌دهد (برای گوشیِ روی همان وای‌فای) و + `/api/` را به runserverِ محلی می‌فرستد؛ در DEBUG کدِ `0000` هر شماره‌ای را وارد می‌کند و کلیدِ + GapGPTِ لپ‌تاپ همان حسابِ سرور است. (`docker compose` فقط روی `127.0.0.1` باز است و کلیدِ سرویسِ + داخلی دیگر پیش‌فرض ندارد.) +- **رفع:** روی شبکه‌ی عمومی (کافه، مدرسه) اجرا نشود؛ برای توسعه کلیدهای جدای GapGPT و Qdrant. + --- ## حفاظ‌هایی که هست (تا دوباره باز نشوند) @@ -249,7 +331,19 @@ | جدولِ رتبه‌بندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) | | شماره‌ی موبایل نرمال می‌شود؛ cooldown، سقفِ روزانه‌ی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده می‌شوند | `accounts/tests_otp_limits.py` | | OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکن‌های قبلی را باطل می‌کند | `accounts/tests_audit_fixes.py` | -| قلبِ صفر جوابِ سوال و پایانِ درس/جزیره را می‌بندد؛ چکش داخلِ تراکنش است | `content/tests_hearts_gate.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` | +| actionهای لغو/بازپرداختِ سفارش، علامتِ «بررسی‌شده» و لینکِ بازار اجازه‌ی تغییر می‌خواهند؛ کارمندِ فقط‌خواندنی (Supervisor) اجرایشان نمی‌کند | `payment/tests_security_review.py`، `content/tests_security_review.py` | +| ورودِ فایلِ سوال/برنامه‌ی درسی اجازه‌ی افزودن (و برای جایگزینی، حذف) می‌خواهد؛ مرورگرِ محتوا اجازه‌ی دیدنِ سوال | `content/tests_security_review.py` | +| شناسه‌ی دستگاهِ مهمان فقط برای ابرکاربر در پنل دیده می‌شود؛ ثبتِ شماره نشست‌های قبلیِ مهمان را باطل می‌کند | `accounts/tests_security_review.py` | +| حذفِ حساب سابقه‌ی کدِ تخفیف، پیشنهادها و کدهای پیامکیِ شماره را نگه می‌دارد؛ مهمانِ نمایشی حذف نمی‌شود | `accounts/tests_security_review.py` | +| صافیِ اطلاعاتِ تماس در نامِ نمایشی (شماره با هر جداکننده، آیدیِ شبکه‌ی اجتماعی) | `accounts/tests_security_review.py` | +| کلیدِ کاوه‌نگار و توکنِ خریدِ بازار در لاگِ خطای شبکه نمی‌آیند | `accounts/tests_security_review.py` | +| برگشت از زرین‌پال بی‌`Authority` ۴۰۰؛ خریدِ دومِ بازار روی سفارشِ پرداخت‌شده ۴۰۹؛ آنچه داده یا پس گرفته می‌شود از خودِ سفارش است، نه پلنِ فعلی | `payment/tests_security_review.py` | +| شکلِ خرابِ مدل جواب را رایگان نمی‌کند؛ علامتِ «خارج از ریاضی» در هر جای استریم جواب را رد می‌کند و در کش نمی‌رود؛ بستنِ صفحه جوابِ ناقص را ثبت می‌کند؛ collectionِ بیرون از `KNOWN_COLLECTIONS` جستجو نمی‌شود | `ai_tutor/tests_security_review.py` | +| ورودیِ عددیِ غیرِ ASCII (مثلِ `²`) ۴۰۰ است نه ۵۰۰؛ متنِ تیکت/گزارشِ باگ حداکثر ۵۰۰۰ نویسه | `content/tests_security_review.py` | +| `INTERNAL_SERVICE_KEY` در هیچ حالتی پیش‌فرض ندارد | `payment/tests.py` (`test_debug_mode_has_no_default_internal_key`) | +| کلیدِ امضای خودساخته (`.django_secret_key`) یک بار و اتمی ساخته می‌شود؛ سه workerِ هم‌زمان یک کلید دارند | دستی: سه پروسه‌ی هم‌زمان که `config.settings` را import می‌کنند، بی‌فایلِ کلید؛ ۲۰ از ۲۰ یک کلید | +| فرانت: شماره‌ی سفارشِ برگشتی از درگاه فقط عدد است، مسیرها encode می‌شوند و «پرداخت موفق» فقط از پاسخِ سرور می‌آید؛ دو تبِ هم‌زمان با تمدیدِ توکن خارج نمی‌شوند | مخزنِ فرانت: `src/app/launchParams.ts`، `src/api/http.ts` | diff --git a/payment/admin.py b/payment/admin.py index 350c35d..3908f19 100644 --- a/payment/admin.py +++ b/payment/admin.py @@ -25,7 +25,7 @@ class OrderAdmin(admin.ModelAdmin): inlines = [TransactionInline] actions = ['cancel_pending', 'refund'] - @admin.action(description='لغوِ سفارشِ در انتظار') + @admin.action(description='لغوِ سفارشِ در انتظار', permissions=['change']) def cancel_pending(self, request, queryset): from .services import OrderStateError, cancel_pending_order @@ -39,7 +39,7 @@ class OrderAdmin(admin.ModelAdmin): if done: self.message_user(request, f'{done} سفارش لغو شد.', messages.SUCCESS) - @admin.action(description='بازپرداخت (پس گرفتنِ اعتبار/اشتراکِ سفارشِ پرداخت‌شده)') + @admin.action(description='بازپرداخت (پس گرفتنِ اعتبار/اشتراکِ سفارشِ پرداخت‌شده)', permissions=['change']) def refund(self, request, queryset): from django.template.response import TemplateResponse @@ -97,7 +97,7 @@ class BazaarCredentialAdmin(admin.ModelAdmin): def has_add_permission(self, request): return False - @admin.action(description='ساختِ لینکِ اتصال به بازار') + @admin.action(description='ساختِ لینکِ اتصال به بازار', permissions=['change']) def make_authorize_link(self, request, queryset): url = bazaar.start_authorization() self.message_user(request, format_html( diff --git a/payment/bazaar.py b/payment/bazaar.py index cb17252..269cf06 100644 --- a/payment/bazaar.py +++ b/payment/bazaar.py @@ -295,8 +295,9 @@ def verify_purchase(*, product_sku: str, purchase_token: str, url = f'{settings.BAZAAR_API_BASE}/api/validate/{pkg}/inapp/{sku}/purchases/{token}/' try: resp = requests.get(url, headers=_auth_headers(), timeout=10) - except requests.RequestException: - logger.exception('Bazaar verify-purchase request failed') + except requests.RequestException as exc: + # فقط نوعِ خطا: متنِ خطا آدرس را دارد و توکنِ خریدِ کاربر بخشی از آن است. + logger.error('Bazaar verify-purchase request failed: error=%s', type(exc).__name__) raise BazaarAPIError() try: return resp.status_code, resp.json() diff --git a/payment/migrations/0011_order_plan_snapshot.py b/payment/migrations/0011_order_plan_snapshot.py new file mode 100644 index 0000000..a3d07f3 --- /dev/null +++ b/payment/migrations/0011_order_plan_snapshot.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.17 on 2026-10-05 01:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0010_order_cancel_refund'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='duration_days', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='order', + name='hearts_days', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='order', + name='question_credits', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='transaction', + name='gateway_ref_id', + field=models.CharField(blank=True, max_length=300), + ), + ] diff --git a/payment/models.py b/payment/models.py index fa21a0e..63e10eb 100644 --- a/payment/models.py +++ b/payment/models.py @@ -30,6 +30,12 @@ class Order(models.Model): # پیش از ۲۰۲۶-۱۰-۰۱ این دو را ندارند (null / خالی). list_price = models.PositiveIntegerField(null=True, blank=True) plan_title = models.CharField(max_length=50, blank=True) + # آنچه این سفارش فروخته، در لحظه‌ی ساخت (create_order). پرداخت و بازپرداخت همین‌ها را می‌دهند و + # پس می‌گیرند، نه عددهای فعلیِ پلن: پیش‌تر اگر ادمین پلن را بینِ ساختِ سفارش و پرداخت عوض می‌کرد، + # خریدارِ بسته‌ی ۴۵ هزار تومانی ۱۵۰ سوال می‌گرفت. null یعنی سفارشِ بی‌پلن یا ساخته‌شده پیش از این ستون. + question_credits = models.PositiveIntegerField(null=True, blank=True) + hearts_days = models.PositiveIntegerField(null=True, blank=True) + duration_days = models.PositiveIntegerField(null=True, blank=True) currency = models.CharField(max_length=10, default='IRT') status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending') provider = models.CharField(max_length=10, choices=PROVIDER_CHOICES, default='gateway') @@ -57,7 +63,8 @@ class Order(models.Model): class Transaction(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='transactions') gateway = models.CharField(max_length=30, default='zarinpal') - gateway_ref_id = models.CharField(max_length=100, blank=True) + # ۳۰۰ مثلِ Order.bazaar_purchase_token: توکنِ خریدِ بازار همین‌جا می‌نشیند. + gateway_ref_id = models.CharField(max_length=300, blank=True) status = models.CharField(max_length=30) paid_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/payment/services.py b/payment/services.py index 5d557e1..25aba1d 100644 --- a/payment/services.py +++ b/payment/services.py @@ -100,12 +100,18 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT', payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing) return existing, payment_url + from subscription.models import Plan + + plan = Plan.objects.filter(pk=plan_id).first() if plan_id else None try: with db_transaction.atomic(): order = Order.objects.create( user_id=user_id, subscription_id=subscription_id, plan_id=plan_id, + question_credits=plan.question_credits if plan else None, + hearts_days=plan.hearts_days if plan else None, + duration_days=plan.duration_days if plan else None, amount=amount, list_price=amount, plan_title=plan_title[:50], @@ -206,7 +212,8 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st if newly_paid: if order.subscription_id: from subscription.services import activate_subscription - activate_subscription(order.subscription_id, plan_id=order.plan_id) + activate_subscription(order.subscription_id, plan_id=order.plan_id, + duration_days=order.duration_days) elif order.plan_id: # بسته‌ی سوال: اعتبار و روزهای قلبِ نامحدود، بی‌دست زدن به اشتراک. from subscription.services import grant_pack @@ -314,7 +321,7 @@ def refund_order(order: Order, *, staff: str = '') -> dict: if order.subscription_id: sub = Subscription.objects.select_for_update().filter(pk=order.subscription_id).first() - days = plan.duration_days if plan else 30 + days = order.duration_days if order.duration_days is not None else (plan.duration_days if plan else 30) if sub is not None and sub.expires_at is not None: sub.expires_at = max(sub.expires_at - timezone.timedelta(days=days), now) if sub.expires_at <= now: @@ -331,12 +338,13 @@ def refund_order(order: Order, *, staff: str = '') -> dict: if take: QuestionCredit.objects.create(user_id=order.user_id, delta=-take, reason='refund', order_id=order.id, note=f'بازپرداختِ سفارشِ {order.id}') - if plan is not None and plan.hearts_days: + hearts_days = order.hearts_days if order.hearts_days is not None else (plan.hearts_days if plan else 0) + if hearts_days: hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first() if hp is not None: - hp.expires_at = max(hp.expires_at - timezone.timedelta(days=plan.hearts_days), now) + hp.expires_at = max(hp.expires_at - timezone.timedelta(days=hearts_days), now) hp.save(update_fields=['expires_at']) - summary['hearts_days'] = plan.hearts_days + summary['hearts_days'] = hearts_days from affiliate.models import Conversion Conversion.objects.filter(order_id=order.id, status='paid').update( diff --git a/payment/tests.py b/payment/tests.py index 3b0382b..a31235c 100644 --- a/payment/tests.py +++ b/payment/tests.py @@ -46,7 +46,7 @@ class SecretDefaultsFailClosedTests(TestCase): ) self.assertTrue(HasInternalServiceKey().has_permission(internal_request, None)) - def test_debug_mode_local_fallback_still_valid(self): + def test_debug_mode_has_no_default_internal_key(self): # settings.py این مقادیر رو یک‌بار موقعِ import محاسبه می‌کنه، نه # چیزی که override_settings بعداً toggle کنه؛ پس برای تستِ این # منطق باید خودِ ماژول رو reload کرد. @@ -63,10 +63,8 @@ class SecretDefaultsFailClosedTests(TestCase): with patch.dict(os.environ, clean_env, clear=True): importlib.reload(settings_module) self.assertTrue(settings_module.DEBUG) - self.assertEqual( - settings_module.INTERNAL_SERVICE_KEY, - 'dev-internal-service-key-change-me', - ) + # رشته‌ی پیش‌فرضِ داخلِ ریپو هر لپ‌تاپِ توسعه‌ی بازِ روی شبکه را به ingest راه می‌داد. + self.assertEqual(settings_module.INTERNAL_SERVICE_KEY, '') finally: # همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمی‌گردونیم تا روی # بقیه‌ی تست‌ها اثر نذاره. diff --git a/payment/tests_security_review.py b/payment/tests_security_review.py new file mode 100644 index 0000000..0407ea2 --- /dev/null +++ b/payment/tests_security_review.py @@ -0,0 +1,120 @@ +""" +حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (پرداخت). هر تست رفتاری را نگه می‌دارد که پیش‌تر غلط بود. +""" +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.core.cache import cache +from django.core.management import call_command +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import User +from discount.models import DiscountCode +from subscription.models import Plan +from subscription.services import credit_balance + +from .bazaar import BazaarPurchaseResult +from .models import Order, Transaction +from .services import apply_order_payment_result + + +def client_for(user): + c = APIClient() + c.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') + return c + + +class _Base(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create(phone='09120009901', is_guest=False) + self.app = client_for(self.user) + self.pack = Plan.objects.get(kind='pack', question_credits=20) + + def tearDown(self): + cache.clear() + + def pack_order(self, **extra): + r = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), **extra}, format='json') + return Order.objects.get(pk=r.json()['orderId']) + + +class SupervisorCannotRunMoneyActionsTests(_Base): + """گروهِ Supervisor فقط view_order دارد؛ actionِ بی‌permissions برای هر بیننده‌ی فهرست اجرا می‌شد.""" + + def setUp(self): + super().setUp() + call_command('setup_supervisor_group', verbosity=0) + staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True) + staff.groups.add(Group.objects.get(name='Supervisor')) + self.client.force_login(staff) + + def test_refund_is_refused(self): + order = self.pack_order() + apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='R1', new_status='paid', event='t') + self.client.post('/admin/payment/order/', {'action': 'refund', '_selected_action': [order.pk], 'confirm': 'yes'}) + order.refresh_from_db() + self.assertEqual(order.status, 'paid') + self.assertEqual(credit_balance(self.user.id), 20) + + def test_cancel_is_refused(self): + order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-c1') + self.client.post('/admin/payment/order/', {'action': 'cancel_pending', '_selected_action': [order.pk]}) + order.refresh_from_db() + self.assertEqual(order.status, 'pending') + + +class CallbackWithoutAuthorityTests(_Base): + def test_missing_authority_is_400_and_touches_no_order(self): + DiscountCode.objects.create(code='SEC50', type='percent', value=50, max_uses=10) + order = self.pack_order() + self.assertEqual(self.app.post('/api/discount/redeem', {'code': 'SEC50', 'orderId': str(order.id)}, + format='json').status_code, 200) + Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-b') # دومین سفارشِ بازنشده + + resp = APIClient().get('/api/payment/zarinpal/callback', {'Status': 'NOK'}) + self.assertEqual(resp.status_code, 400) + order.refresh_from_db() + self.assertEqual(order.status, 'pending') + + +class PlanEditedBetweenOrderAndPaymentTests(_Base): + def test_buyer_gets_what_the_order_sold(self): + order = self.pack_order() + Plan.objects.filter(pk=self.pack.pk).update(price=order.amount * 5, question_credits=150, hearts_days=60) + apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='P1', new_status='paid', event='t') + self.assertEqual(credit_balance(self.user.id), 20) + self.assertEqual(order.question_credits, 20) + + +@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example') +class BazaarSecondTokenOnPaidOrderTests(_Base): + def setUp(self): + super().setUp() + Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-pack20') + + def test_second_token_is_409_and_stays_usable(self): + order = self.pack_order(paymentMethod='bazaar') + valid = BazaarPurchaseResult(is_valid=True, purchase_state=0) + with patch('payment.bazaar.verify_purchase', return_value=valid): + first = self.app.post('/api/payment/bazaar/verify', + {'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json') + again = self.app.post('/api/payment/bazaar/verify', + {'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json') + second = self.app.post('/api/payment/bazaar/verify', + {'orderId': str(order.id), 'purchaseToken': 'TOKEN-2'}, format='json') + self.assertEqual(first.json()['status'], 'paid') + self.assertEqual(again.status_code, 200) # همان توکن دوباره: بی‌اثر و بی‌خطا + self.assertEqual(second.status_code, 409) + self.assertFalse(Transaction.objects.filter(gateway_ref_id='TOKEN-2').exists()) + + r2 = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), 'paymentMethod': 'bazaar'}, + format='json', HTTP_IDEMPOTENCY_KEY='sec-new') + with patch('payment.bazaar.verify_purchase', return_value=valid): + fresh = self.app.post('/api/payment/bazaar/verify', + {'orderId': r2.json()['orderId'], 'purchaseToken': 'TOKEN-2'}, format='json') + self.assertEqual(fresh.json()['status'], 'paid') + self.assertEqual(credit_balance(self.user.id), 40) diff --git a/payment/views.py b/payment/views.py index e376ea5..383ab59 100644 --- a/payment/views.py +++ b/payment/views.py @@ -7,6 +7,7 @@ from django.utils.html import escape from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response @@ -107,7 +108,7 @@ class CreateOrderView(APIView): if v.get('planId'): from subscription.models import Plan - plan = Plan.objects.filter(pk=v['planId']).first() if str(v['planId']).isdigit() else None + plan = Plan.objects.filter(pk=v['planId']).first() if (str(v['planId']).isascii() and str(v['planId']).isdigit()) else None if plan is None: raise ValidationError({'planId': 'پلنی با این شناسه وجود ندارد.'}) @@ -225,6 +226,10 @@ class ZarinpalCallbackView(APIView): def get(self, request): authority = request.query_params.get('Authority', '') status_param = request.query_params.get('Status', '') + # بی‌Authority، جستجو هر سفارشی را می‌گرفت که صفحه‌ی پرداختش هنوز باز نشده (authority خالی): + # یک NOKِ ناشناس آن را «ناموفق» می‌کرد و با دو سفارش ۵۰۰ می‌داد. + if not authority: + raise ValidationError({'Authority': 'لازم است.'}) order = get_object_or_404(Order, gateway_authority=authority, provider='gateway') if status_param != 'OK': @@ -338,6 +343,8 @@ class BazaarVerifyPurchaseView(APIView): # می‌شد؛ یعنی توکنِ خریدِ ارزان‌ترین محصول سفارشِ گران‌ترین پلن را «paid» می‌کرد. if not order_lookup.bazaar_product_sku: raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'}) + if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']): + return self._second_purchase_response() result = bazaar.verify_purchase( product_sku=order_lookup.bazaar_product_sku, purchase_token=v['purchaseToken'], @@ -349,6 +356,8 @@ class BazaarVerifyPurchaseView(APIView): pk=v['orderId'], user_id=request.user.id, provider='bazaar', ) + if self._second_purchase_on_paid_order(order, v['purchaseToken']): + return self._second_purchase_response() new_status = 'paid' if result.is_valid else 'failed' order.bazaar_purchase_token = v['purchaseToken'] order.save(update_fields=['bazaar_purchase_token']) @@ -367,6 +376,20 @@ class BazaarVerifyPurchaseView(APIView): data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid} return Response(BazaarVerifyResponseSerializer(data).data) + @staticmethod + def _second_purchase_on_paid_order(order, purchase_token: str) -> bool: + """ + خریدِ دیگری برای سفارشی که با یک خریدِ دیگر پرداخت شده. پیش‌تر پاسخ «paid» بود، چیزی داده + نمی‌شد و توکنِ دوم هم برای هر سفارشِ بعدی سوخته بود؛ کاربر دو بار پول داده بود. + """ + return (order.status == 'paid' and bool(order.bazaar_purchase_token) + and order.bazaar_purchase_token != purchase_token) + + @staticmethod + def _second_purchase_response(): + return Response({'detail': 'این سفارش با خریدِ دیگری پرداخت شده است؛ برای این خرید سفارشِ تازه بساز.'}, + status=status.HTTP_409_CONFLICT) + class OrderDetailView(APIView): """GET /api/payment/orders/{id} — وضعیت سفارش""" diff --git a/progress/views.py b/progress/views.py index a061395..2a0ec36 100644 --- a/progress/views.py +++ b/progress/views.py @@ -255,7 +255,10 @@ class SelectCharacterView(APIView): def post(self, request): serializer = SelectCharacterRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) - character = get_object_or_404(Character, pk=serializer.validated_data['characterId']) + character_id = serializer.validated_data['characterId'] + if not (character_id.isascii() and character_id.isdigit()): + raise ValidationError({'characterId': 'شناسه‌ی کاراکتر باید عدد باشد.'}) + character = get_object_or_404(Character, pk=int(character_id)) stats, _ = UserStats.objects.get_or_create(user_id=request.user.id) if stats.xp < character.unlock_xp: diff --git a/rag/services/diagram.py b/rag/services/diagram.py index 86257ac..e578a11 100644 --- a/rag/services/diagram.py +++ b/rag/services/diagram.py @@ -1053,8 +1053,20 @@ def _normalize(obj): return obj +# بزرگ‌ترین شکلِ معتبرِ سنجش‌ها چند کیلوبایت بود؛ بیش از این نشانه‌ی خروجیِ خراب یا دست‌کاری‌شده است. +MAX_DIAGRAM_CHARS = 8000 + + def parse(body: str) -> dict | None: + """ + شکلِ معتبر یا None. هیچ خطایی بیرون نمی‌رود: شکل اختیاری است، و خطایی که از این‌جا بالا می‌رفت + (OverflowError از عددِ ۴۰۰ رقمی، RecursionError از JSONِ خیلی تودرتو) در استریم کلِ جواب را + «شکست» حساب می‌کرد و سوال را رایگان و بی‌ثبت می‌گذاشت، در حالی که متن به کاربر رسیده بود. + """ body = (body or '').strip() + if len(body) > MAX_DIAGRAM_CHARS: + logger.warning('diagram: بلوکِ شکل %s نویسه بود (سقف %s)، دور ریخته شد', len(body), MAX_DIAGRAM_CHARS) + return None if body.startswith('```'): body = body.strip('`').removeprefix('json').strip() try: @@ -1062,6 +1074,9 @@ def parse(body: str) -> dict | None: except (json.JSONDecodeError, TypeError, ValueError): logger.info('diagram: JSONِ نامعتبر از مدل، دور ریخته شد') return None + except Exception: + logger.exception('diagram: خطای پیش‌بینی‌نشده در خواندنِ شکل، دور ریخته شد') + return None class StreamSplitter: diff --git a/rag/services/generation.py b/rag/services/generation.py index a6cca78..2b7492e 100644 --- a/rag/services/generation.py +++ b/rag/services/generation.py @@ -402,7 +402,15 @@ def stream_answer(question: str, context_chunks: list[dict] | None, if not clean: continue if released: - yield clean.replace(guardrails.OFF_TOPIC_MARKER, '') + if guardrails.OFF_TOPIC_MARKER in clean: + # علامت دیر آمد: بخشِ پیش از آن رفته است، ولی بقیه نمی‌رود و جواب «ردشده» حساب می‌شود + # تا نه ذخیره شود نه در کش برود (پیش‌تر کاربرِ بعدی همین متن را از کش می‌گرفت). + head = clean.split(guardrails.OFF_TOPIC_MARKER, 1)[0] + if head.strip(): + yield head + refused = True + continue + yield clean continue held += clean if guardrails.OFF_TOPIC_MARKER in held: @@ -420,7 +428,7 @@ def stream_answer(question: str, context_chunks: list[dict] | None, result['diagram'] = None if refused else diagram_mod.fits( f'{question}\n{focus}\n{_previous_question(history)}', splitter.diagram) if refused: - yield guardrails.REFUSAL + yield ('\n\n' if released else '') + guardrails.REFUSAL return if not released and held.strip(): yield held diff --git a/rag/services/retrieval.py b/rag/services/retrieval.py index 9c1c6b2..bed5550 100644 --- a/rag/services/retrieval.py +++ b/rag/services/retrieval.py @@ -10,7 +10,9 @@ import logging from .config import build_collection_name, resolve_grade_title, retrieval_top_k from .embedding import embed_text -from .known_collections import filter_key_for_subject, is_math_subject, resolve_math_chapter +from .known_collections import ( + KNOWN_COLLECTIONS, filter_key_for_subject, is_math_subject, resolve_math_chapter, +) from .qdrant_store import search logger = logging.getLogger(__name__) @@ -29,6 +31,12 @@ def resolve_target(subject: str, grade_title: str, grade_id, lesson_number): return None, None, None collection = build_collection_name(subject, grade_title) + if collection not in KNOWN_COLLECTIONS: + # subject و grade می‌توانند از بدنه‌ی درخواست بیایند؛ بی‌این چک هر کاربری از هر collectionِ + # Qdrantِ مشترک (از جمله مالِ تیمِ دیگر) متن می‌خواند و در usedChunks پس می‌گرفت. + # نتیجه همان رفتارِ collectionِ ناموجود است: جوابِ بی‌context. + logger.warning('rag.retrieve: collection %r در KNOWN_COLLECTIONS نیست، جستجو نشد', collection) + return None, None, None filter_key = filter_key_for_subject(subject) if is_math_subject(subject): filter_value = resolve_math_chapter(subject, grade_title, lesson_number) diff --git a/rag/tests.py b/rag/tests.py index e6b68a5..75873cd 100644 --- a/rag/tests.py +++ b/rag/tests.py @@ -65,7 +65,8 @@ class _FakeQdrant: return SimpleNamespace(points=pts) -@override_settings(**RAG_ON) +# «علوم تجربی سوم» در KNOWN_COLLECTIONS هست؛ collectionِ ناشناخته اصلاً جستجو نمی‌شود. +@override_settings(**{**RAG_ON, 'RAG_GRADE_TITLE_OVERRIDES': {1: 'سوم'}}) class AskAIRagPathTests(TestCase): def setUp(self): cache.clear() @@ -129,6 +130,7 @@ class IngestTextTests(TestCase): reset_clients_cache() self.client = APIClient() + @override_settings(INTERNAL_SERVICE_KEY='test-internal-key') def test_ingest_text_creates_chunks(self): qdrant = _FakeQdrant() reply = '{"summary": "خلاصه‌ی این تکه."}' @@ -142,7 +144,7 @@ class IngestTextTests(TestCase): 'text': 'الف' * 900, }, format='json', - HTTP_X_INTERNAL_KEY='dev-internal-service-key-change-me', + HTTP_X_INTERNAL_KEY='test-internal-key', ) self.assertEqual(resp.status_code, 200, resp.content) self.assertGreater(resp.data['chunksCreated'], 0) diff --git a/subscription/services.py b/subscription/services.py index 8adb425..045a026 100644 --- a/subscription/services.py +++ b/subscription/services.py @@ -76,15 +76,19 @@ def grant_pack(order) -> bool: plan = Plan.objects.filter(pk=order.plan_id, kind='pack').first() if plan is None: return False + # عددها از خودِ سفارش (همان که فروخته شد)؛ سفارشِ بی‌این ستون‌ها (پیش از آن‌ها، یا ساخته‌شده بیرون + # از create_order) عددهای فعلیِ پلن را می‌گیرد، همان رفتارِ پیشین. + credits = order.question_credits if order.question_credits is not None else plan.question_credits + hearts_days = order.hearts_days if order.hearts_days is not None else plan.hearts_days try: with transaction.atomic(): - QuestionCredit.objects.create(user_id=order.user_id, delta=plan.question_credits, + QuestionCredit.objects.create(user_id=order.user_id, delta=credits, reason='purchase', order_id=order.id, note=plan.title[:100]) - if plan.hearts_days: + if hearts_days: now = timezone.now() hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first() start = hp.expires_at if hp and hp.expires_at > now else now - until = start + timezone.timedelta(days=plan.hearts_days) + until = start + timezone.timedelta(days=hearts_days) HeartsPass.objects.update_or_create(user_id=order.user_id, defaults={'expires_at': until}) except IntegrityError: return False @@ -111,7 +115,7 @@ def ai_daily_quota(user_id: int) -> int: return max(quotas) if quotas else settings.AI_DAILY_QUOTA_FREE -def activate_subscription(subscription_id: int, plan_id: int | None = None): +def activate_subscription(subscription_id: int, plan_id: int | None = None, duration_days: int | None = None): """ بعد از تاییدِ پرداخت صدا زده می‌شود (کال‌بکِ زرین‌پال، تاییدِ بازار). @@ -136,7 +140,9 @@ def activate_subscription(subscription_id: int, plan_id: int | None = None): # باید دیده شود. پلن را پاک نکنید؛ on_sale=False کنید. logger.error('activate_subscription: پلنِ %s برای اشتراکِ %s پیدا نشد', plan_id, subscription_id) plan = plan or subscription.plan - duration_days = plan.duration_days if plan else 30 + # duration_days از سفارش (عددِ لحظه‌ی خرید)؛ None برای سفارشِ پیش از آن ستون. + if duration_days is None: + duration_days = plan.duration_days if plan else 30 now = timezone.now() still_running = ( old_status == 'active' and subscription.expires_at is not None diff --git a/support/serializers.py b/support/serializers.py index 43cbe66..7c8a0ea 100644 --- a/support/serializers.py +++ b/support/serializers.py @@ -3,7 +3,8 @@ from rest_framework import serializers class BugReportCreateRequestSerializer(serializers.Serializer): title = serializers.CharField(max_length=150) - description = serializers.CharField() + # سقف: پیش‌تر هر متن تا حدِ بدنه‌ی درخواست (~۲٫۵ مگابایت) پذیرفته و ذخیره می‌شد. + description = serializers.CharField(max_length=5000) screenshotUrl = serializers.URLField(required=False, allow_blank=True) @@ -50,7 +51,7 @@ class TicketDetailResponseSerializer(serializers.Serializer): class TicketMessageCreateRequestSerializer(serializers.Serializer): - text = serializers.CharField() + text = serializers.CharField(max_length=5000) class TicketMessageCreateResponseSerializer(serializers.Serializer):