cleanup: trim bloated multi-line comments across the project

This commit is contained in:
2026-08-26 03:22:20 +03:30
parent 83435c5a5f
commit 683bd40169
27 changed files with 235 additions and 699 deletions

View File

@@ -56,47 +56,19 @@ class UserAdmin(admin.ModelAdmin):
list_filter = ('is_guest',) list_filter = ('is_guest',)
actions = ['send_sms_to_selected'] actions = ['send_sms_to_selected']
# permissions=['change']: بدونِ این، جنگو این اکشن رو بر اساسِ # permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor)
# has_change_permission فیلتر نمی‌کنه (فقط اکشن‌هایی که با پارامترِ # می‌تونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛
# permissions تعریف شده باشن فیلتر می‌شن)، یعنی کاربری که فقط # این پارامتر باعث می‌شه ModelAdmin فقط برای change_user در دسترس بذارتش.
# view_user داره (مثلِ گروهِ Supervisor) می‌تونه با یک POSTِ مستقیم
# همین اکشن رو صدا بزنه و واقعاً پیامک بفرسته، حتی بدونِ اینکه دکمه‌ی
# اکشن رو در UI ببینه. با permissions=['change']، ModelAdmin.response_action
# خودش (get_actions -> _filter_actions_by_permissions) این اکشن رو
# فقط برای کاربرانِ دارایِ change_user در دسترس می‌ذاره؛ برای بقیه
# (مثلِ Supervisor) اصلاً در actions ثبت‌شده نیست، پس درخواستِ مستقیم
# هم بی‌اثر می‌مونه (پیامِ هشدارِ استانداردِ «اکشنی انتخاب نشده»).
@admin.action(description='ارسال پیامک به کاربران انتخاب‌شده', permissions=['change']) @admin.action(description='ارسال پیامک به کاربران انتخاب‌شده', permissions=['change'])
def send_sms_to_selected(self, request, queryset): def send_sms_to_selected(self, request, queryset):
""" """اکشنِ ادمین برای ارسالِ پیامک به کاربرانِ انتخاب‌شده یا غیرفعال؛
قدمِ اولیه‌ی مینیمالِ فیچرِ «ارسالِ پیامک از ادمین»: یک اکشنِ ادمینِ گیرنده با فیلدِ «target» انتخاب می‌شه (cutoffِ «غیرفعال» رو خودِ ادمین
معمولی روی UserAdmin که یک صفحه‌ی میانیِ ساده (فرمِ متنِ پیامک) رو در فرم وارد می‌کنه، چیزی hardcode نیست).
نشون می‌ده و بعد از تأیید، دقیقاً به همون کاربرهایی که در changelist
انتخاب شدن، همون متن رو با accounts.services.send_sms موجود ارسال
می‌کنه.
گیرنده‌ها با فیلدِ «target» در فرم انتخاب می‌شن: ارسال کاملاً synchronous و توی همون requestه (نه صفِ پس‌زمینه)، پس
- 'selected' (پیش‌فرض): همون queryset که ادمین در changelist اگر گیرنده‌ها از settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشن، اکشن
انتخاب کرده — رفتارِ قبلی، بدون تغییر. هیچ پیامکی نمی‌فرسته تا request تایم‌اوت نکنه یا provider overload نشه.
- 'inactive': کاربرانِ غیرفعال، با استفاده از شکستِ ارسال برای یک کاربر بقیه را متوقف نمی‌کنه.
accounts.services.get_inactive_users(since) — cutoff (تاریخ)
رو خودِ ادمین در فرم وارد می‌کنه، هیچ عددِ روزی hardcode نشده.
عمداً محدود نگه داشته شده:
- هیچ مدلِ کمپین/بدنه‌ی جدیدی ساخته/persist نمی‌شه (نه رکوردی از
پیام، نه رکوردی از تحویل)؛ ارسال کاملاً بلادرنگه.
- هیچ زمان‌بندی، صفِ پس‌زمینه، یا فیلترِ هدف‌گیریِ دیگه‌ای (غیر از
selected/inactive) نیست.
- محافظتِ سقف: چون ارسال هنوز کاملاً synchronous و توی همون
request انجام می‌شه (نه صفِ پس‌زمینه)، اگر تعدادِ گیرنده‌ها از
settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشه، اکشن هیچ پیامکی
ارسال نمی‌کنه و فقط خطا نشون می‌ده — تا یک انتخاب/تاریخِ خیلی
بزرگ باعثِ تایم‌اوتِ request یا overload شدنِ پروایدر نشه.
- هیچ منطقِ provider تکرار نشده؛ send_sms عیناً همون تابعِ موجودیه
که OTP و campaigns هم ازش استفاده می‌کنن.
- کاربرانِ بدونِ شماره‌تلفن (مثلِ مهمان) به‌سادگی skip می‌شن.
- شکستِ ارسال برای یک کاربر (SMSDeliveryError) کلِ اکشن رو متوقف
نمی‌کنه؛ فقط شمارشِ failed رو زیاد می‌کنه و برای بقیه ادامه می‌ده.
""" """
if 'apply' in request.POST: if 'apply' in request.POST:
form = SendSMSForm(request.POST) form = SendSMSForm(request.POST)
@@ -123,10 +95,8 @@ class UserAdmin(admin.ModelAdmin):
), ),
level=messages.ERROR, level=messages.ERROR,
) )
# به‌جای return None، به رندرِ مشترکِ پایینِ تابع سقوط # سقوط به رندرِ مشترکِ پایین (نه return None) چون None از
# می‌کنیم تا فرم دوباره (بدونِ ارسالِ هیچ پیامکی) نشون # یک اکشنِ ادمین باعثِ ریدایرکتِ ۳۰۲ می‌شه، نه رندرِ مستقیم.
# داده بشه — دقیقاً همون الگویی که برای فرمِ نامعتبر
# استفاده می‌شه.
else: else:
sent_count = 0 sent_count = 0
failed_count = 0 failed_count = 0
@@ -158,12 +128,8 @@ class UserAdmin(admin.ModelAdmin):
f'{skipped_no_phone} کاربر بدونِ شماره‌تلفن رد شدن (پیامک ارسال نشد).', f'{skipped_no_phone} کاربر بدونِ شماره‌تلفن رد شدن (پیامک ارسال نشد).',
level=messages.WARNING, level=messages.WARNING,
) )
# توجه: قبلاً اینجا `return None` بود؛ حذف شد چون در # سقوط به رندرِ مشترکِ پایین (نه return None)، هم‌الگو با
# جنگو، None برگردوندن از یک اکشنِ ادمین باعثِ یک # شاخه‌ی cap-exceeded.
# ریدایرکتِ ۳۰۲ واقعی می‌شه (نه رندرِ مستقیمِ ۲۰۰)،
# درست برخلافِ چیزی که این کامنت قبلاً فرض کرده بود.
# حالا هم‌الگو با شاخه‌ی cap-exceeded، سقوط می‌کنیم به
# رندرِ مشترکِ پایینِ تابع.
else: else:
form = SendSMSForm() form = SendSMSForm()
@@ -195,38 +161,15 @@ class OTPCodeAdmin(admin.ModelAdmin):
list_filter = ('is_used',) list_filter = ('is_used',)
# ───────────────────────────────────────────────────────────────────────── # صفحه‌ی «ناظر» (Supervisor): یک ایندکسِ read-only از لینک‌ها به
# صفحه‌ی «ناظر» (Supervisor) # changelistهای موجود (کاربران/پرداخت‌ها/اشتراک‌ها)، داخلِ همون AdminSite
# پیش‌فرض — نه AdminSite جدا و بدون هیچ ModelAdmin جدید.
# #
# یک صفحه‌ی خیلی کوچیک و *read-only* داخلِ همون AdminSiteِ پیش‌فرضِ جنگو # read-only بودن با پنهون‌کردنِ لینک تضمین نمی‌شه، بلکه با پرمیشنِ استانداردِ
# (django.contrib.admin.site) — نه یک AdminSite جدا، و نه CRUDِ موازی. # جنگو: هر لینک فقط با view_<model> نشون داده می‌شه و حتی دسترسیِ مستقیم به
# این صفحه صرفاً یک ایندکسِ گروهی از لینک‌هایی به همون changelistهای # URLِ change/add/delete هم توسطِ پرمیشنِ پیش‌فرضِ همون ModelAdminها رد می‌شه
# ModelAdmin موجودِ پروژه‌ست: کاربران (accounts.UserAdmin)، پرداخت‌ها # (گروهِ Supervisor فقط view_* داره). is_staff به‌تنهایی برای ورود کافی
# (payment.OrderAdmin / payment.TransactionAdmin) و اشتراک‌ها # نیست؛ عضویت در گروهِ Supervisor هم چک می‌شه، وگرنه PermissionDenied.
# (subscription.SubscriptionAdmin). هیچ منطقِ کسب‌وکار یا ModelAdmin
# جدیدی اینجا ساخته نمی‌شه.
#
# محدودیتِ واقعیِ read-only از طریقِ سیستمِ استانداردِ Group/Permission
# جنگو تأمین می‌شه، نه با پنهون‌کردنِ لینک:
# - هر لینک در این صفحه فقط وقتی نشون داده می‌شه که کاربر پرمیشنِ
# view_<model> مربوطه رو داشته باشه (request.user.has_perm(...)).
# - حتی اگه کسی مستقیماً URL تغییر/افزودن/حذف رو حدس بزنه، خودِ
# changelist/change/add/delete viewهای اون ModelAdminها (که اینجا هیچ
# override‌ای نشدن) طبقِ has_view_permission/has_change_permission/
# has_add_permission/has_delete_permission پیش‌فرضِ ModelAdmin — که
# مستقیماً از Permission مدلِ کاربر می‌خونن — چک می‌کنن؛ یعنی گروهِ
# Supervisor (که با دستورِ مدیریتیِ setup_supervisor_group فقط پرمیشنِ
# view_* می‌گیره) حتی با دسترسیِ مستقیم به URL هم نمی‌تونه add/change/
# delete کنه.
# - خودِ صفحه هم مثلِ هر صفحه‌ی دیگه‌ی Admin با admin.site.admin_view
# wrap شده، یعنی همون احرازِ هویت/is_staff استانداردِ Admin رو داره؛
# ولی is_staff به‌تنهایی برای *ورود به خودِ صفحه* کافی نیست — پایین‌تر
# قبل از هر کارِ دیگه‌ای عضویتِ کاربر در گروهِ Supervisor هم چک می‌شه
# (request.user.groups.filter(name=SUPERVISOR_GROUP_NAME))، وگرنه
# PermissionDenied (۴۰۳) بالا می‌ره. این یک سیستمِ پرمیشنِ جدید نیست؛
# فقط از همون گروهِ Supervisorِ موجود (که setup_supervisor_group
# می‌سازه) برای گیت‌کیپینگِ ورود به صفحه استفاده می‌کنه.
# ─────────────────────────────────────────────────────────────────────────
SUPERVISOR_GROUP_NAME = 'Supervisor' SUPERVISOR_GROUP_NAME = 'Supervisor'

View File

