see the real client IP behind ArvanCloud, and mark all notifications read in one call

Production is ArvanCloud -> nginx -> gunicorn, but NUM_PROXIES was 1, so DRF
took ArvanCloud's edge IP as the client: every guest and every OTP request
behind one edge shared a single throttle bucket (20 guest sessions an hour for
everyone). Default is now 2, and /api/health reports clientIp so the proxy
count can be checked from outside.

PATCH /api/notifications/read-all replaces the frontend's one-PATCH-per-item
loop, which spent the shared "read" budget 75 requests at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 05:03:27 +03:30
parent 147af3ac10
commit c874c186b1
8 changed files with 83 additions and 4 deletions

View File

@@ -50,7 +50,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
**Apps are decoupled by convention**: instead of real FKs across app boundaries, code uses loose refs (`user_id`, `object_id` + `label`) so any app can be split out later. Don't add cross-app FKs — follow the existing loose-reference pattern.
**Throttling**: DRF `ScopedRateThrottle` with per-endpoint `throttle_scope` strings, rates centralized in `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` in `config/settings.py`. Guest users are throttled by IP (`NUM_PROXIES` setting controls how many XFF hops are trusted — must match real proxy count in prod or IP spoofing/throttle-bypass is possible). Adding a new sensitive endpoint means adding both a `throttle_scope` on the view and a matching rate entry in settings.
**Throttling**: DRF `ScopedRateThrottle` with per-endpoint `throttle_scope` strings, rates centralized in `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` in `config/settings.py`. Guest users are throttled by IP (`NUM_PROXIES` setting controls how many XFF hops are trusted — must match real proxy count in prod or IP spoofing/throttle-bypass is possible). Prod is ArvanCloud → nginx → gunicorn, so the default is **2**; it was 1 until 2026-09-24, which made every guest share ArvanCloud's IP bucket. `/api/health` returns `clientIp` — it should be your own public IP. Adding a new sensitive endpoint means adding both a `throttle_scope` on the view and a matching rate entry in settings.
**`POST /api/ai/ask`** is real RAG (Qdrant retrieval filtered by chapter → GapGPT generation) whenever `OPENAI_API_KEY` and `QDRANT_API_KEY` are set, which they are on the server since 2026-09-23; without them it falls back to a deterministic stub. Entry point is `generate_tutor_answer` in `ai_tutor/views.py`; `usedChunks` in the response names the book chunks the answer was grounded on.

View File

@@ -180,5 +180,10 @@ class HealthView(APIView):
'integrations': _check_integrations(),
'traffic': _traffic(),
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
# IPای که throttle برای همین درخواست می‌بیند. باید IPِ عمومیِ خودِ
# صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط
# است و همه‌ی مهمان‌ها یک سهمِ مشترک دارند. فقط IPِ خودِ صدازننده
# را به خودش برمی‌گرداند، پس چیزی لو نمی‌دهد.
'clientIp': ScopedRateThrottle().get_ident(request),
}
return Response(payload, status=200 if critical_ok else 503)

View File

