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:
@@ -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.
|
**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.
|
**`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.
|
||||||
|
|
||||||
|
|||||||
@@ -180,5 +180,10 @@ class HealthView(APIView):
|
|||||||
'integrations': _check_integrations(),
|
'integrations': _check_integrations(),
|
||||||
'traffic': _traffic(),
|
'traffic': _traffic(),
|
||||||
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
|
'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)
|
return Response(payload, status=200 if critical_ok else 503)
|
||||||
|
|||||||
@@ -394,7 +394,15 @@ REST_FRAMEWORK = {
|
|||||||
# تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این
|
# تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این
|
||||||
# مقدار باید دقیقاً با تعدادِ لایهی پراکسی/LB جلوی اپ یکی باشه (مثلاً
|
# مقدار باید دقیقاً با تعدادِ لایهی پراکسی/LB جلوی اپ یکی باشه (مثلاً
|
||||||
# Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده میشه.
|
# 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': {
|
'DEFAULT_THROTTLE_RATES': {
|
||||||
'otp_request': '5/hour',
|
'otp_request': '5/hour',
|
||||||
'otp_login': '10/hour',
|
'otp_login': '10/hour',
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
`X-Forwarded-For` قابلِ اعتماد است و **باید با تعدادِ واقعیِ پروکسیها یکی
|
`X-Forwarded-For` قابلِ اعتماد است و **باید با تعدادِ واقعیِ پروکسیها یکی
|
||||||
باشد**، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن میشود.
|
باشد**، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن میشود.
|
||||||
|
|
||||||
|
روی پروداکشن این عدد **۲** است (پیشفرضِ کد از ۲۰۲۶-۰۹-۲۴): ArvanCloud جلوی
|
||||||
|
سرور است و بعد nginxِ خودِ ماشین. تا آن روز ۱ بود و جنگو IPِ آروان را IPِ کاربر
|
||||||
|
میدید؛ یعنی همهی مهمانها یک سهمِ مشترک داشتند. `clientIp` در `/api/health`
|
||||||
|
نشان میدهد سرور صدازننده را با چه IPای میشناسد — باید IPِ عمومیِ خودتان باشد.
|
||||||
|
|
||||||
|
⚠️ سرور روی پورتِ ۸۰ مستقیم هم (بدونِ آروان) جواب میدهد. کسی که IPِ سرور را
|
||||||
|
بداند میتواند از آن راه XFF جعل کند و محدودیتِ IP را دور بزند. ورودِ OTP جدا
|
||||||
|
به ازای هر شماره هم محدود است، پس این راه به حدسِ کد نمیرسد. بستنِ کاملش (پورتِ
|
||||||
|
۸۰ فقط برای رنجهای آروان، یا `set_real_ip_from` در nginx) کارِ ادمینِ سرور است.
|
||||||
|
|
||||||
## پرداخت: هرگز مستقیم به درگاه
|
## پرداخت: هرگز مستقیم به درگاه
|
||||||
|
|
||||||
`create_order` هیچوقت زرینپال را صدا نمیزند. `paymentUrl` به اندپوینتِ خودمان
|
`create_order` هیچوقت زرینپال را صدا نمیزند. `paymentUrl` به اندپوینتِ خودمان
|
||||||
|
|||||||
@@ -24,3 +24,8 @@ class NotificationListResponseSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class MarkReadResponseSerializer(serializers.Serializer):
|
class MarkReadResponseSerializer(serializers.Serializer):
|
||||||
success = serializers.BooleanField()
|
success = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class MarkAllReadResponseSerializer(serializers.Serializer):
|
||||||
|
success = serializers.BooleanField()
|
||||||
|
updated = serializers.IntegerField()
|
||||||
|
|||||||
@@ -146,3 +146,29 @@ class NotificationPayloadShapeTests(TestCase):
|
|||||||
r = self.client.get('/api/notifications', HTTP_AUTHORIZATION=f'Bearer {self.token}')
|
r = self.client.get('/api/notifications', HTTP_AUTHORIZATION=f'Bearer {self.token}')
|
||||||
|
|
||||||
self.assertEqual(r.data['items'][0]['body'], '')
|
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)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import NotificationListView, NotificationMarkReadView
|
from .views import NotificationListView, NotificationMarkAllReadView, NotificationMarkReadView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('notifications', NotificationListView.as_view(), name='notifications-list'),
|
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'),
|
path('notifications/<int:id>/read', NotificationMarkReadView.as_view(), name='notifications-mark-read'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ from rest_framework.throttling import ScopedRateThrottle
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from .models import Notification
|
from .models import Notification
|
||||||
from .serializers import MarkReadResponseSerializer, NotificationListResponseSerializer
|
from .serializers import (
|
||||||
|
MarkAllReadResponseSerializer, MarkReadResponseSerializer, NotificationListResponseSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
# سقفِ تعدادِ اعلانِ برگشتی؛ جلوی رشدِ نامحدودِ پاسخ برای کاربرِ قدیمی رو
|
# سقفِ تعدادِ اعلانِ برگشتی؛ جلوی رشدِ نامحدودِ پاسخ برای کاربرِ قدیمی رو
|
||||||
# میگیره. صفحهبندیِ واقعی (cursor) وقتی فرانت infinite-scroll اضافه کنه.
|
# میگیره. صفحهبندیِ واقعی (cursor) وقتی فرانت infinite-scroll اضافه کنه.
|
||||||
@@ -60,3 +62,25 @@ class NotificationMarkReadView(APIView):
|
|||||||
notification.save(update_fields=['is_read'])
|
notification.save(update_fields=['is_read'])
|
||||||
|
|
||||||
return Response(MarkReadResponseSerializer({'success': True}).data)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user