@@ -104,12 +104,9 @@ def send_sms(*, phone: str, message: str) -> None:
) )
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.') raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
# ⚠️ کاوه‌نگار حتی برای شکست‌های سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبار # کاوه‌نگار حتی برای شکست‌های سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبارِ ناکافی و…)
# ناکافی، گیرنده‌ی نامعتبر و…) HTTP 200 برمی‌گردونه؛ نتیجه‌ی واقعی توی # HTTP 200 برمی‌گردونه؛ نتیجه‌ی واقعی توی body.return.status هست. فقط
# بدنه‌ی JSON، زیرِ return.status هست (200 یعنی موفق). صرفِ # اون فیلد موفقیت رو تعیین می‌کنه، نه status_code.
# status_code == 200 «موفقیت» در نظر گرفته نمی‌شه؛ بدنه parse می‌شه و
# فقط return.status == 200 موفقیت محسوب می‌شه؛ هر پاسخِ ناقص/غیرمنتظره
# هم شکستِ تحویل در نظر گرفته می‌شه (نه موفقیت).
try: try:
body = response.json() body = response.json()
provider_status = body['return']['status'] provider_status = body['return']['status']
@@ -158,23 +155,10 @@ def send_otp_sms(*, phone: str, code: str) -> None:
raise OTPDeliveryError(str(exc)) from exc raise OTPDeliveryError(str(exc)) from exc
# ───────────────────────────────────────────────────────────────────────── # Targeting helpers (internal, read-only): کوئری‌های قابل‌استفاده‌ی مجدد برای
# Targeting helpers (internal, read-only) # انتخابِ زیرمجموعه‌ای از کاربران (مثلاً برای کمپین‌های پیامی)؛ فقط SELECT،
# # بدونِ ارسالِ پیام. ارجاع به subscription.Subscription با import محلی و
# هدف: صرفاً کوئری‌های قابل‌استفاده‌ی مجدد برای انتخاب زیرمجموعه‌ای از # user_id انجام می‌شه چون accounts.User هیچ‌وقت FK واقعی به اپ‌های دیگه نمی‌زنه.
# کاربران، تا فیچرهای آینده (مثلاً کمپین‌های پیامی) بدونِ تکرارِ منطقِ
# فیلتر، از همین توابع استفاده کنن. این ماژول:
# - هیچ پیامی نمی‌فرسته (نه SMS، نه notification).
# - هیچ جدول/مایگریشنِ جدیدی اضافه نمی‌کنه.
# - هیچ background job/endpoint‌ای نداره؛ فقط QuerySetِ User برمی‌گردونه.
# - هیچ رفتارِ موجودی رو تغییر نمی‌ده (فقط SELECT).
#
# ارجاع به subscription.Subscription عمداً با import محلی (داخلِ تابع) و
# بر اساسِ user_id انجام می‌شه — دقیقاً همون الگویی که payment/services.py
# و discount/services.py برای صداکردنِ subscription استفاده می‌کنن؛ چون
# accounts.User (طبقِ docstringِ خودش) هیچ‌وقت FK واقعی به مدل‌های اپ‌های
# دیگه نمی‌زنه.
# ─────────────────────────────────────────────────────────────────────────
def get_all_users(): def get_all_users():
"""همه‌ی کاربران (مهمان و ثبت‌نام‌شده).""" """همه‌ی کاربران (مهمان و ثبت‌نام‌شده)."""

View File

@@ -837,27 +837,13 @@ class GuestSessionThrottleTests(TestCase):
} }
) )
def test_repeated_guest_session_requests_are_eventually_throttled(self): def test_repeated_guest_session_requests_are_eventually_throttled(self):
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): این تست دو مشکلِ جدا # override_settings(REST_FRAMEWORK=...) به‌تنهایی رفتارِ
# داشت: # ScopedRateThrottle رو تغییر نمی‌ده: DRF مقدارِ THROTTLE_RATES رو
# ۱) `type` رو نمی‌فرستاد (پایین‌تر فیکس شده) — قراردادِ واقعیِ # یک‌بار موقعِ import، به‌عنوانِ class attribute، از دیکشنریِ اصلیِ
# API طبقِ README نیازش داره، پس هر درخواست قبل از throttle # settings.py می‌گیره؛ override_settings دیکشنریِ settings رو عوض
# با 400 رد می‌شد. # می‌کنه ولی این class attribute هنوز به نسخه‌ی قدیمی اشاره می‌کنه.
# ۲) override_settings(REST_FRAMEWORK=...) به‌تنهایی رفتارِ # برای سنجیدنِ نرخِ واقعی، مستقیماً patch.dict روی همون دیکشنریِ
# ScopedRateThrottle رو تغییر نمی‌ده: DRF مقدارِ # زنده‌ی ScopedRateThrottle.THROTTLE_RATES لازمه.
# THROTTLE_RATES رو یک‌بار موقعِ import ماژولِ throttling
# به‌عنوانِ یک class attribute از api_settings.
# DEFAULT_THROTTLE_RATES می‌گیره (همون دیکشنریِ اصلیِ
# settings.py، با reference). override_settings یک دیکشنریِ
# *جدید* برای settings.REST_FRAMEWORK می‌سازه، اما این
# class attribute هنوز به همون دیکشنریِ قدیمی اشاره می‌کنه —
# سیگنالِ setting_changed فقط api_settings رو reload می‌کنه،
# نه این class attribute رو. نتیجه: نرخِ واقعیِ اعمال‌شده
# همیشه همون دیفالتِ 20/hour در settings.py می‌موند، نه
# 3/hour، پس ۵ درخواست هیچ‌وقت به سقف نمی‌رسیدن. برای اینکه
# این تست واقعاً نرخِ محدودشده رو بسنجه، مستقیماً همون
# دیکشنریِ زنده‌ی ScopedRateThrottle.THROTTLE_RATES رو
# patch.dict می‌کنیم (که override_settings بالا هم برای
# سازگاری/مستندسازیِ نیت نگه داشته می‌شه).
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'guest_session': '3/hour'}): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'guest_session': '3/hour'}):
for i in range(3): for i in range(3):
response = self.client.post( response = self.client.post(

View File

@@ -116,24 +116,10 @@ class RequestOTPView(APIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
phone = serializer.validated_data['phone'] phone = serializer.validated_data['phone']
# حداقل فاصله‌ی زمانی بین دو درخواست برای همون شماره (burst # حداقل فاصله‌ی زمانی بین دو درخواستِ همون شماره (burst protection)؛
# protection). سقفِ ساعتیِ زیر جلویِ حجمِ کلی رو می‌گیره، اما به‌تنهایی # قبل از شمارنده‌ی ساعتی چک می‌شه تا یک درخواستِ بلاک‌شده سهمیه‌ی
# جلویِ این رو نمی‌گیره که همون ۵ تلاشِ مجاز در عرضِ چند ثانیه/کمتر # ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره می‌کنه تا
# از یک ثانیه مصرف بشن (که هم به‌معنایِ flood پیامکی روی یک شماره‌ی # retryAfter/هدرِ Retry-After بدونِ خواندنِ اضافه محاسبه بشه.
# مشخصه، هم می‌تونه با invalidate کردنِ پی‌درپیِ OTPهای قبلی
# (رفتارِ «فقط آخرین کد معتبره» که پایین‌تره) عملاً یک DoS روی
# کاربرِ واقعیِ اون شماره باشه). چک این cooldown *قبل* از شمارنده‌ی
# ساعتی انجام می‌شه تا یک درخواستِ بلاک‌شده توسطِ cooldown سهمیه‌ی
# ساعتی رو مصرف نکنه. از همون الگویِ اتمیکِ cache.add استفاده شده
# (دقیقاً همون مکانیزمِ زیر و مکانیزمِ LoginView).
#
# کلید cooldown به‌جای یک فلگِ ساده، زمانِ epoch انقضاء رو ذخیره
# می‌کنه؛ این‌طوری وقتی add شکست بخوره (کلید از قبل هست)، بدونِ
# هیچ خواندنِ ناامنی می‌شه دقیقاً محاسبه کرد چند ثانیه تا پایانِ
# cooldown مونده و اون رو هم در بدنه‌ی پاسخ (retryAfter) و هم در
# هدرِ استانداردِ Retry-After برگردوند — این‌طوری frontend
# countdown رو دقیقاً از رویِ همین پاسخِ backend شروع می‌کنه، نه
# از رویِ یک عددِ hardcode‌شده‌ی سمتِ کلاینت که مرجعِ امنیتی نیست.
phone_cooldown_key = f'otp_request_phone_cooldown:{phone}' phone_cooldown_key = f'otp_request_phone_cooldown:{phone}'
cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS
now_epoch = timezone.now().timestamp() now_epoch = timezone.now().timestamp()
@@ -161,13 +147,9 @@ class RequestOTPView(APIView):
headers={'Retry-After': str(retry_after)}, headers={'Retry-After': str(retry_after)},
) )
# محدودیت صدور OTP بر اساس شماره تلفن، مستقل از throttle IP-محورِ # محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
# otp_request بالا. throttle IP-محور به‌تنهایی کافی نیست چون یک # (وگرنه یه مهاجم می‌تونه بینِ چند IP پخش کنه). cache.add فقط وقتی
# مهاجم می‌تونه درخواست‌های تکراری برای یک شماره رو بین چند IP # کلید نیست مقدار می‌ذاره، پس اتمیکه.
# مختلف پخش کنه و پیامک OTP رو flood کنه. الگوی cache.add/incr
# همون الگوی atomic زیرِ LoginView است: cache.add فقط وقتی کلید
# وجود نداره مقدار می‌ذاره (اتمیک روی سطح backend cache)، پس دو
# درخواستِ هم‌زمان نمی‌تونن هر دو از یک non-atomic check رد بشن.
phone_requests_key = f'otp_request_phone_attempts:{phone}' phone_requests_key = f'otp_request_phone_attempts:{phone}'
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS): if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
attempt_count = 1 attempt_count = 1
@@ -202,13 +184,9 @@ class RequestOTPView(APIView):
try: try:
send_otp_sms(phone=phone, code=code) send_otp_sms(phone=phone, code=code)
except OTPDeliveryError: except OTPDeliveryError:
# ⚠️ فیکس: کاربر عملاً کد رو دریافت نکرده (ارسالِ پیامک شکست # ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک می‌کنیم
# خورده)، پس cooldownِ ۱۲۰ثانیه‌ای که بالاتر برای همین درخواست # تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/
# ثبت شده نباید جلوی تلاشِ فوریِ بعدیِ کاربر رو بگیره؛ در غیرِ # throttleِ IP دست‌نخورده می‌مونن (محدودیتِ حجم، نه این cooldown).
# این‌صورت کاربر ۲ دقیقه معطل می‌مونه بدونِ اینکه اصلاً کدی
# گرفته باشه. سقفِ ساعتیِ per-phone (بالاتر) و throttle
# IP-محور دست‌نخورده می‌مونن — این‌ها محدودیتِ حجمِ کلی‌ان، نه
# cooldownِ بینِ-درخواستی، و منطقِ متفاوتی دارن.
cache.delete(phone_cooldown_key) cache.delete(phone_cooldown_key)
raise raise

View File

@@ -63,24 +63,16 @@ class TrackConversionView(APIView):
خودِ کاربرِ تازه‌ارجاع‌شده (بعد از لاگین/ثبت‌نام) زده بشه، هویتش رو خودِ کاربرِ تازه‌ارجاع‌شده (بعد از لاگین/ثبت‌نام) زده بشه، هویتش رو
از توکن احرازشده (request.user) می‌گیریم، نه از بدنه‌ی درخواست. از توکن احرازشده (request.user) می‌گیریم، نه از بدنه‌ی درخواست.
# ⚠️ اصلاح امنیتی: قبلاً این endpoint با AllowAny و یک # هویت از request.user گرفته می‌شه (نه بدنه‌ی درخواست) چون یک
# referredUserId دلخواه از سمت کلاینت کار می‌کرد؛ یعنی هر کلاینتِ # referredUserIdِ کلاینت‌دار یعنی هر کلاینتِ ناشناس می‌تونست برای هر
# ناشناس می‌تونست برای هر کاربرِ واقعیِ دیگه‌ای (بدون اطلاع یا # کاربرِ دیگه‌ای Conversion بسازه یا با اشغالِ همه‌ی userId ها (قیدِ
# مشارکت خودش) به‌جای او Conversion بسازه، یا حتی برای فرار از # unique رو دورزدن) مسیرِ ارجاعِ واقعی رو مسدود کنه.
# قید unique روی referred_user_id، از قبل تمام userId های موجود
# رو برای یک کد افیلیتِ جعلی «اشغال» کنه و مسیر ارجاع واقعی رو
# برای همیشه مسدود کنه. با گرفتن هویت از request.user (نیازمند
# احراز هویت)، دیگه امکان جعل/انتساب ارجاع به کاربر دیگری وجود
# نداره؛ چک self-referral هم حفظ شده.
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: قبلاً این endpoint هیچ throttleای نداشت؛ referralCode # referralCode نامعتبر ۴۰۴ می‌گیره (یک oracle قابل‌probe)، پس throttle
# نامعتبر ۴۰۴ می‌گیره (یک oracle قابل‌probe) و حدسِ درست یک # مجزا هم probe رو کند می‌کنه هم (با GuestAwareAffiliateThrottle) جلویِ
# Conversion واقعیِ متصل به کمیسیونِ affiliate می‌سازه. طبقِ همون # bucket تازه از چرخشِ حساب مهمان رو می‌گیره.
# الگویِ بقیه‌ی اپ‌ها، throttle_scope مجزا با GuestAwareAffiliateThrottle
# اضافه شد تا هم probe کردنِ کد رو کند کنه، هم چرخشِ حساب مهمان
# دیگه bucket تازه نده.
throttle_scope = 'affiliate_track' throttle_scope = 'affiliate_track'
throttle_classes = [GuestAwareAffiliateThrottle] throttle_classes = [GuestAwareAffiliateThrottle]

View File

