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.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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` به اندپوینتِ خودمان
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user