Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and curriculum import need add/delete; content browser needs view_question; guest device ids are shown to superusers only. - Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles. - Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409; orders snapshot credits/hearts/days at creation (payment.0011). - Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker refuses the answer (not stored, not cached); a disconnect saves the partial answer; only KNOWN_COLLECTIONS are searched. - Infra: secret-key file is created atomically (workers no longer split keys); no default INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400; ticket text capped at 5000. - docs/security-before-production.md: remaining open items 24-31 and the guard tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -100,8 +100,10 @@ BAZAAR_REFRESH_TOKEN=
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۶) اختیاری — پیشفرضها معمولاً کافیاند
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی روی سرور = آن مسیرها بستهاند.
|
||||
# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی = آن مسیرها بستهاند (در DEBUG هم پیشفرضی ندارد).
|
||||
# INTERNAL_SERVICE_KEY=
|
||||
# مهمانهای نمایشی که با «حذفِ حساب» پاک نمیشوند (با کاما). پیشفرض: frontend-test-device.
|
||||
# DEMO_GUEST_DEVICE_IDS=
|
||||
# با صفر قلب جلوی جواب دادن گرفته شود؟ پیشفرض روشن (از ۲۰۲۶-۱۰-۰۲)؛ ۰ خاموشش میکند.
|
||||
# HEARTS_ENFORCED=1
|
||||
# سقفهای کدِ ورود: هر شماره در روز، و کلِ سامانه در روز (ضدِ SMS pumping).
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,7 +10,7 @@ data/textbooks/*
|
||||
!data/textbooks/README.md
|
||||
|
||||
# کلیدِ امضای تولیدشدهی خودکار (هرگز کامیت نشود)
|
||||
.django_secret_key
|
||||
.django_secret_key*
|
||||
|
||||
# خروجیِ collectstatic — تولیدی است، در هر دیپلوی از نو ساخته میشود.
|
||||
staticfiles/
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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',)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
179
accounts/tests_security_review.py
Normal file
179
accounts/tests_security_review.py
Normal file
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
138
ai_tutor/tests_security_review.py
Normal file
138
ai_tutor/tests_security_review.py
Normal file
@@ -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 = '<diagram>{"kind":"numberline","min":1' + '0' * 400 + '*2,"max":5}</diagram>'
|
||||
DEEP_DIAGRAM = '<diagram>' + '[' * 3000 + ']' * 3000 + '</diagram>'
|
||||
|
||||
|
||||
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'], [])
|
||||
@@ -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
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
@@ -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 رندر میشود و نقطهی جمله و ترتیبِ
|
||||
# ستونهای جدول جابهجا میشوند. توضیح در خودِ قالب.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
هر سوال با دو سیگنال: گزارشِ بازِ کاربران، و درصدِ پاسخِ غلط.
|
||||
|
||||
80
content/tests_security_review.py
Normal file
80
content/tests_security_review.py
Normal file
@@ -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))
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` | رابطِ دیباگِ اختیاری |
|
||||
|
||||
|
||||
@@ -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` بهعنوانِ
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
33
payment/migrations/0011_order_plan_snapshot.py
Normal file
33
payment/migrations/0011_order_plan_snapshot.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمیگردونیم تا روی
|
||||
# بقیهی تستها اثر نذاره.
|
||||
|
||||
120
payment/tests_security_review.py
Normal file
120
payment/tests_security_review.py
Normal file
@@ -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)
|
||||
@@ -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} — وضعیت سفارش"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user