@@ -394,7 +394,15 @@ REST_FRAMEWORK = {
# تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این
# مقدار باید دقیقاً با تعدادِ لایه‌ی پراکسی/LB جلوی اپ یکی باشه (مثلاً
# Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده می‌شه.
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')),
#
# پروداکشن دو لایه دارد: ArvanCloud (TLS، `server: ArvanCloud`) → nginx روی
# همین ماشین → gunicorn. XFF در جنگو «IPِ کاربر, IPِ آروان» است؛ با ۱
# آخرین مقدار، یعنی IPِ آروان، IPِ کاربر حساب می‌شد و همه‌ی مهمان‌ها و همه‌ی
# درخواست‌های OTP پشتِ یک edgeِ آروان یک سهمِ مشترک داشتند (ورودِ مهمان
# ۲۰ در ساعت برای *همه*). برای اجرای محلی بی‌پراکسی هم بی‌خطر است: وقتی
# XFF نیست یا کوتاه‌تر است، DRF همان REMOTE_ADDR/اولین مقدار را می‌گیرد.
# `/api/health` در `clientIp` نشان می‌دهد سرور چه IPای می‌بیند.
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '2')),
'DEFAULT_THROTTLE_RATES': {
'otp_request': '5/hour',
'otp_login': '10/hour',

View File

@@ -36,6 +36,16 @@
`X-Forwarded-For` قابلِ اعتماد است و **باید با تعدادِ واقعیِ پروکسی‌ها یکی
باشد**، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن می‌شود.
روی پروداکشن این عدد **۲** است (پیش‌فرضِ کد از ۲۰۲۶-۰۹-۲۴): ArvanCloud جلوی
سرور است و بعد nginxِ خودِ ماشین. تا آن روز ۱ بود و جنگو IPِ آروان را IPِ کاربر
می‌دید؛ یعنی همه‌ی مهمان‌ها یک سهمِ مشترک داشتند. `clientIp` در `/api/health`
نشان می‌دهد سرور صدازننده را با چه IPای می‌شناسد — باید IPِ عمومیِ خودتان باشد.
⚠️ سرور روی پورتِ ۸۰ مستقیم هم (بدونِ آروان) جواب می‌دهد. کسی که IPِ سرور را
بداند می‌تواند از آن راه XFF جعل کند و محدودیتِ IP را دور بزند. ورودِ OTP جدا
به ازای هر شماره هم محدود است، پس این راه به حدسِ کد نمی‌رسد. بستنِ کاملش (پورتِ
۸۰ فقط برای رنج‌های آروان، یا `set_real_ip_from` در nginx) کارِ ادمینِ سرور است.
## پرداخت: هرگز مستقیم به درگاه
`create_order` هیچ‌وقت زرین‌پال را صدا نمی‌زند. `paymentUrl` به اندپوینتِ خودمان

View File

@@ -24,3 +24,8 @@ class NotificationListResponseSerializer(serializers.Serializer):
class MarkReadResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
class MarkAllReadResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
updated = serializers.IntegerField()

View File

@@ -146,3 +146,29 @@ class NotificationPayloadShapeTests(TestCase):
r = self.client.get('/api/notifications', HTTP_AUTHORIZATION=f'Bearer {self.token}')
self.assertEqual(r.data['items'][0]['body'], '')
class MarkAllReadTests(TestCase):
"""دکمه‌ی «همه خوانده شد» با یک درخواست، نه یکی برای هر اعلان."""
def setUp(self):
from django.core.cache import cache
cache.clear()
from accounts.authentication import issue_access_token
from accounts.models import User
self.me = User.objects.create(phone='09120000021')
self.other = User.objects.create(phone='09120000022')
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.me.id)}'}
for user in (self.me, self.me, self.other):
Notification.objects.create(user_id=user.id, type='xp_earned', title='x')
def test_marks_only_my_unread_notifications(self):
response = self.client.patch('/api/notifications/read-all', **self.auth)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {'success': True, 'updated': 2})
self.assertFalse(Notification.objects.filter(user_id=self.me.id, is_read=False).exists())
self.assertTrue(Notification.objects.filter(user_id=self.other.id, is_read=False).exists())
def test_requires_sign_in(self):
self.assertEqual(self.client.patch('/api/notifications/read-all').status_code, 401)

View File

@@ -1,8 +1,9 @@
from django.urls import path
from .views import NotificationListView, NotificationMarkReadView
from .views import NotificationListView, NotificationMarkAllReadView, NotificationMarkReadView
urlpatterns = [
path('notifications', NotificationListView.as_view(), name='notifications-list'),
path('notifications/read-all', NotificationMarkAllReadView.as_view(), name='notifications-mark-all-read'),
path('notifications/<int:id>/read', NotificationMarkReadView.as_view(), name='notifications-mark-read'),
]

View File

@@ -6,7 +6,9 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .models import Notification
from .serializers import MarkReadResponseSerializer, NotificationListResponseSerializer
from .serializers import (
MarkAllReadResponseSerializer, MarkReadResponseSerializer, NotificationListResponseSerializer,
)
# سقفِ تعدادِ اعلانِ برگشتی؛ جلوی رشدِ نامحدودِ پاسخ برای کاربرِ قدیمی رو
# می‌گیره. صفحه‌بندیِ واقعی (cursor) وقتی فرانت infinite-scroll اضافه کنه.
@@ -60,3 +62,25 @@ class NotificationMarkReadView(APIView):
notification.save(update_fields=['is_read'])
return Response(MarkReadResponseSerializer({'success': True}).data)
class NotificationMarkAllReadView(APIView):
"""
PATCH /api/notifications/read-all — همه‌ی اعلان‌های کاربر خوانده‌شده.
دکمه‌ی «همه خوانده شد» پیش از این برای هر اعلان یک درخواستِ جدا می‌زد؛
حسابی با ۷۵ اعلان یعنی ۷۵ درخواستِ هم‌زمان که سهمِ throttleِ read را
(همان که /home و بقیه مصرف می‌کنند) یک‌جا می‌خورد.
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle]
@extend_schema(request=None, responses=MarkAllReadResponseSerializer,
tags=['Notifications (notifications)'])
def patch(self, request):
updated = Notification.objects.filter(
user_id=request.user.id, is_read=False,
).update(is_read=True)
return Response(MarkAllReadResponseSerializer({'success': True, 'updated': updated}).data)