Files
MathApp/progress/views.py
Zareian a973c41db2 Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and
  curriculum import need add/delete; content browser needs view_question; guest device ids are
  shown to superusers only.
- Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps
  Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted
  (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles.
- Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409;
  orders snapshot credits/hearts/days at creation (payment.0011).
- Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker
  refuses the answer (not stored, not cached); a disconnect saves the partial answer; only
  KNOWN_COLLECTIONS are searched.
- Infra: secret-key file is created atomically (workers no longer split keys); no default
  INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc
  pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400;
  ticket text capped at 5000.
- docs/security-before-production.md: remaining open items 24-31 and the guard tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:52:25 +03:30

272 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .models import Character, StreakRecord, UserHearts, UserStats
from .services import effective_current_streak
class GuestAwareReadThrottle(ScopedRateThrottle):
"""همون الگوی GuestAwareContentThrottle: کاربرِ مهمان (هر deviceId یک pk
تازه) با IP کلید می‌خوره تا چرخشِ حساب سقفِ read رو دور نزنه."""
def get_cache_key(self, request, view):
user = request.user
if user and user.is_authenticated and getattr(user, 'is_guest', False):
ident = f'guest-ip:{self.get_ident(request)}'
elif user and user.is_authenticated:
ident = user.pk
else:
ident = self.get_ident(request)
return self.cache_format % {'scope': self.scope, 'ident': ident}
from .serializers import (
CharacterListResponseSerializer,
LeaderboardResponseSerializer,
SelectCharacterRequestSerializer,
SelectCharacterResponseSerializer,
StreakCalendarResponseSerializer,
UserHeartsResponseSerializer,
UserSummaryResponseSerializer,
WeeklyReportResponseSerializer,
)
def _active_plan_title(user_id):
from django.db.models import Q
from django.utils import timezone
from subscription.models import Subscription
sub = (
Subscription.objects.select_related('plan')
.filter(user_id=user_id, status='active')
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()))
.first()
)
return sub.plan.title if sub and sub.plan else None
class UserSummaryView(APIView):
"""GET /api/user/summary — پروفایل + آمار + استریک (ادغام‌شده)"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle]
@extend_schema(responses=UserSummaryResponseSerializer, tags=['Progress & Gamification (progress)'])
def get(self, request):
user = request.user # accounts.User، از طریق TadrisinoTokenAuthentication
stats = UserStats.objects.filter(user_id=user.id).first()
streak = StreakRecord.objects.filter(user_id=user.id).first()
data = {
'userId': str(user.id),
'name': user.name,
'avatar': user.avatar,
'gradeId': user.grade_id,
'xp': stats.xp if stats else 0,
'coins': stats.coins if stats else 0,
'streak': {
'current': effective_current_streak(streak),
'longest': streak.longest_streak if streak else 0,
},
# عنوانِ پلنِ اشتراکِ فعال، یا null. تا ۲۰۲۶-۰۹-۲۴ همیشه null بود
# (از زمانی که اپِ subscription هنوز وجود نداشت).
'subscription': _active_plan_title(user.id),
}
return Response(UserSummaryResponseSerializer(data).data)
class StreakCalendarView(APIView):
"""GET /api/user/streak/calendar — تقویم کامل روزهای فعال"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle]
@extend_schema(responses=StreakCalendarResponseSerializer, tags=['Progress & Gamification (progress)'])
def get(self, request):
user = request.user
streak = StreakRecord.objects.filter(user_id=user.id).first()
data = {
'currentStreak': effective_current_streak(streak),
'longestStreak': streak.longest_streak if streak else 0,
'activeDays': list(streak.active_days.values_list('date', flat=True)) if streak else [],
}
return Response(StreakCalendarResponseSerializer(data).data)
class WeeklyReportView(APIView):
"""
GET /api/user/weekly-report — گزارشِ ۷ روزِ اخیرِ خودِ کاربر در کنارِ ۷ روزِ قبلش.
فرانت آن را با عنوانِ «گزارشِ والدین» نشان می‌دهد؛ والد همین را در حسابِ خودِ
دانش‌آموز می‌بیند و حسابِ جدا ندارد. فقط دیتای خودِ درخواست‌دهنده را برمی‌گرداند.
محاسبه و این‌که `minutes` تخمینی است: `progress/report.py`.
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle]
@extend_schema(responses=WeeklyReportResponseSerializer, tags=['Progress & Gamification (progress)'])
def get(self, request):
from .report import weekly_report
return Response(WeeklyReportResponseSerializer(weekly_report(request.user.id)).data)
# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ هم‌زمان هر بار جدول را
# از نو حساب نکنند.
LEADERBOARD_CACHE_TTL = 60
class LeaderboardView(APIView):
"""
GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه
فقط برای کاربرِ واردشده (مهمان هم توکن دارد): پاسخ نامِ کاربران، که بیشترشان کودک‌اند،
و شناسه‌ی داخلی‌شان را دارد و بدونِ ورود برای هر کسی در اینترنت قابلِ جمع‌آوری بود.
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle]
@extend_schema(
parameters=[OpenApiParameter('period', OpenApiTypes.STR, required=False,
enum=['weekly', 'all_time'])],
responses=LeaderboardResponseSerializer,
tags=['Progress & Gamification (progress)'],
)
def get(self, request):
period = request.query_params.get('period', 'weekly')
from .leaderboard import PERIODS
if period not in PERIODS:
# پیش‌تر هر رشته‌ای ۲۰۰ و یک کلیدِ کشِ تازه می‌ساخت.
raise ValidationError({'period': f'یکی از {", ".join(PERIODS)}.'})
# زنده از فعالیتِ واقعی، با کشِ کوتاه (progress/leaderboard.py). قبلاً از
# جدولِ LeaderboardEntry خوانده می‌شد که فقط با اجرای دستیِ
# rebuild_leaderboard پر می‌شد، پس XPی کاربرِ تازه هیچ‌وقت دیده نمی‌شد.
from django.core.cache import cache
from .leaderboard import compute_scores
scores = cache.get_or_set(
f'leaderboard:{period}', lambda: compute_scores(period), LEADERBOARD_CACHE_TTL,
)
# نام کاربر از accounts.User خونده می‌شه (فقط برای نمایش، بدون FK واقعی).
from accounts.models import User as AccountUser
names = dict(
AccountUser.objects.filter(id__in=[uid for uid, _ in scores]).values_list('id', 'name')
)
data = {
'period': period,
'entries': [
{
'rank': rank,
'userId': str(uid),
'name': names.get(uid, ''),
'xpInPeriod': xp,
}
for rank, (uid, xp) in enumerate(scores, 1)
],
}
return Response(LeaderboardResponseSerializer(data).data)
class UserHeartsView(APIView):
"""GET /api/user/hearts — وضعیت فعلی جان‌ها (هارت)"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=UserHeartsResponseSerializer, tags=['Progress & Gamification (progress)'])
def get(self, request):
from subscription.services import has_unlimited_hearts
user = request.user
hearts, _ = UserHearts.objects.get_or_create(user_id=user.id)
if hearts.apply_regen():
hearts.save(update_fields=UserHearts.REGEN_FIELDS)
unlimited = has_unlimited_hearts(user.id)
data = {
'currentHearts': hearts.max_hearts if unlimited else hearts.current_hearts,
'maxHearts': hearts.max_hearts,
'nextRefillAt': None if unlimited else hearts.next_refill_at,
'fullRefillAt': None if unlimited else hearts.full_refill_at,
'isUnlimited': unlimited,
}
return Response(UserHeartsResponseSerializer(data).data)
class CharacterListView(APIView):
"""
GET /api/characters — کاتالوگِ کاراکترهای گیمیفیکیشن (مثلِ ماسکات‌های
Duolingo)، به‌همراه این‌که کدوم برای کاربرِ فعلی باز شده (unlock_xp <= XP
کاربر) و کدوم انتخاب‌شده‌ست.
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle]
@extend_schema(responses=CharacterListResponseSerializer, tags=['Progress & Gamification (progress)'])
def get(self, request):
stats = UserStats.objects.filter(user_id=request.user.id).first()
xp = stats.xp if stats else 0
selected_id = stats.selected_character_id if stats else None
data = {
'characters': [
{
'characterId': str(c.id),
'slug': c.slug,
'name': c.name,
'icon': c.icon,
'unlockXp': c.unlock_xp,
'unlocked': xp >= c.unlock_xp,
'selected': c.id == selected_id,
}
for c in Character.objects.all()
],
}
return Response(CharacterListResponseSerializer(data).data)
class SelectCharacterView(APIView):
"""POST /api/characters/select — انتخابِ کاراکترِ فعال؛ فقط کاراکترِ باز‌شده قابلِ انتخابه."""
permission_classes = [IsAuthenticated]
throttle_scope = 'user_profile_write'
throttle_classes = [ScopedRateThrottle]
@extend_schema(
request=SelectCharacterRequestSerializer,
responses=SelectCharacterResponseSerializer,
tags=['Progress & Gamification (progress)'],
)
def post(self, request):
serializer = SelectCharacterRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
character_id = serializer.validated_data['characterId']
if not (character_id.isascii() and character_id.isdigit()):
raise ValidationError({'characterId': 'شناسه‌ی کاراکتر باید عدد باشد.'})
character = get_object_or_404(Character, pk=int(character_id))
stats, _ = UserStats.objects.get_or_create(user_id=request.user.id)
if stats.xp < character.unlock_xp:
raise ValidationError('این کاراکتر هنوز باز نشده است.')
stats.selected_character_id = character.id
stats.save(update_fields=['selected_character_id'])
data = {'characterId': str(character.id), 'selected': True}
return Response(SelectCharacterResponseSerializer(data).data)