@@ -54,15 +54,10 @@ class AskIdempotency(models.Model):
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='+') conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='+')
user_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+') user_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
assistant_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+') assistant_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
# ⚠️ اصلاح: قبلاً suggestedFollowUps هیچ‌جا ذخیره نمی‌شد و در مسیرِ # ذخیره می‌شه تا replayِ idempotent بتونه بدونِ فراخوانیِ دوباره‌ی
# replay (idempotent retry) دوباره با فراخوانیِ generate_tutor_answer # generate_tutor_answer همون مقدارِ اصلی رو برگردونه — وقتی provider
# بازسازی می‌شد. این فقط تا وقتی generate_tutor_answer یک stub خالی از # واقعی جایگزینِ stub بشه، هر replay یعنی هزینه/تأخیرِ تکراری و
# اثر جانبیه بی‌ضرره؛ وقتی به یک AI provider واقعی وصل بشه، هر replay # follow-upهای متفاوت در هر retry، برخلافِ هدفِ idempotency.
# یک درخواستِ واقعیِ دیگه به provider می‌زنه (هزینه/تأخیرِ تکراری، و
# پیشنهادهای follow-up متفاوت در هر retry — دقیقاً برخلافِ هدفِ
# idempotency). حالا خروجیِ follow-up های همون درخواستِ اولِ موفق،
# همینجا ذخیره می‌شه تا replay بتونه بدونِ هیچ فراخوانیِ دیگری به
# generate_tutor_answer، همون مقدارِ اصلی رو برگردونه.
suggested_follow_ups = models.JSONField(default=list) suggested_follow_ups = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -139,11 +139,8 @@ class AskAIView(APIView):
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)""" """POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# ⚠️ افزوده شده: قبلاً این endpoint هیچ throttle مخصوصی نداشت؛ DAILY_AI_QUOTA # DAILY_AI_QUOTA فقط سقفِ روزانه‌ست و جلوی burstِ کوتاه‌مدت رو نمی‌گیره؛
# فقط سقفِ روزانه است و جلوی burst کوتاه‌مدت (مثلاً چند ده درخواست در چند # این throttle_scope مجزا اون burst رو محدود می‌کنه.
# ثانیه به generate_tutor_answer) را نمی‌گیرد. throttle_scope مجزا
# (ai_ask) این burst را محدود می‌کند، بدون اینکه به کوتای روزانه/idempotency
# یا منطق AI دست بزند.
throttle_scope = 'ai_ask' throttle_scope = 'ai_ask'
throttle_classes = [GuestAwareAIThrottle] throttle_classes = [GuestAwareAIThrottle]

View File

@@ -47,15 +47,10 @@ class CampaignDeliveryAdmin(admin.ModelAdmin):
readonly_fields = fields readonly_fields = fields
def has_view_permission(self, request, obj=None): def has_view_permission(self, request, obj=None):
# صریحاً True: نباید به fallbackِ مبتنی‌بر codename پرمیشنِ # صریحاً True: بدونِ override، fallbackِ پیش‌فرضِ Django به کاربرِ
# پیش‌فرضِ Django (has_perm('view_...') or has_perm('change_...')) # staffِ بدونِ پرمیشنِ view/change حتی اجازه‌ی دیدنِ این صفحه‌ی
# تکیه کنیم. بدونِ این override، یک کاربرِ staff که پرمیشنِ # read-only رو هم نمی‌ده — یعنی مدل عملاً غیرقابلِ‌دسترس می‌شه، نه
# view/change روی این مدل رو نداره، حتی برای دیدنِ صفحه‌ی # فقط view-only.
# جزئیاتِ read-only هم 403 می‌گیره — یعنی مدل عملاً "view-only"
# نیست بلکه کاملاً غیرقابلِ‌دسترسه. این متد مستقل از
# has_change_permission (که پایین همیشه False برمی‌گردونه)
# تضمین می‌کنه که دیدن همیشه مجازه، صرفِ‌نظر از پرمیشن‌های
# دیتابیسی.
return True return True
def has_add_permission(self, request): def has_add_permission(self, request):

View File

