""" میان‌افزارِ سبکِ لاگِ درخواست‌های API — سطحِ پروژه. ⚠️ افزوده شده: پروژه چند logger پراکنده‌ی اپلیکیشنی داشت (accounts.services، ai_tutor.views، ...) اما هیچ لاگِ یکپارچه‌ای در سطحِ پروژه برای *همه‌ی* درخواست‌های API وجود نداشت؛ یعنی درخواست‌های عادی، ناموفق، یا الگوهای مشکوک (مثلاً یک IP/کاربر که پشتِ سرِ هم درخواستِ ناموفق می‌فرسته) به‌صورتِ یکدست قابلِ ردیابی نبودن. این میان‌افزار فقط یک خط لاگِ خلاصه به‌ازای هر درخواستِ زیرِ /api/ ثبت می‌کنه: متد، مسیر، کدِ وضعیتِ پاسخ، مدت‌زمان، و شناسه‌ی کاربرِ احرازهویت‌شده (اگه وجود داشته باشه). نکاتِ امنیتی/حریمِ خصوصی: - هیچ‌وقت بدنه‌ی درخواست/پاسخ، query string، هدرها (از جمله Authorization، Idempotency-Key و غیره)، رمزعبور/OTP، access/refresh token، کلیدِ API، یا سکرتِ webhook لاگ نمی‌شه — فقط request.path (بدونِ query string) و متادیتای بی‌ضررِ بالا. - در صورتِ هر خطای غیرمنتظره در خودِ فرآیندِ لاگ‌کردن (مثلاً یک مقدارِ غیرمنتظره برای user)، این میان‌افزار هیچ‌وقت نباید جریانِ درخواست/پاسخ را بشکنه؛ به همین خاطر کل بخشِ لاگ داخلِ try/except قرار گرفته. - ⚠️ اصلاح: قبلاً اگه self.get_response(request) خودش یک exceptionِ دست‌نخورده (unhandled) بالا می‌آورد، اون exception قبل از رسیدن به بلوکِ لاگ از __call__ خارج می‌شد؛ یعنی خطاهای ۵۰۰ اصلاً لاگ نمی‌شدن. حالا self.get_response در try/except قرار گرفته: در صورتِ exception، یک خطِ لاگِ سطحِ ERROR (با متد/مسیر/مدت/user_id، بدونِ جزئیاتِ حساس) ثبت می‌شه و بعد همون exceptionِ اصلی بدونِ تغییر دوباره raise می‌شه (تا رفتارِ مدیریتِ خطای Django/DRF دست‌نخورده بمونه). - ⚠️ اصلاحِ این دور: پیامِ لاگِ مسیرِ exception از یک placeholderِ واقعیِ status=%s استفاده می‌کنه (هم‌سو با مسیرِ موفق)، و status=500 حالا از طریقِ آرگومانِ status به _log_safely() صریحاً پاس داده می‌شه، نه به‌صورتِ متنِ ثابت داخلِ رشته. این هم پیام رو با مسیرِ موفق یکدست می‌کنه و هم از ناهماهنگیِ تعدادِ آرگومان‌های % formatting جلوگیری می‌کنه. """ import logging import time logger = logging.getLogger(__name__) # فقط مسیرهای API لاگ می‌شن؛ درخواست‌های استاتیک/غیرِ API (که این پروژه # اصلاً مسیرِ URL جداگانه‌ای براشون در config/urls.py تعریف نکرده) عمداً # نادیده گرفته می‌شن تا لاگ شلوغ/بی‌فایده تولید نشه. _LOGGED_PATH_PREFIX = '/api/' def _user_id_for_log(request): """شناسه‌ی کاربرِ احرازهویت‌شده برای لاگ، یا None (کاربرِ ناشناس/بدونِ user).""" user = getattr(request, 'user', None) return user.id if getattr(user, 'is_authenticated', False) else None class APIRequestLoggingMiddleware: """میان‌افزارِ Django برای لاگِ خلاصه‌ی هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر).""" def __init__(self, get_response): self.get_response = get_response def __call__(self, request): if not request.path.startswith(_LOGGED_PATH_PREFIX): return self.get_response(request) start = time.monotonic() try: response = self.get_response(request) except Exception: self._log_safely( logger.error, 'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s (unhandled exception)', request, start, status=500, ) raise self._log_safely( logger.info, 'api_request method=%s path=%s status=%s duration_ms=%.1f user_id=%s', request, start, response.status_code, ) return response def _log_safely(self, log_func, message, request, start, status=None): # لاگ‌کردن هیچ‌وقت نباید خودش دلیلِ شکستِ یک درخواست بشه (چه در # مسیرِ موفق، چه در مسیرِ exception)؛ به همین خاطر کل بخشِ لاگ # داخلِ try/except قرار گرفته. try: duration_ms = (time.monotonic() - start) * 1000 if status is None: log_func(message, request.method, request.path, duration_ms, _user_id_for_log(request)) else: log_func(message, request.method, request.path, status, duration_ms, _user_id_for_log(request)) except Exception: logger.exception('api_request logging failed') class APINoStoreMiddleware: """ هر پاسخِ `/api/` هدرِ `Cache-Control: no-store` می‌گیرد، مگر خودِ view چیزِ دیگری گذاشته باشد (مثلاً استریمِ دستیار که `no-cache` دارد). ArvanCloud جلوی سرور است و الان برای API کش نمی‌کند (`x-cache: BYPASS`)، ولی این فقط به تنظیمِ پنلِ آروان بند است. پاسخ‌های API به کاربر وابسته‌اند (توکنِ Bearer)؛ اگر روزی کسی کش را در پنل روشن کند، بدونِ این هدر یک دانش‌آموز می‌توانست `/api/home` یا پروفایلِ دیگری را ببیند. """ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): response = self.get_response(request) if request.path.startswith(_LOGGED_PATH_PREFIX) and not response.has_header('Cache-Control'): response['Cache-Control'] = 'private, no-store' return response class DatabaseBusyMiddleware: """ poolِ دیتابیسِ پر → ۵۰۳ با Retry-After به‌جای ۵۰۰ (config/db.py) برای viewهای غیرِ DRF مثلِ /admin/؛ viewهای DRF را هندلرِ خطای DRF (config/exceptions.py) جواب می‌دهد، چون هر خطا را خودش ۵۰۰ می‌کند و استثنا هیچ‌وقت به این‌جا نمی‌رسد. فقط خطای pool را می‌گیرد؛ هر OperationalErrorِ دیگری (Postgres پایین، کوئریِ timeout‌خورده) همان ۵۰۰ِ قبلی می‌ماند. """ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): return self.get_response(request) def process_exception(self, request, exception): from django.http import JsonResponse from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy if not is_pool_timeout(exception): return None log_server_busy(request) response = JsonResponse(SERVER_BUSY_BODY, status=503) response['Retry-After'] = str(SERVER_BUSY_RETRY_AFTER) return response class AdminLoginThrottleMiddleware: """ سقفِ تلاشِ ورود به /admin/ برای هر IP (ADMIN_LOGIN_MAX_ATTEMPTS در ۱۵ دقیقه). پنلِ ادمین روی دامنه‌ی عمومی است و فرمِ ورودِ جنگو هیچ محدودیتی ندارد؛ یعنی رمزِ ادمین (که همه‌ی محتوا و پرداخت‌ها را کنترل می‌کند) بی‌سقف قابلِ حدس زدن بود. شمارش در کشِ پیش‌فرض است: تا CACHE_URL (Redis) تنظیم نشده، برای هر worker جدا — یعنی سقفِ واقعی تا سه برابر. IP با همان منطقِ NUM_PROXIESِ throttleهای DRF خوانده می‌شود. """ WINDOW_SECONDS = 15 * 60 def __init__(self, get_response): self.get_response = get_response def __call__(self, request): if request.method == 'POST' and request.path.startswith('/admin/login'): from django.conf import settings from django.core.cache import cache from django.http import HttpResponse from rest_framework.throttling import BaseThrottle key = f'admin_login:{BaseThrottle().get_ident(request)}' cache.add(key, 0, self.WINDOW_SECONDS) try: attempts = cache.incr(key) except ValueError: cache.set(key, 1, self.WINDOW_SECONDS) attempts = 1 if attempts > getattr(settings, 'ADMIN_LOGIN_MAX_ATTEMPTS', 10): logger.warning('admin login throttled: ident=%s attempts=%s', key, attempts) return HttpResponse( 'تلاشِ ورود بیش از حد مجاز است؛ ۱۵ دقیقه‌ی دیگر دوباره امتحان کنید.', status=429, content_type='text/plain; charset=utf-8', ) return self.get_response(request)