From c874c186b19323ef63457015113d262915de6644 Mon Sep 17 00:00:00 2001 From: Zareian Date: Thu, 24 Sep 2026 05:03:27 +0330 Subject: [PATCH] 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 --- CLAUDE.md | 2 +- config/health.py | 5 +++++ config/settings.py | 10 +++++++++- docs/architecture.md | 10 ++++++++++ notifications/serializers.py | 5 +++++ notifications/tests.py | 26 ++++++++++++++++++++++++++ notifications/urls.py | 3 ++- notifications/views.py | 26 +++++++++++++++++++++++++- 8 files changed, 83 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index afa3cdf..e864271 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/config/health.py b/config/health.py index 008446d..459330b 100644 --- a/config/health.py +++ b/config/health.py @@ -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) diff --git a/config/settings.py b/config/settings.py index 588d21f..f3e22cc 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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', diff --git a/docs/architecture.md b/docs/architecture.md index 02b5085..993807c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` به اندپوینتِ خودمان diff --git a/notifications/serializers.py b/notifications/serializers.py index 8f8cd13..f01bcc6 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -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() diff --git a/notifications/tests.py b/notifications/tests.py index 8974509..7a92e18 100644 --- a/notifications/tests.py +++ b/notifications/tests.py @@ -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) diff --git a/notifications/urls.py b/notifications/urls.py index b2b8bb3..abe81cc 100644 --- a/notifications/urls.py +++ b/notifications/urls.py @@ -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//read', NotificationMarkReadView.as_view(), name='notifications-mark-read'), ] diff --git a/notifications/views.py b/notifications/views.py index 544bf8e..f371c44 100644 --- a/notifications/views.py +++ b/notifications/views.py @@ -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)