@@ -96,24 +96,12 @@ class CampaignDelivery(models.Model):
ordering = ['-created_at'] ordering = ['-created_at']
constraints = [ constraints = [
# جلوگیریِ race condition: اگه execute_campaign() برای همون # جلوگیریِ race condition: اگه execute_campaign() برای همون
# campaign هم‌زمان (دو پراسس/ریکوئستِ موازی) اجرا بشه، هر دو # campaign هم‌زمان اجرا بشه، این constraint تضمین می‌کنه حداکثر
# می‌تونن قبل از اینکه هرکدوم رکوردی بسازن از چکِ # یک رکوردِ «فعال یا موفق» (pending/sent) به‌ازای هر
# already_sent رد بشن (TOCTOU) و برای یک کاربر دو بار # (campaign, user_id, channel) باشه؛ تلاشِ دومِ هم‌زمان با
# send_sms/notify صدا زده بشه. این unique constraint دقیقاً # IntegrityError شکست می‌خوره و به‌عنوانِ claim‌شده skip می‌شه.
# همون چیزی رو در سطحِ دیتابیس تضمین می‌کنه که چکِ اپلیکیشن‌ # status=failed عمداً مستثناست تا retry (ساختِ pendingِ جدید
# لایه فرض می‌کرد ولی گارانتی نمی‌داد: برای هر # بعد از شکست) دست‌نخورده بمونه.
# (campaign, user_id, channel) در آنِ واحد حداکثر یک رکوردِ
# «فعال یا موفق» (pending یا sent) می‌تونه وجود داشته باشه.
# وقتی دو تلاشِ هم‌زمان می‌خوان برای همون کاربر رکورد pending
# بسازن، دومی روی این constraint با IntegrityError شکست
# می‌خوره و در execute_campaign() به‌عنوانِ «این کاربر توسطِ
# یک اجرای دیگه claim شده» skip می‌شه — پس send_sms/notify
# دوباره صدا زده نمی‌شه.
#
# status=failed عمداً از این constraint مستثنا شده تا رفتارِ
# retryِ فعلی (ساختنِ یک رکوردِ pending جدید بعد از یک تلاشِ
# ناموفق) دست‌نخورده بمونه: یک رکوردِ failed دیگه «فعال» حساب
# نمی‌شه، پس جای رکوردِ جدید رو برای تلاشِ بعدی باز می‌ذاره.
UniqueConstraint( UniqueConstraint(
fields=['campaign', 'user_id', 'channel'], fields=['campaign', 'user_id', 'channel'],
condition=Q(status__in=['pending', 'sent']), condition=Q(status__in=['pending', 'sent']),

View File

@@ -39,12 +39,10 @@ def _retry_on_sqlite_lock(fn, *, retries=5, base_delay=0.05):
raise raise
time.sleep(base_delay * (attempt + 1)) time.sleep(base_delay * (attempt + 1))
# نوعِ نوتیفیکیشنِ ثبت‌شده برای اعلان‌های ناشی از کمپین. عمداً در # عمداً به notifications.models.Notification.TYPE_CHOICES اضافه نشده: آن
# notifications.models.Notification.TYPE_CHOICES اضافه نشده چون آن # choices فقط رویدادهاییه که notifications به‌عنوانِ listener گوش می‌ده،
# choices فقط رویدادهایی‌ان که notifications به‌عنوانِ event-listener # اینجا notify() مستقیم صدا زده می‌شه؛ Notification.type یک CharField
# بهشون گوش می‌ده (طبق داکیومنتِ همون فایل)؛ این‌جا notify() مستقیماً و # معمولیه، مقداردهیِ خارج از choices مجازه.
# بدونِ رویداد صدا زده می‌شه، پس تغییرِ آن مدل لازم نیست — Notification.type
# یک CharField معمولیه و مقداردهی‌اش خارج از choices هم مجاز است.
CAMPAIGN_NOTIFICATION_TYPE = 'message_campaign' CAMPAIGN_NOTIFICATION_TYPE = 'message_campaign'

View File

@@ -227,21 +227,11 @@ REST_FRAMEWORK = {
'DEFAULT_THROTTLE_CLASSES': [ 'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.ScopedRateThrottle', 'rest_framework.throttling.ScopedRateThrottle',
], ],
# ⚠️ اضافه شده (امنیتی): بدونِ NUM_PROXIES، DRF (SimpleRateThrottle.get_ident) # بدونِ NUM_PROXIES، DRF هدرِ X-Forwarded-For رو بدون‌چک به‌عنوانِ IP
# هدرِ X-Forwarded-For رو - اگه در درخواست وجود داشته باشه - عیناً و # کلاینت قبول می‌کنه؛ یعنی هر کلاینت می‌تونست با XFF دلخواه، bucket
# بدون‌چک به‌عنوانِ IP کلاینت قبول می‌کنه؛ یعنی هر کلاینتِ ناشناس می‌تونست # تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این
# با فرستادنِ یک X-Forwarded-For دلخواه/تصادفی در هر درخواست، برای # مقدار باید دقیقاً با تعدادِ لایه‌ی پراکسی/LB جلوی اپ یکی باشه (مثلاً
# هر GuestAware*Throttle (content/ai_tutor/subscription/payment/discount/ # Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده می‌شه.
# support/family/affiliate) یک IP-bucket تازه بگیره - دقیقاً همون
# bypassِ چرخشِ حساب مهمان که این throttle-classها قرار بود ببندن، فقط
# از طریقِ هدر به‌جای deviceId جدید.
# با NUM_PROXIES=1 (پیش‌فرض؛ برای دیپلویِ پشتِ یک لایه‌ی پراکسی/LB که
# خودش X-Forwarded-For رو ست/rewrite می‌کنه، نه پاس‌دادنِ مقدارِ
# کلاینت)، DRF از انتهای زنجیره‌ی XFF دقیقاً همون تعداد hop رو
# می‌خونه. اگه دیپلویِ پروداکشن تعداد لایه‌ی پراکسیِ دیگه‌ای داره
# (مثلاً Cloudflare + Nginx = ۲ لایه)، این مقدار باید با env
# NUM_PROXIES مطابقِ همون تعداد ست بشه، وگرنه یا IP واقعی اشتباه
# خونده می‌شه یا (اگه صفر باشه) دوباره همون مشکلِ بالا برمی‌گرده.
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')), 'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')),
'DEFAULT_THROTTLE_RATES': { 'DEFAULT_THROTTLE_RATES': {
'otp_request': '5/hour', 'otp_request': '5/hour',
@@ -259,9 +249,6 @@ REST_FRAMEWORK = {
'discount_redeem': '10/hour', 'discount_redeem': '10/hour',
'family_invite_accept': '10/hour', 'family_invite_accept': '10/hour',
'family_invite_create': '5/hour', 'family_invite_create': '5/hour',
# ⚠️ اضافه شده: چهار endpointی که در ممیزیِ throttleِ امروز بدونِ
# throttle_scope پیدا شدن (نگاه کنید به توضیحاتِ throttle_scope
# مربوطه در accounts/views.py، affiliate/views.py، payment/views.py).
'user_profile_write': '60/hour', 'user_profile_write': '60/hour',
'affiliate_track': '10/hour', 'affiliate_track': '10/hour',
'payment_webhook': '60/hour', 'payment_webhook': '60/hour',
@@ -292,13 +279,9 @@ SPECTACULAR_SETTINGS = {
ACCESS_TOKEN_TTL_SECONDS = 3600 ACCESS_TOKEN_TTL_SECONDS = 3600
REFRESH_TOKEN_TTL_DAYS = 30 REFRESH_TOKEN_TTL_DAYS = 30
# سقفِ تعدادِ گیرنده برای اکشنِ ادمینِ send_sms_to_selected (هم برای # سقفِ گیرنده‌ی اکشنِ ادمینِ send_sms_to_selected؛ این اکشن synchronous توی
# هدف‌گیریِ 'selected' و هم 'inactive'). این اکشن هنوز کاملاً بلادرنگ و # همون requestه (بدون صفِ پس‌زمینه)، پس این سقف جلویِ تایم‌اوت/overloadِ
# synchronous توی همون request اجرا می‌شه (بدون صفِ پس‌زمینه)، پس این سقف # provider رو می‌گیره — بیشتر از سقف یعنی هیچ پیامکی ارسال نمی‌شه.
# صرفاً جلویِ یک ارسالِ ناخواسته‌ی خیلی بزرگ (که می‌تونه request رو تایم‌اوت
# کنه یا پروایدرِ پیامک رو overload کنه) رو می‌گیره؛ اگر تعدادِ گیرنده‌ها
# بیشتر از این باشه، اکشن هیچ پیامکی ارسال نمی‌کنه و از ادمین می‌خواد
# بازه/انتخاب رو محدودتر کنه.
ADMIN_SMS_MAX_RECIPIENTS = int(os.environ.get('ADMIN_SMS_MAX_RECIPIENTS', 200)) ADMIN_SMS_MAX_RECIPIENTS = int(os.environ.get('ADMIN_SMS_MAX_RECIPIENTS', 200))
# محدودیت تلاش ورود بر اساس شماره تلفن، مستقل از throttle IP-محورِ otp_login. # محدودیت تلاش ورود بر اساس شماره تلفن، مستقل از throttle IP-محورِ otp_login.
@@ -311,15 +294,9 @@ OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600
OTP_REQUEST_PHONE_MAX_ATTEMPTS = 5 OTP_REQUEST_PHONE_MAX_ATTEMPTS = 5
OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600 OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
# حداقل فاصله‌ی زمانی بین دو درخواستِ صدورِ OTP برای همون شماره. این مستقل # حداقل فاصله بین دو درخواستِ OTP برای همون شماره؛ مستقل از سقفِ ساعتیِ بالا
# از سقفِ ساعتیِ بالاست: سقفِ ساعتی جلویِ حجمِ کلی رو می‌گیره اما جلویِ # (که جلویِ burst رو نمی‌گیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار
# burst رو نمی‌گیره (مثلاً هر ۵ تلاشِ مجاز در عرضِ کمتر از یک ثانیه). این # باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
# cooldown دقیقاً همون burst رو هدف می‌گیره؛ نه جایگزینِ سقفِ ساعتیه، نه
# باهاش redundant — هر کدوم یک بُعدِ متفاوتِ سوءاستفاده رو می‌بندن.
# ⚠️ تصمیمِ محصول/امنیت: کاربر واقعاً باید هر ۲ دقیقه بتونه دوباره OTP
# بگیره؛ enforcement سمتِ سرور هم باید دقیقاً همین مقدار باشه — countdown
# سمتِ فرانت صرفاً UX هست و نباید مرجعِ امنیتی باشه، پس این مقدار همیشه
# باید با چیزی که فرانت به کاربر نشون می‌ده یکی باشه.
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120 OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود. # در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
@@ -368,14 +345,10 @@ PAYMENT_WEBHOOK_SECRET = os.environ.get(
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Bazaar In-App Billing (پرداخت درون‌برنامه‌ای کافه‌بازار — REST API v2) # Bazaar In-App Billing (پرداخت درون‌برنامه‌ای کافه‌بازار — REST API v2)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# طبق داکیومنت رسمی (developers.cafebazaar.ir/fa/guidelines/in-app-billing)، # صحت‌سنجی OAuth2 سرور-به-سروره: REFRESH_TOKEN یک‌بار دستی از پنل devapi
# صحت‌سنجی پرداخت بازار یک OAuth2 سرور-به-سرور‌ه: یک بار با CLIENT_ID/ # گرفته می‌شه، بعد بک‌اند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه می‌گیره.
# CLIENT_SECRET و کد بازگشتی از پنل devapi، REFRESH_TOKEN گرفته می‌شه (یک‌بار، # قبل از اتصال واقعی، مسیرِ endpointها رو از پنل devapi تایید کنید — بازار
# دستی)؛ از اون به بعد بک‌اند با همین REFRESH_TOKEN، ACCESS_TOKEN تازه # این روش رو قبلاً تغییر داده.
# می‌گیره و با اون به endpoint وضعیت خرید/اشتراک درخواست می‌زنه.
# ⚠️ کافه‌بازار اواخر ۱۴۰۳ روش صحت‌سنجی رو تغییر داد؛ قبل از اتصال واقعی،
# حتماً آخرین مسیر endpointها رو از همون صفحه‌ی رسمی/پنل devapi خودتون
# تایید کنید (BAZAAR_API_BASE پایینی رو بر همون اساس ست کنید).
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app') BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app')
BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '') BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '')
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '') BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
@@ -388,18 +361,11 @@ BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Logging (آماده‌سازیِ لاگ‌های موجود برای جمع‌آوریِ متمرکز — Loki/ELK-style) # Logging — بدونِ یک LOGGING صریح، loggerهای موجودِ اپ (config.middleware،
# accounts.services، ...) فقط به rootِ پیش‌فرض می‌رسیدن که برای INFO عملاً
# خروجی‌ای نداره. خروجی: یک JSON در هر خط روی stdout، سازگار با
# Promtail/Filebeat برای جمع‌آوریِ بعدی توسطِ Loki/ELK.
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# ⚠️ افزوده شده: پروژه از قبل چندین logger (config.middleware،
# accounts.services، ai_tutor.views، ...) با logging.getLogger(__name__)
# داشت، اما بدونِ یک LOGGING صریح در settings.py، اینا فقط به rootِ
# پیش‌فرضِ کتابخانه‌ی logging می‌رسیدن (که برای سطحِ INFO عملاً هیچ خروجی‌ای
# تولید نمی‌کنه). این تنظیم، بدونِ اضافه/حذفِ هیچ loggerِ اپلیکیشنی یا
# eventِ لاگی، همون پیام‌های موجود رو روی stdout و به‌صورتِ یک JSON در هر
# خط (فرمتِ سازگار با agentِ جمع‌آوریِ بیرونی مثلِ Promtail/Filebeat که
# بعداً می‌تونه به Loki/ELK بفرسته) چاپ می‌کنه. هیچ appِ جدید، ذخیره‌سازیِ
# دیتابیسی، API/UIِ لاگ، یا وابستگیِ Elasticsearch/Loki/Grafana به خودِ
# کدِ Django اضافه نشده.
LOGGING = { LOGGING = {
'version': 1, 'version': 1,
'disable_existing_loggers': False, 'disable_existing_loggers': False,
@@ -420,16 +386,9 @@ LOGGING = {
}, },
} }
# ⚠️ افزوده شده (یکپارچه‌سازیِ Docker + Promtail/Loki/Grafana): وقتی # وقتی LOG_DIR ست باشه (فقط داخلِ docker-compose.yml، روی ولومِ مشترک با
# env var LOG_DIR ست شده باشه (فقط داخلِ docker-compose.yml، روی یک # promtail)، همون خطوطِ JSON علاوه بر stdout در یک فایل هم نوشته می‌شن تا
# ولومِ مشترک با کانتینرِ promtail)، همون خطوطِ JSON که از قبل روی # Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
# stdout چاپ می‌شدن، *علاوه* بر stdout در یک فایل هم نوشته می‌شن؛
# Promtail همون فایل رو tail می‌کنه و به Loki می‌فرسته.
# این فقط یک هندلرِ خروجیِ اضافه با همون formatter (json) هست — نه
# پیام‌های لاگ، نه محافظت‌های حساسیتیِ موجود (بدونِ OTP/token/رمزعبور و
# غیره در پیام‌ها)، و نه هیچ loggerِ اپلیکیشنی تغییر نمی‌کنه.
# وقتی LOG_DIR ست نباشه (یعنی اجرای عادیِ خارج از Docker، طبق README)،
# رفتار دقیقاً همون چیزیه که از قبل بود: فقط stdout.
LOG_DIR = os.environ.get('LOG_DIR', '') LOG_DIR = os.environ.get('LOG_DIR', '')
if LOG_DIR: if LOG_DIR:

View File

@@ -52,15 +52,9 @@ class SecretKeyFailClosedTests(SimpleTestCase):
) )
def test_production_configured_secret_key_used(self): def test_production_configured_secret_key_used(self):
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): این تست فقط رفتارِ # CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون
# SECRET_KEY رو می‌سنجه، اما چون CACHE_URL رو ست نمی‌کرد، وقتی # ImproperlyConfiguredِ CACHE_URL رخ می‌ده، نه رسیدن به assertionِ
# فیکسِ جداگانه‌ی CACHE_URL fail-closed (پایین‌ترِ همین فایل، نگاه # SECRET_KEY که این تست واقعاً می‌سنجه.
# کنید به CacheUrlFailClosedTests) اضافه شد، همین import/reload با
# DEBUG=False و بدونِ CACHE_URL به‌جای رسیدن به assertion، با
# ImproperlyConfigured مربوط به CACHE_URL (نه SECRET_KEY) fail
# می‌شد — یک false negative روی چیزی که این تست اصلاً قرار نیست
# بسنجه. یک CACHE_URL معتبر هم ست می‌کنیم تا این تست فقط دقیقاً
# همون چیزی که اسمش می‌گه (SECRET_KEY) رو بسنجه.
settings_module = self._reload_settings_with_env( settings_module = self._reload_settings_with_env(
env_overrides={ env_overrides={
'DJANGO_DEBUG': '', 'DJANGO_DEBUG': '',
@@ -196,24 +190,11 @@ class APIRequestLoggingMiddlewareTests(TestCase):
self.assertEqual(records, []) self.assertEqual(records, [])
def test_unhandled_exception_from_view_is_logged_as_error_and_reraised(self): def test_unhandled_exception_from_view_is_logged_as_error_and_reraised(self):
# 🔒 اثبات فیکسِ این دور: اگه self.get_response(request) خودش یک # میان‌افزار باید یک خطِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ
# exceptionِ دست‌نخورده raise کنه، میان‌افزار باید همچنان یک خطِ # جزئیاتِ حساس) ثبت کنه و بعد همون exception رو دوباره raise کنه.
# لاگِ سطحِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ جزئیاتِ # با logging.Formatter().format(record) دقیقاً همون مسیری که یک
# حساس) ثبت کنه و بعد دقیقاً همون exceptionِ اصلی رو دوباره raise # handlerِ واقعی طی می‌کنه رو صدا می‌زنیم (نه فقط getMessage()، که
# کنه — بدونِ اینکه هیچ رفتارِ Django/DRF یا exceptionِ دیگه‌ای رو # یه mismatchِ %%-args رو با exceptionِ خودش می‌پوشونه، نه fail واقعی).
# تغییر بده. مستقیماً خودِ میان‌افزار (نه کلِ چرخه‌ی
# exception-handlingِ Django) تست می‌شه، چون همون لایه‌ای‌ست که این
# فیکس روش اعمال شده.
#
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): قبلاً این تست فقط
# record.getMessage() رو چک می‌کرد. getMessage() خودش داخلی
# msg %% args رو انجام می‌ده، پس اگه شمارِ آرگومان‌ها با
# placeholderهای رشته جور نباشه، همون‌جا با یک exception (نه یک
# پیامِ نادرست) fail می‌شد و اصلاً به assertion نمی‌رسید. برای اینکه
# این تست واقعاً *صحتِ formatting* رو بسنجه (نه فقط اینکه یک‌جوری
# fail نشه)، حالا با logging.Formatter().format(record) دقیقاً
# همون مسیری که یک handlerِ واقعی (کنسول/فایل) طی می‌کنه رو صدا
# می‌زنیم و روی متنِ نهاییِ فرمت‌شده assert می‌کنیم.
request = RequestFactory().get('/api/some/failing/path') request = RequestFactory().get('/api/some/failing/path')
def failing_get_response(inner_request): def failing_get_response(inner_request):

View File

@@ -12,13 +12,8 @@ from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, U
class LessonCompleteImpossibleCountTests(TestCase): class LessonCompleteImpossibleCountTests(TestCase):
""" """رگرسیون: correctCount باید با تعدادِ واقعیِ سوال‌های درس مقایسه بشه، وگرنه
🔒 تستِ رگرسیونِ فیکسِ امنیتی: LessonCompleteView قبلاً correctCount رو کلاینتِ مخرب می‌تونه xp/coins رو متورم کنه."""
بدونِ مقایسه با تعدادِ واقعیِ سوال‌های درس می‌پذیرفت، پس یک کلاینتِ
مخرب می‌تونست با correctCount بزرگ‌تر از تعدادِ واقعیِ سوال‌ها،
xp/coins متورم بگیره. این تست هم ردِ درخواست‌های غیرممکن رو، هم
رفتارِ صحیحِ تکمیل با مقادیرِ معتبر رو تضمین می‌کنه.
"""
def setUp(self): def setUp(self):
self.client = APIClient() self.client = APIClient()
@@ -67,14 +62,9 @@ class LessonCompleteImpossibleCountTests(TestCase):
class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase): class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
""" """رگرسیون: ratio باید از actual_question_count محاسبه بشه نه از totalQuestیِ
🔒 تستِ رگرسیونِ فیکسِ دوم: قبلاً ratio از totalQuestions ارسالی‌ی ارسالیِ کلاینت، وگرنه حتی بعدِ سقف‌زدنِ correctCount کلاینت می‌تونه با
خودِ کلاینت محاسبه می‌شد، نه از تعدادِ واقعیِ سوال‌های درس. یعنی حتی totalQuestions دروغین ratio رو مصنوعاً ۱۰۰٪ کنه."""
بعد از سقف‌زدنِ correctCount، کلاینت می‌تونست با totalQuestions=1
(به‌جای ۳ سوالِ واقعی)، ratio رو مصنوعاً ۱۰۰٪ کنه و ۳ ستاره/coins
کامل بگیره درحالی‌که فقط ۱ از ۳ سوال رو درست جواب داده. حالا ratio
همیشه بر اساسِ actual_question_count محاسبه می‌شه.
"""
def setUp(self): def setUp(self):
self.client = APIClient() self.client = APIClient()
@@ -114,13 +104,8 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
class QuestionAnswerThrottleTests(TestCase): class QuestionAnswerThrottleTests(TestCase):
""" """محدودیتِ per-user روی QuestionAnswerView (throttle_scope='question_answer')؛
🔒 اثبات محدودیتِ جدیدِ per-user روی QuestionAnswerView Idempotency-Key فقط retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت."""
(throttle_scope='question_answer'): Idempotency-Key فقط جلوی retry با
همون کلید رو می‌گیره، نه حجمِ بالای درخواست‌های *متفاوت* که هرکدوم
یک UserAnswer جدید می‌سازن و منطقِ هارت/NodeProgress رو اجرا می‌کنن.
ScopedRateThrottle با pk کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP.
"""
def setUp(self): def setUp(self):
from django.core.cache import cache from django.core.cache import cache
@@ -242,14 +227,8 @@ class QuestionAnswerThrottleTests(TestCase):
class LessonCompleteThrottleTests(TestCase): class LessonCompleteThrottleTests(TestCase):
""" """محدودیتِ per-user روی LessonCompleteView (throttle_scope='lesson_complete')؛
🔒 اثبات محدودیتِ جدیدِ per-user روی LessonCompleteView Idempotency-Key فقط retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت."""
(throttle_scope='lesson_complete'): Idempotency-Key فقط جلوی retry با
همون کلید رو می‌گیره، نه حجمِ بالای درخواست‌های *متفاوت* که هرکدوم
یک UserLessonCompletion جدید می‌سازن و سیگنالِ post_save
(NodeProgress) + به‌روزرسانیِ UserStats رو اجرا می‌کنن.
ScopedRateThrottle با pk کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP.
"""
def setUp(self): def setUp(self):
from django.core.cache import cache from django.core.cache import cache
@@ -361,17 +340,9 @@ class LessonCompleteThrottleTests(TestCase):
class QuestionAnswerGuestThrottleBypassTests(TestCase): class QuestionAnswerGuestThrottleBypassTests(TestCase):
""" """اثباتِ فیکسِ throttle: چرخشِ حساب مهمان (هر deviceId یه pk تازه) نباید
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='question_answer' برای bucket تازه‌ی question_answer بده — GuestAwareContentThrottle برای
هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView مهمان‌ها کلید رو بر اساسِ IP می‌سازه، نه user.pk."""
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم
می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle
تازه بگیره و منطقِ هارت/NodeProgress رو بی‌محدودیت اجرا کنه. حالا
GuestAwareContentThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP
(همون get_ident که guest_session هم استفاده می‌کنه) می‌سازه، پس چرخشِ
حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرِ
غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده.
"""
def setUp(self): def setUp(self):
from django.core.cache import cache from django.core.cache import cache
@@ -446,17 +417,9 @@ class QuestionAnswerGuestThrottleBypassTests(TestCase):
class LessonCompleteGuestThrottleBypassTests(TestCase): class LessonCompleteGuestThrottleBypassTests(TestCase):
""" """اثباتِ فیکسِ throttle: چرخشِ حساب مهمان نباید bucket تازه‌ی
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='lesson_complete' برای lesson_complete بده — GuestAwareContentThrottle برای مهمان‌ها کلید رو
هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView بر اساسِ IP می‌سازه، نه user.pk."""
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم
می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle
تازه بگیره و UserLessonCompletion/xp/coins/progress رو بی‌محدودیت
تولید کنه. حالا GuestAwareContentThrottle برای کاربرهای مهمان کلید رو
بر اساسِ IP می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده.
رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده
مونده.
"""
def setUp(self): def setUp(self):
from django.core.cache import cache from django.core.cache import cache

View File

@@ -26,24 +26,11 @@ from .serializers import (
class GuestAwareContentThrottle(ScopedRateThrottle): class GuestAwareContentThrottle(ScopedRateThrottle):
""" """
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)، مشترک با GuestAwareAIThrottle/GuestAwareFamilyThrottle/GuestAwareDiscountThrottle
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/ و مشابه‌هاش: چون GuestSessionView با هر deviceId جدید یک pk تازه می‌سازه،
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py) throttleِ استانداردِ بر اساسِ user.pk با چرخشِ حسابِ مهمان دور زده می‌شه —
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده درحالی‌که answer/complete اثرِ جانبیِ ماندگار (هارت، xp/coins/progress) دارن.
از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون برای همین فقط کاربرهای مهمان با IP کلید می‌خورن؛ کاربرهای غیرمهمان مثلِ قبل بر اساسِ pk.
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، برای هر
حساب یک bucket تازه از throttle_scope 'question_answer'/'lesson_complete'
بگیره و محدودیتِ واقعی رو دور بزنه — درحالی‌که هر دو endpoint اثرِ
جانبیِ ماندگار دارن (UserAnswer + کسرِ هارت، یا UserLessonCompletion +
xp/coins/progress).
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی
حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب
دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان
(ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ
answer/heart/progress/completion/idempotency دست‌نخورده‌ست.
""" """
def get_cache_key(self, request, view): def get_cache_key(self, request, view):
@@ -165,18 +152,9 @@ class QuestionAnswerView(APIView):
# userId همیشه از request.user.id گرفته می‌شود، نه ورودی کلاینت. # userId همیشه از request.user.id گرفته می‌شود، نه ورودی کلاینت.
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ بالای # Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های
# درخواست‌های *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک # *متفاوت* که هرکدوم UserAnswer جدید می‌سازن و هارت/NodeProgress رو تغییر می‌دن؛
# UserAnswer جدید می‌سازن و منطقِ هارت/NodeProgress رو اجرا می‌کنن — # برای همون GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا).
# حتی وقتی هارتِ کاربر صفر شده. ScopedRateThrottle وقتی request.user
# احرازهویت‌شده باشه با pk کاربر کلید می‌خوره (نه فقط IP)، پس تغییرِ
# IP این محدودیت رو دور نمی‌زنه.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از
# GuestAwareContentThrottleِ مشترک استفاده می‌شه که برای کاربرهای
# مهمان کلید رو بر اساسِ IP می‌سازه و برای کاربرهای غیرمهمان رفتارِ
# per-user قبلی رو دست‌نخورده نگه می‌داره.
throttle_scope = 'question_answer' throttle_scope = 'question_answer'
throttle_classes = [GuestAwareContentThrottle] throttle_classes = [GuestAwareContentThrottle]
@@ -274,20 +252,12 @@ class QuestionAnswerView(APIView):
class LessonCompleteView(APIView): class LessonCompleteView(APIView):
"""POST /api/lessons/{id}/complete — اتمام درس + آمار جدید""" """POST /api/lessons/{id}/complete — اتمام درس + آمار جدید"""
# ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده می‌شد که به # userId همیشه از request.user.id گرفته می‌شود (نه ورودیِ کلاینت)، وگرنه کلاینت
# کلاینت اجازه می‌داد به‌جای هر کاربر دیگری xp/coins ثبت کنه. حالا auth # می‌تونست به‌جای هر کاربرِ دیگری xp/coins ثبت کنه.
# اجباریه و همیشه از request.user.id استفاده می‌شه.
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ بالای # Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره؛ برای همینه که
# درخواست‌های *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک # GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ ۱۰/hour
# UserLessonCompletion جدید می‌سازن و سیگنالِ post_save (NodeProgress) + # (نه ۶۰/min مثلِ question_answer) چون این endpoint رکوردِ ماندگار می‌سازه.
# به‌روزرسانیِ UserStats رو اجرا می‌کنن. ScopedRateThrottle با pk
# کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP.
# ⚠️ اصلاح شده: همون مشکلِ چرخشِ حسابِ مهمان (نگاه کنید به
# GuestAwareContentThrottle بالا) اینجا هم بود؛ حالا از همون کلاسِ
# مشترک استفاده می‌شه. نرخ هم از ۶۰/min به ۱۰/hour تغییر کرد چون این
# endpoint رکوردِ completion ماندگار می‌سازه و xp/coins/progress رو
# واقعاً تغییر می‌ده (برخلافِ question_answer که فرکانسِ طبیعی‌اش بالاتره).
throttle_scope = 'lesson_complete' throttle_scope = 'lesson_complete'
throttle_classes = [GuestAwareContentThrottle] throttle_classes = [GuestAwareContentThrottle]
@@ -352,21 +322,12 @@ class LessonCompleteView(APIView):
} }
return Response(LessonCompleteResponseSerializer(data).data) return Response(LessonCompleteResponseSerializer(data).data)
# ⚛️ atomic: UserLessonCompletion.create پایین‌تر یک سیگنال post_save # atomic لازمه چون create پایین‌تر سیگنالِ post_save رو تریگر می‌کنه
# رو تریگر می‌کنه (content/receivers.py → NodeProgress + احتمالاً # (NodeProgress) و UserStats هم آپدیت می‌شه؛ بدون اون، fail شدنِ save
# StatusChangeLog)؛ به‌علاوه‌ی get_or_create/save روی UserStats. # روی stats یعنی تکمیل ثبت شده ولی xp/coins یک‌بارمصرف از دست رفته.
# بدون atomic، اگه save روی stats fail کنه، تکمیلِ درس ثبت شده ولی # ردیفِ UserStats قفل می‌شه (select_for_update) و چکِ is_first_completion
# xp/coins/progress_percent به‌روز نشده — یعنی چون تکمیل هر درس # داخلِ همون قفل انجام می‌شه تا دو درخواستِ همزمان (دابل‌کلیک) هردو
# یک‌بارمصرفه، کاربر دیگه هیچ‌وقت اون xp رو نمی‌گیره. # جایزه‌ی کاملِ اول رو نگیرن.
#
# ⚛️🔒 race condition: چکِ «is_first_completion» قبلاً قبل از atomic
# و بدون قفل انجام می‌شد — دو درخواستِ همزمان (دابل‌کلیک/رتریِ
# کلاینت) برای تکمیلِ همون درس می‌تونستن هر دو ببینن که هنوز کامل
# نشده و هر دو xp/coins کامل بگیرن (دوبرابر شدنِ جایزه). حالا ردیفِ
# UserStats کاربر قفل می‌شه (select_for_update) و چکِ
# is_first_completion داخلِ همون قفل، بعد از لاک‌شدن، دوباره انجام
# می‌شه — یعنی درخواستِ دومِ همزمان، حالا که اولی commit شده، به‌درستی
# می‌بینه که دیگه first completion نیست.
try: try:
with transaction.atomic(): with transaction.atomic():
UserStats.objects.get_or_create(user_id=user_id) UserStats.objects.get_or_create(user_id=user_id)

View File

@@ -27,18 +27,11 @@ def apply_offer(*, user_id: int, offer: Offer, source: str) -> UserOffer | None:
if not offer.is_active: if not offer.is_active:
return None return None
# ⚠️ اصلاح: قبلاً هر IntegrityError داخل کل بلوکِ atomic (که شاملِ # فقط IntegrityErrorِ ناشیِ از UniqueConstraintِ خودِ UserOffer.create
# grant_free_subscription هم می‌شد) به‌عنوان «duplicate UserOffer» # (duplicate واقعی) اینجا گرفته می‌شه، با یک savepoint مجزا که فقط
# تفسیر می‌شد و None برمی‌گردوند — یعنی اگه یک IntegrityError واقعیِ # همون create رو rollback می‌کنه — وگرنه یک IntegrityErrorِ دیگه (مثلاً
# دیگه (مثلاً داخل grant_free_subscription) اتفاق می‌افتاد، خطا قورت # داخلِ grant_free_subscription) قورت داده می‌شد و caller اشتباهاً فکر
# داده می‌شد و caller فکر می‌کرد «کاربر قبلاً این آفر رو گرفته»، در # می‌کرد کاربر قبلاً این آفر رو گرفته.
# حالی که اصلاً چنین چیزی نبود. حالا فقط IntegrityError ناشی از
# UniqueConstraintِ خودِ UserOffer.create (duplicate واقعی) گرفته
# می‌شه — با یک savepoint جداگانه (atomic تودرتو) که فقط همون create
# رو rollback می‌کنه، نه کل تراکنش رو — و خطاهای دیگه (مثلاً از داخل
# grant_free_subscription) عادی propagate می‌شن. atomic بودنِ کلِ
# عملیات (UserOffer + Subscription + expires_at) هم دست‌نخورده باقی
# مونده، دقیقاً طبق همون دلیلی که در docstring بالا توضیح داده شده.
with transaction.atomic(): with transaction.atomic():
try: try:
with transaction.atomic(): # savepoint مجزا فقط برای create with transaction.atomic(): # savepoint مجزا فقط برای create

View File

@@ -102,12 +102,7 @@ class RedeemOfferCodeThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
def test_throttle_is_scoped_per_user_not_shared_globally(self): def test_throttle_is_scoped_per_user_not_shared_globally(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه یک حسابِ مهمانِ # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حساب‌های
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
# RedeemOfferCodeGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که باید
# دقیقاً مثلِ قبل دست‌نخورده بمونه.
other_user = User.objects.create(is_guest=False, name='', phone='+989120000701') other_user = User.objects.create(is_guest=False, name='', phone='+989120000701')
other_token = issue_access_token(other_user.id) other_token = issue_access_token(other_user.id)
@@ -299,12 +294,7 @@ class ValidateDiscountThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
def test_throttle_is_scoped_per_user_not_shared_globally(self): def test_throttle_is_scoped_per_user_not_shared_globally(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه یک حسابِ مهمانِ # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حساب‌های
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
# ValidateDiscountGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که باید
# دقیقاً مثلِ قبل دست‌نخورده بمونه.
other_user = User.objects.create(is_guest=False, name='', phone='+989120000731') other_user = User.objects.create(is_guest=False, name='', phone='+989120000731')
other_token = issue_access_token(other_user.id) other_token = issue_access_token(other_user.id)
@@ -387,12 +377,7 @@ class RedeemDiscountThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
def test_throttle_is_scoped_per_user_not_shared_globally(self): def test_throttle_is_scoped_per_user_not_shared_globally(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه یک حسابِ مهمانِ # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حساب‌های
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
# RedeemDiscountGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که باید
# دقیقاً مثلِ قبل دست‌نخورده بمونه.
other_user = User.objects.create(is_guest=False, name='', phone='+989120000301') other_user = User.objects.create(is_guest=False, name='', phone='+989120000301')
other_token = issue_access_token(other_user.id) other_token = issue_access_token(other_user.id)
other_order = Order.objects.create( other_order = Order.objects.create(

View File

@@ -69,21 +69,11 @@ class ValidateDiscountView(APIView):
# صورت کلاینت بدون احراز هویت می‌تواند کدهای تخفیف را probe کند. # صورت کلاینت بدون احراز هویت می‌تواند کدهای تخفیف را probe کند.
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: این endpoint خودِ type/value کد رو هم برمی‌گردونه (نه # این endpoint type/value کد رو برمی‌گردونه، پس بدونِ throttle یک
# فقط valid/invalid)، پس بدونِ throttle یک کاربرِ احرازهویت‌شده می‌تونست # کاربر می‌تونست بی‌نهایت کد رو probe کنه؛ نرخ کمی بالاتر از
# بی‌نهایت کدهای تخفیف رو probe کنه و جزئیاتشون رو یاد بگیره. طبقِ # offer_redeem چون این یک چکِ read-only پیش از خریده. GuestAwareDiscountThrottle
# همون الگویِ ScopedRateThrottle که برای RedeemOfferCodeView # مشترک (هم اینجا هم RedeemDiscountView/RedeemOfferCodeView) برای
# (offer_redeem) اضافه شد: چون کاربر احرازهویت‌شده، کلید بر اساسِ pk # مهمان‌ها کلید رو بر اساسِ IP می‌سازه تا چرخشِ حساب bucket تازه نده.
# کاربره (نه IP). نرخ کمی بالاتر از offer_redeem انتخاب شده چون این
# یک چکِ read-only پیش از خریده (ممکنه کاربر چند کد رو قبل از تصمیمِ
# نهایی امتحان کنه)، نه خودِ مصرف کد.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از همون
# GuestAwareDiscountThrottleِ مشترک (که RedeemDiscountView/
# RedeemOfferCodeView هم استفاده می‌کنن) استفاده می‌شه که برای
# کاربرهای مهمان کلید رو بر اساسِ IP می‌سازه و برای کاربرهای
# غیرمهمان رفتارِ per-user قبلی رو دست‌نخورده نگه می‌داره.
throttle_scope = 'discount_validate' throttle_scope = 'discount_validate'
throttle_classes = [GuestAwareDiscountThrottle] throttle_classes = [GuestAwareDiscountThrottle]
@@ -117,23 +107,11 @@ class RedeemDiscountView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: این endpoint هم دقیقاً همون oracle ای که باعث شد # همون oracleِ probe مثلِ ValidateDiscountView، ولی ریسکش بیشتره: حدسِ
# ValidateDiscountView و RedeemOfferCodeView throttle بگیرن رو داره — # درست یه کدِ واقعی رو مصرف می‌کنه (سهمِ max_uses مشترک) و مبلغِ سفارش
# کدِ ناموجود ۴۰۴ می‌گیره، کدِ موجود-ولی-نامعتبر ۴۰۰ می‌گیره، پس بدونِ # رو واقعاً کم می‌کنه، ضمن اینکه هر تلاش دو select_for_update و یه
# throttle یک کاربرِ احرازهویت‌شده می‌تونست کدهای تخفیف رو روی سفارشِ # تراکنشه. GuestAwareDiscountThrottle برای مهمان‌ها کلید رو بر اساسِ IP
# خودش probe/brute-force کنه. اینجا ریسک از اون دوتا هم بیشتره، چون # می‌سازه تا چرخشِ حساب bucket تازه نده.
# حدسِ درست صرفاً اطلاعات لو نمی‌ده؛ یک کدِ واقعی رو مصرف می‌کنه
# (سهمِ max_uses مشترک بین همه‌ی کاربرها رو می‌خوره) و باعثِ افتِ
# واقعیِ مبلغِ سفارش می‌شه؛ ضمناً هر تلاش دو تا select_for_update و یک
# تراکنشِ دیتابیسی اجرا می‌کنه که گرون‌تر از GET سبکِ validate است.
# طبقِ همون الگو، ScopedRateThrottle با کلیدِ per-user (نه IP) اضافه شد.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از
# GuestAwareDiscountThrottle استفاده می‌شه که برای کاربرهای مهمان
# کلید رو بر اساسِ IP می‌سازه (دقیقاً همون الگویِ GuestAwareAIThrottle
# و GuestAwareFamilyThrottle) و برای کاربرهای غیرمهمان رفتارِ per-user
# قبلی رو دست‌نخورده نگه می‌داره.
throttle_scope = 'discount_redeem' throttle_scope = 'discount_redeem'
throttle_classes = [GuestAwareDiscountThrottle] throttle_classes = [GuestAwareDiscountThrottle]
@@ -200,24 +178,11 @@ class RedeemOfferCodeView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: کدهای نامعتبر فقط یک ValidationError ساده (۴۰۰) برمی‌گردونن # بدونِ throttle، کدهای آفر قابلِ probe بودن؛ اینجا حساس‌تره چون
# و هیچ throttleای اینجا نبود؛ یعنی یک کاربرِ احرازهویت‌شده می‌تونست # Offer.code سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ همون
# بدونِ هیچ محدودیتی کدهای آفر رو حدس بزنه (offer-code probing). طبقِ # کاربر رو می‌گیره — یک حسابِ مهمانِ چرخشی می‌تونست همون آفرِ سراسری رو
# همون الگویِ ScopedRateThrottle که در بقیه‌ی اکشن‌هایِ کم‌تکرار/حساسِ # دوباره بگیره. GuestAwareDiscountThrottle برای مهمان‌ها کلید رو بر
# پروژه استفاده شده (مثلِ purchase در subscription، support_create در # اساسِ IP می‌سازه تا چرخشِ حساب bucket تازه نده.
# support)، اینجا هم یک throttle_scope مجزا اضافه شد.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود — به‌خصوص اینجا
# حساس‌تره چون Offer.code سراسری/یکتاست و redeem_offer_code() فقط
# جلوی تکرارِ *همون* کاربر رو می‌گیره، نه کاربرهای مهمانِ جدید (یک
# حسابِ مهمانِ چرخشی می‌تونست همون آفرِ سراسری رو دوباره از
# apply_offer() بگیره). حالا از همون GuestAwareDiscountThrottle که
# برای RedeemDiscountView اضافه شد استفاده می‌شه (دقیقاً همون الگویِ
# GuestAwareAIThrottle، GuestAwareFamilyThrottle و
# GuestAwarePurchaseThrottle) که برای کاربرهای مهمان کلید رو بر
# اساسِ IP می‌سازه و برای کاربرهای غیرمهمان رفتارِ per-user قبلی رو
# دست‌نخورده نگه می‌داره.
throttle_scope = 'offer_redeem' throttle_scope = 'offer_redeem'
throttle_classes = [GuestAwareDiscountThrottle] throttle_classes = [GuestAwareDiscountThrottle]

View File

@@ -58,11 +58,7 @@ class FamilyLinkAcceptThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
def test_throttle_is_scoped_per_user_not_shared_globally(self): def test_throttle_is_scoped_per_user_not_shared_globally(self):
# این تست مخصوصِ کاربرهای *غیرمهمان* (ثبت‌نام‌شده) است — طبقِ فیکسِ # کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
# GuestAwareFamilyThrottle، حساب‌های مهمان دیگه bucket جداگانه
# ندارن (نگاه کنید به FamilyLinkAcceptGuestThrottleBypassTests پایین‌تر)،
# اما رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده باید دقیقاً مثلِ
# قبل دست‌نخورده بمونه.
other_user = User.objects.create( other_user = User.objects.create(
is_guest=False, name='', phone='+989120000099', is_guest=False, name='', phone='+989120000099',
) )
@@ -241,12 +237,8 @@ class FamilyLinkInviteThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self): def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.parent که # کاربرِ غیرمهمان (نه self.parent که مهمانه)، چون کلیدِ throttleِ
# مهمان است)، چون طبقِ فیکسِ GuestAwareFamilyThrottle حالا کلیدِ # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه.
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# FamilyLinkInviteGuestThrottleBypassTests پایین‌تر) — این تست
# فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین
# می‌کنه.
registered_parent = User.objects.create(is_guest=False, name='', phone='+989120001300') registered_parent = User.objects.create(is_guest=False, name='', phone='+989120001300')
registered_token = issue_access_token(registered_parent.id) registered_token = issue_access_token(registered_parent.id)

View File

@@ -67,17 +67,10 @@ class FamilyLinkInviteView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttle مخصوصی نداشت، درحالی‌که # بدونِ throttle، یک کاربر می‌تونست با فرستادنِ پی‌درپیِ شماره‌های مختلف
# یک FamilyLink ماندگار می‌سازه و یک کدِ دعوتِ ۶رقمیِ تازه تولید می‌کنه. # تعدادِ زیادی FamilyLink بسازه. GuestAwareFamilyThrottle برای مهمان‌ها
# بدونِ throttle، یک کاربرِ احرازهویت‌شده می‌تونست با فرستادنِ # کلید رو بر اساسِ IP می‌سازه؛ scopeِ جدا از family_invite_accept چون
# پی‌درپیِ شماره‌های مختلف، تعدادِ زیادی FamilyLink/کدِ دعوت بسازه. # تولید و مصرفِ کدِ دعوت باید مستقل قابل‌تنظیم باشن.
# طبقِ همون الگویِ GuestAwareFamilyThrottleِ مشترک (که
# FamilyLinkAcceptView هم استفاده می‌کنه): برای کاربرهای مهمان کلید
# بر اساسِ IP و برای کاربرهای غیرمهمان بر اساسِ user.pk است، پس
# چرخشِ حسابِ مهمان نمی‌تونه bucket تازه بگیره. یک throttle_scope
# مجزا ('family_invite_create'، نه 'family_invite_accept') استفاده
# شده چون تولید و مصرفِ کدِ دعوت باید مستقل از هم قابل‌تنظیم بمونن؛
# منطقِ generation/authorization دست‌نخورده‌ست.
throttle_scope = 'family_invite_create' throttle_scope = 'family_invite_create'
throttle_classes = [GuestAwareFamilyThrottle] throttle_classes = [GuestAwareFamilyThrottle]
@@ -131,15 +124,9 @@ class FamilyLinkAcceptView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: inviteCode یک رازِ عددیِ ۶رقمی تولیدشده‌ی سرور است # inviteCode یک رازِ عددیِ ۶رقمیه؛ بدونِ throttle یه کاربر می‌تونست فضای
# (family/views.py::FamilyLinkInviteView)، و این endpoint دقیقاً همون # ۶رقمی رو brute-force کنه. throttle در APIView.initial() قبل از
# کد رو با select_for_update() جست‌وجو می‌کنه؛ بدونِ throttle، یک # post() اجرا می‌شه، پس حدس‌های بلاک‌شده اصلاً به select_for_update()
# کاربرِ احرازهویت‌شده می‌تونست بدونِ هیچ محدودیتی فضای ۶رقمی رو
# brute-force کنه. طبقِ همون الگویِ ScopedRateThrottle که برای بقیه‌ی
# اندپوینت‌هایِ حدس‌پذیر پروژه اضافه شد، اینجا هم یک scope مجزا اضافه
# شد (نه scope مشترک با discount/offer) تا نرخِ محدودسازی مستقل از اون‌ها
# قابل‌تنظیم باشه. چون DRF throttle check قبل از dispatch به post() در
# APIView.initial() اجرا می‌شه، حدس‌های بلاک‌شده اصلاً به select_for_update()
# نمی‌رسن. # نمی‌رسن.
throttle_scope = 'family_invite_accept' throttle_scope = 'family_invite_accept'
throttle_classes = [GuestAwareFamilyThrottle] throttle_classes = [GuestAwareFamilyThrottle]

View File

@@ -8,12 +8,9 @@ class Order(models.Model):
""" """
STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')] STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')]
# دو مدل پرداخت طبق درخواست سینیور: 'gateway' (زرین‌پال/زیبال و مشابه) و # 'gateway' و 'bazaar' منطق و صحت‌سنجیِ کاملاً متفاوتی دارن (webhook در
# 'bazaar' (پرداخت درون‌برنامه‌ای اپلیکیشن بازار). این دو منطق پرداخت و # برابرِ verifyِ سرور-به-سرور با access_token)؛ برای همین provider جداست
# صحت‌سنجی کاملاً متفاوتی دارن (webhook درگاه در برابر verify سرور-به- # از Transaction.gateway (که فقط برای گزارش‌گیریِ درگاهِ واقعیه).
# سرور با access_token بازار)؛ برای همین provider جداست، نه فقط یک مقدار
# دیگه در Transaction.gateway (که فقط برای گزارش‌گیریِ درگاه واقعی زرین‌پال/
# زیبال باقی می‌مونه).
PROVIDER_CHOICES = [('gateway', 'درگاه پرداخت'), ('bazaar', 'پرداخت درون‌برنامه‌ای بازار')] PROVIDER_CHOICES = [('gateway', 'درگاه پرداخت'), ('bazaar', 'پرداخت درون‌برنامه‌ای بازار')]
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
@@ -48,18 +45,11 @@ class Transaction(models.Model):
class Meta: class Meta:
ordering = ['created_at'] ordering = ['created_at']
constraints = [ constraints = [
# 🔒 یکتاییِ سراسریِ (gateway, gateway_ref_id) برای refهای # یکتاییِ سراسریِ (gateway, gateway_ref_id) برای refهای غیرخالی:
# غیرخالی: این قید سطح DB تضمین می‌کنه یک gateway_ref_id مشخص # یک ref مشخص (مثلاً Authorityِ زرین‌پال) فقط می‌تونه متعلقِ یک
# (مثلاً Authority زرین‌پال یا purchaseToken بازار) در کل جدول # Order باشه — هم جلوی duplicate رو می‌گیره هم جلوی reuse/replay
# Transaction فقط می‌تونه متعلق به یک Order باشه — هم جلوی # برای Orderِ دیگه (نگاه کنید به apply_order_payment_result).
# duplicate/idempotency برای همون Order رو می‌گیره (چون در کل # gateway_ref_id__gt='' یعنی refِ خالی مشمول نیست.
# جدول حداکثر یک ردیف با این (gateway, ref) می‌تونه وجود داشته
# باشه) و هم جلوی reuse/replay همون ref برای یک Order دیگه رو
# (نگاه کنید به apply_order_payment_result). این قید، قیدِ
# قدیمی‌ترِ (order, gateway_ref_id, status) رو هم در برمی‌گیره
# (چون سخت‌گیرانه‌تره)، برای همین دیگه لازم نیست جدا نگه داشته
# بشه. gateway_ref_id__gt='' یعنی رفرنس‌های خالی (که provider
# واقعی هنوز رفرنسی برنگردونده) مشمول این قید نمی‌شن.
models.UniqueConstraint( models.UniqueConstraint(
fields=['gateway', 'gateway_ref_id'], fields=['gateway', 'gateway_ref_id'],
condition=models.Q(gateway_ref_id__gt=''), condition=models.Q(gateway_ref_id__gt=''),

View File

@@ -56,12 +56,9 @@ class SecretDefaultsFailClosedTests(TestCase):
self.assertTrue(HasWebhookSecret().has_permission(webhook_request, None)) self.assertTrue(HasWebhookSecret().has_permission(webhook_request, None))
def test_debug_mode_local_fallback_still_valid(self): def test_debug_mode_local_fallback_still_valid(self):
# settings.py این مقادیر رو یک‌بار موقعِ import، بر اساسِ DEBUG واقعیِ # settings.py این مقادیر رو یک‌بار موقعِ import محاسبه می‌کنه، نه
# همون پروسه محاسبه می‌کنه (نه چیزی که override_settings در تست # چیزی که override_settings بعداً toggle کنه؛ پس برای تستِ این
# می‌تونه بعداً toggle کنه). برای تستِ صریحِ همین منطق، ماژولِ # منطق باید خودِ ماژول رو reload کرد.
# config.settings رو با DJANGO_DEBUG=1 و بدونِ ست‌بودنِ
# INTERNAL_SERVICE_KEY/PAYMENT_WEBHOOK_SECRET دوباره reload می‌کنیم
# و مطمئن می‌شیم دیفالتِ محلیِ قبلیِ dev هنوز برقراره.
from config import settings as settings_module from config import settings as settings_module
env_overrides = {'DJANGO_DEBUG': '1'} env_overrides = {'DJANGO_DEBUG': '1'}
@@ -227,12 +224,8 @@ class BazaarVerifyPurchaseThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self): def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
# مهمان است)، چون طبقِ فیکسِ GuestAwareBazaarThrottle حالا کلیدِ # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه.
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# BazaarVerifyPurchaseGuestThrottleBypassTests پایین‌تر) — این تست
# فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین
# می‌کنه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000751') registered_user = User.objects.create(is_guest=False, name='', phone='+989120000751')
registered_token = issue_access_token(registered_user.id) registered_token = issue_access_token(registered_user.id)

View File

@@ -23,25 +23,10 @@ from .services import apply_order_payment_result, create_order
class GuestAwareBazaarThrottle(ScopedRateThrottle): class GuestAwareBazaarThrottle(ScopedRateThrottle):
""" """همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)، حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازه‌ی bazaar_verify
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/ بده، چون هر درخواستِ پذیرفته‌شده یه فراخوانیِ سرور-به-سرور به بازاره.
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py) برای مهمان‌ها کلیدِ cache بر اساسِ IP هست، نه pk."""
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده
از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، برای هر
حساب یک bucket تازه از throttle_scope 'bazaar_verify' بگیره — درحالی‌که
هر درخواستِ پذیرفته‌شده یک فراخوانیِ سرور-به-سرور به API بازار رو
تریگر می‌کنه.
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی
حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب
دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان
(ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ
verify/order ownership/transaction/apply-payment دست‌نخورده‌ست.
"""
def get_cache_key(self, request, view): def get_cache_key(self, request, view):
user = request.user user = request.user
@@ -121,14 +106,10 @@ class PaymentWebhookView(APIView):
""" """
permission_classes = [HasWebhookSecret] permission_classes = [HasWebhookSecret]
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت، درحالی‌که فقط # این endpoint فقط با مقایسه‌ی هدرِ ثابتِ X-Webhook-Secret محافظت می‌شه،
# با مقایسه‌ی یک هدرِ ثابت (X-Webhook-Secret) محافظت می‌شه؛ بدونِ # پس throttle لازمه تا حدسِ سکرت (یا fuzzِ orderId) محدود بمونه. چون
# throttle، تعدادِ نامحدودی تلاش برای حدسِ این سکرت (یا fuzz کردنِ # caller خودِ درگاهه نه یک کاربرِ اپ، از ScopedRateThrottleِ IP-محور
# orderId بعد از افشای احتمالیِ سکرت) ممکن بود، درحالی‌که یک حدسِ # استفاده شده، نه الگویِ GuestAware*Throttle که نیازمندِ request.user است.
# درست باعثِ paid شدنِ واقعیِ سفارش و فعال‌سازیِ اشتراک می‌شه. چون
# این caller یک کاربرِ اپ نیست (خودِ درگاهِ پرداخته)، از یک
# ScopedRateThrottleِ سادهٔ IP-محور استفاده شده، نه الگویِ
# GuestAware*Throttle (که نیازمندِ request.user است).
throttle_scope = 'payment_webhook' throttle_scope = 'payment_webhook'
throttle_classes = [ScopedRateThrottle] throttle_classes = [ScopedRateThrottle]
@@ -182,19 +163,11 @@ class BazaarVerifyPurchaseView(APIView):
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# ⚠️ افزوده شده: این endpoint یک فراخوانیِ سرور-به-سرور به API بازار # این endpoint یک فراخوانیِ سرور-به-سرورِ واقعی به API بازار (verify_purchase)
# (verify_purchase) رو تریگر می‌کنه؛ بدون throttle مخصوص، یک کاربر # تریگر می‌کنه، برای همین throttle_scope مجزا و نرخِ سخت‌گیرانه‌تر (۱۰/hour)
# می‌تونست با ارسال مکرر purchaseToken نامعتبر، بار زیادی روی سرویس # داره. کاربرهای مهمان با هر deviceId یک pk تازه می‌گیرن، پس
# بازار و بک‌اند بندازه. throttle_scope مجزا (bazaar_verify) جدا از # GuestAwareBazaarThrottle برای اون‌ها بر اساسِ IP کلید می‌سازه تا چرخشِ
# نرخ‌های دیگه (مثل otp_*) تنظیم شده. # حساب bucket تازه نگیره؛ برای کاربرهای غیرمهمان رفتارِ per-user حفظ می‌شه.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از
# GuestAwareBazaarThrottle استفاده می‌شه که برای کاربرهای مهمان کلید
# رو بر اساسِ IP می‌سازه و برای کاربرهای غیرمهمان رفتارِ per-user
# قبلی رو دست‌نخورده نگه می‌داره. نرخ هم از ۲۰/hour به ۱۰/hour
# سخت‌گیرانه‌تر شد چون هر درخواستِ پذیرفته‌شده یک تماسِ سرور-به-سرورِ
# واقعی با بازار می‌زنه.
throttle_scope = 'bazaar_verify' throttle_scope = 'bazaar_verify'
throttle_classes = [GuestAwareBazaarThrottle] throttle_classes = [GuestAwareBazaarThrottle]
@@ -208,13 +181,10 @@ class BazaarVerifyPurchaseView(APIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
v = serializer.validated_data v = serializer.validated_data
# ⚛️🔒 اصلاح: فراخوانی HTTP به API بازار (تا ~۲۰ ثانیه، دو درخواست # verify_purchase (تا ~۲۰ ثانیه، دو درخواستِ پشت‌سرهم) عمداً *قبل* از
# پشت‌سرهم برای refresh token + verify) قبلاً *داخل* transaction.atomic # بازشدنِ atomic/lock صدا زده می‌شه، نه داخلش — وگرنه ردیفِ Order (و هر
# و زیر select_for_update روی Order انجام می‌شد؛ یعنی ردیف Order (و # تراکنشِ دیگه‌ای روی همون Order/Subscription) تا برگشتِ پاسخِ بازار
# هر تراکنش دیگه‌ای که بخواد همون Order یا Subscription مرتبط رو لمس # قفل می‌موند. لاک فقط برای اعمالِ نتیجه (چند میلی‌ثانیه) گرفته می‌شه.
# کنه) تا برگشتِ پاسخ بازار قفل می‌موند. حالا verify_purchase قبل از
# باز شدن atomic/lock صدا زده می‌شه؛ لاک فقط برای اعمال نتیجه (چند
# میلی‌ثانیه) گرفته می‌شه.
order_lookup = get_object_or_404( order_lookup = get_object_or_404(
Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar', Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar',
) )

View File

@@ -14,31 +14,21 @@ class Plan(models.Model):
price = models.PositiveIntegerField() price = models.PositiveIntegerField()
currency = models.CharField(max_length=10, default='IRT') currency = models.CharField(max_length=10, default='IRT')
discount_percent = models.PositiveSmallIntegerField(default=0) discount_percent = models.PositiveSmallIntegerField(default=0)
popular = models.BooleanField(default=False) # <-- اصلاح/افزوده شده popular = models.BooleanField(default=False)
# ⚠️ اصلاح دیگه: سند طراحی هیچ فیلد duration‌ای برای Plan نداره، در # منبعِ صریحِ مدتِ اشتراک؛ بدونش activate_subscription مجبور بود مدت رو
# نتیجه‌ی این نبود، activate_subscription قبلاً مدت اشتراک رو با یک # از رویِ متنِ title (مثلاً کلمه‌ی «سالانه») حدس بزنه.
# قاعده‌ی stub (چک کردن کلمه‌ی «سالانه»/yearly در عنوان) حدس می‌زد که duration_days = models.PositiveIntegerField(default=30)
# شکننده و وابسته به متن title بود. duration_days اینجا به‌عنوان منبع
# صریح و قابل‌اتکای مدت اشتراک اضافه شده؛ پیش‌فرض ۳۰ روز.
duration_days = models.PositiveIntegerField(default=30) # <-- اصلاح/افزوده شده
def __str__(self): def __str__(self):
return self.title return self.title
class Subscription(models.Model): class Subscription(models.Model):
""" """وضعیتِ pending برای فاصله‌ی بینِ ساختنِ Subscription (در purchase) و
⚠️ اصلاح نسبت به سند اولیه: STATUS_CHOICES در سند فقط شامل تاییدِ پرداخت (webhook)؛ فعال‌سازیِ واقعی توسطِ
active/expired/canceled بود. اما در جریان POST /api/subscription/purchase subscription.services.activate_subscription انجام می‌شه."""
یک Subscription قبل از تایید پرداخت (که با payment webhook میاد) ساخته
می‌شه؛ بدون یک وضعیت میانی، راهی برای نمایش «سفارش ثبت شده ولی هنوز
پرداخت تایید نشده» وجود نداره. برای همین وضعیت pending اضافه و به‌عنوان
پیش‌فرض ساخت (به‌جای active در سند) تنظیم شده؛ فعال‌سازی واقعی توسط
subscription.services.activate_subscription بعد از webhook پرداخت انجام
می‌شه.
"""
STATUS_CHOICES = [ STATUS_CHOICES = [
('pending', 'در انتظار پرداخت'), # <-- اصلاح/افزوده شده ('pending', 'در انتظار پرداخت'),
('active', 'فعال'), ('active', 'فعال'),
('expired', 'منقضی'), ('expired', 'منقضی'),
('canceled', 'لغوشده'), ('canceled', 'لغوشده'),
@@ -50,19 +40,11 @@ class Subscription(models.Model):
started_at = models.DateTimeField(auto_now_add=True) started_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True) expires_at = models.DateTimeField(null=True, blank=True)
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
# 🔒 اصلاحِ رگرسیونِ concurrency در PurchaseView.post: چون Subscription # چون Subscription فقط یک ردیف در ازای هر user_id داره، دو خریدِ هم‌زمانِ
# فقط یک ردیف در ازای هر user_id داره، دو درخواستِ هم‌زمانِ خریدِ همون # همون کاربر می‌تونن به وضعیتِ میانیِ کاملاً یکسانی برسن (مقایسه‌ی
# کاربر (مثلاً با همون plan) می‌تونن دقیقاً به یک وضعیتِ میانیِ یکسان # فیلدها کافی نیست برای CAS). version با UPDATEِ اتمیکِ
# برسن (status='pending', همون plan_id, order_id=None) — یعنی مقایسه‌ی # version=F('version')+1 (بدونِ select_for_update) هر نوشتنِ واقعی رو
# مقادیرِ فیلدها (که قبلاً به‌عنوانِ CAS guard در cleanup استفاده می‌شد) # از نوشتنِ هم‌زمانِ دیگری تمیز می‌ده.
# نمی‌تونه بینِ «ردیفِ نوشته‌شده توسطِ خودِ همین درخواست» و «ردیفِ
# نوشته‌شده توسطِ یک درخواستِ هم‌زمانِ دیگه» تمایز بذاره. version یک
# شمارنده‌ی صریحه که PurchaseView.post هر بار که واقعاً روی این ردیف
# می‌نویسه (با UPDATE اتمیکِ version=F('version')+1، بدونِ نیاز به
# select_for_update/نگه‌داشتنِ قفل) افزایشش می‌ده؛ پس حتی وقتی محتوای
# semantic فیلدهای دیگه بینِ دو نویسنده‌ی هم‌زمان یکسانه، version همیشه
# بینِ نوشته‌های مجزا فرق می‌کنه و می‌تونه به‌عنوانِ CAS token قابل‌اتکا
# برای cleanup استفاده بشه.
version = models.PositiveIntegerField(default=0) version = models.PositiveIntegerField(default=0)
def __str__(self): def __str__(self):

View File

@@ -362,12 +362,9 @@ class PurchaseThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self): def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که # کاربرِ غیرمهمان لازمه، نه self.user، چون کلیدِ bucketِ مهمان‌ها
# مهمان است)، چون طبقِ فیکسِ GuestAwarePurchaseThrottle حالا کلیدِ # عمداً بر اساسِ IP است (نگاه کنید به PurchaseGuestThrottleBypassTests)
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به # و این تست رفتارِ per-user برای ثبت‌نام‌شده‌ها را می‌سنجد.
# PurchaseGuestThrottleBypassTests پایین‌تر) — این تست فقط رفتارِ
# per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که
# باید دقیقاً مثلِ قبل دست‌نخورده بمونه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000501') registered_user = User.objects.create(is_guest=False, name='', phone='+989120000501')
registered_token = issue_access_token(registered_user.id) registered_token = issue_access_token(registered_user.id)

View File

@@ -21,27 +21,10 @@ from .serializers import (
class GuestAwarePurchaseThrottle(ScopedRateThrottle): class GuestAwarePurchaseThrottle(ScopedRateThrottle):
""" """همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)، حساب مهمان نباید bucket تازه‌ی purchase بده — این endpoint حساسِ مالیه
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle (Subscription می‌سازه/آپدیت می‌کنه، create_order رو صدا می‌زنه). برای
(discount/views.py) اینجا هم هست. ScopedRateThrottle استاندارد برای مهمان‌ها کلیدِ cache بر اساسِ IP هست، نه pk."""
درخواستِ احرازهویت‌شده از request.user.pk به‌عنوانِ کلیدِ throttle
استفاده می‌کنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ
مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ
حساب‌های مهمان (تا سقفِ guest_session=20/hour)، برای هر حساب یک
bucket تازه از throttle_scope 'purchase' بگیره — یعنی سقفِ ۱۰/ساعتِ
درخواستِ purchase عملاً با چرخشِ حساب مهمان چند برابر می‌شد. این
endpoint حساسِ مالیه: هر درخواستِ پذیرفته‌شده وارد جریانِ purchase
می‌شه، یک Subscription می‌سازه/آپدیت می‌کنه و create_order() رو صدا
می‌زنه (که در پروداکشن با درگاهِ واقعی جایگزینِ stub فعلی می‌شه).
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی
حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب
دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان
(ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ
purchase/subscription/order/Idempotency-Key/plan-validation دست‌نخورده‌ست.
"""
def get_cache_key(self, request, view): def get_cache_key(self, request, view):
user = request.user user = request.user
@@ -85,20 +68,12 @@ class PurchaseView(APIView):
to create an Order.""" to create an Order."""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# scope کیدهای cache رو با pk کاربرِ احرازهویت‌شده می‌سازه (نه فقط IP)؛ # این throttle مستقلِ Idempotency-Key است چون اون فقط جلوی تکرارِ *همون*
# چون Idempotency-Key به‌تنهایی جلوی حجمِ بالای درخواست‌های *متفاوت* # کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت. کاربرهای مهمان با هر
# (با کلیدهای idempotency متفاوت) رو نمی‌گیره، این throttle مستقل و # deviceId یک pk تازه می‌گیرن، پس کلیدِ per-user ساده قابل‌دورزدنه؛
# مکملِ اون محافظته. ScopedRateThrottle خودش وقتی request.user # GuestAwarePurchaseThrottle برای مهمان‌ها بر اساسِ IP کلید می‌سازه
# احرازهویت‌شده باشه (is_authenticated=True) به‌جای IP از # (مثلِ GuestAwareAIThrottle/FamilyThrottle/DiscountThrottle) و برای
# request.user.pk به‌عنوانِ ident استفاده می‌کنه، پس تغییرِ IP از # کاربرهای غیرمهمان رفتارِ per-user را حفظ می‌کنه.
# همون اکانت این محدودیت رو دور نمی‌زنه.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از
# GuestAwarePurchaseThrottle استفاده می‌شه که برای کاربرهای مهمان
# کلید رو بر اساسِ IP می‌سازه (دقیقاً همون الگویِ GuestAwareAIThrottle،
# GuestAwareFamilyThrottle و GuestAwareDiscountThrottle) و برای
# کاربرهای غیرمهمان رفتارِ per-user قبلی رو دست‌نخورده نگه می‌داره.
throttle_scope = 'purchase' throttle_scope = 'purchase'
throttle_classes = [GuestAwarePurchaseThrottle] throttle_classes = [GuestAwarePurchaseThrottle]

View File

@@ -54,12 +54,8 @@ class BugReportCreateThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self): def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
# مهمان است)، چون طبقِ فیکسِ GuestAwareSupportThrottle حالا کلیدِ # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه.
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه
# که باید دقیقاً مثلِ قبل دست‌نخورده بمونه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000901') registered_user = User.objects.create(is_guest=False, name='', phone='+989120000901')
registered_token = issue_access_token(registered_user.id) registered_token = issue_access_token(registered_user.id)
@@ -131,11 +127,8 @@ class TicketCreateThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self): def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه.
# عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ
# per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000902') registered_user = User.objects.create(is_guest=False, name='', phone='+989120000902')
registered_token = issue_access_token(registered_user.id) registered_token = issue_access_token(registered_user.id)
@@ -208,11 +201,8 @@ class TicketMessageCreateThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self): def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که # کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان # مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه.
# عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ
# per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000903') registered_user = User.objects.create(is_guest=False, name='', phone='+989120000903')
registered_token = issue_access_token(registered_user.id) registered_token = issue_access_token(registered_user.id)
registered_ticket = Ticket.objects.create( registered_ticket = Ticket.objects.create(

View File

@@ -60,12 +60,9 @@ class BugReportCreateView(APIView):
"""POST /api/support/bug-reports — ثبت گزارش باگ""" """POST /api/support/bug-reports — ثبت گزارش باگ"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# هیچ مکانیزمِ idempotency ای اینجا نیست، پس بدونِ throttle یک کاربرِ # بدونِ idempotency/throttle، یک کاربر می‌تونست تعدادِ نامحدودی BugReport
# احرازهویت‌شده می‌تونه تعدادِ نامحدودی BugReport با درخواست‌های # بسازه؛ GuestAwareSupportThrottle برای مهمان‌ها کلید رو بر اساسِ IP
# پرتکرار بسازه. ScopedRateThrottle با pk کاربر کلید می‌خوره (نه IP). # می‌سازه تا چرخشِ حساب bucket تازه نده.
# ⚠️ اصلاح شده: چون کاربرهای مهمان با هر deviceId جدید یک pk تازه
# می‌گیرن، از GuestAwareSupportThrottle استفاده می‌شه تا چرخشِ حساب
# مهمان دیگه bucket تازه ندهد (نگاه کنید به تعریفِ کلاس بالا).
throttle_scope = 'support_create' throttle_scope = 'support_create'
throttle_classes = [GuestAwareSupportThrottle] throttle_classes = [GuestAwareSupportThrottle]