tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed

Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 06:24:13 +03:30
parent fbdb9495ee
commit 42783f9ba4
56 changed files with 2792 additions and 326 deletions

View File

@@ -108,14 +108,33 @@ def _traffic() -> dict:
def _check_integrations() -> dict:
"""فقط «تنظیم شده یا نه» — هیچ کلیدی برنمی‌گردد."""
"""
فقط «تنظیم شده یا نه» — هیچ کلیدی برنمی‌گردد. `aiProvider` وضعیتِ واقعیِ
آخرین تماس است: کلیدِ تنظیم‌شده با اعتبارِ تمام‌شده هم `openai: true` بود.
"""
return {
'qdrant': bool(getattr(settings, 'QDRANT_API_KEY', '')),
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
'aiProvider': _ai_provider_state(),
'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''),
'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None,
}
def _ai_provider_state() -> dict:
try:
from statuslog.models import StatusChangeLog
row = StatusChangeLog.objects.filter(label='ai_tutor.Provider').values(
'new_status', 'created_at').first()
except Exception as exc: # noqa: BLE001
return {'error': type(exc).__name__}
if row is None:
return {'state': 'ok', 'since': None}
return {'state': row['new_status'], 'since': row['created_at'].isoformat()}
def _warnings() -> list[str]:
"""
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند.
@@ -136,10 +155,17 @@ def _warnings() -> list[str]:
if test_phones:
# نه خودِ شماره و نه کد برنمی‌گردد؛ فقط تعداد، تا کسی که تنظیماتِ سرور
# را ندیده هم بداند چند حسابِ تست با کدِ ثابت باز است و بتواند ببنددشان.
# تعداد عمداً گفته نمی‌شود: این صفحه عمومی است و «۷ حسابِ کدِ ثابت» برای
# مهاجم یعنی هدفِ مشخص برای حدس زدن.
out.append(
f'{len(test_phones)} شماره‌ی تستِ ورود با کدِ ثابت تعریف شده (TEST_LOGIN_PHONES). '
'شماره‌های تستِ ورود با کدِ ثابت فعال‌اند (TEST_LOGIN_PHONES). '
'برای توسعه لازم است؛ پیش از تحویلِ نهایی به کاربرانِ واقعی حذفشان کنید.'
)
if _ai_provider_state().get('state') == 'quota_exhausted':
out.append(
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها '
'«در دسترس نیست» می‌دهد تا حساب شارژ شود.'
)
backend = settings.CACHES['default']['BACKEND']
if not settings.DEBUG and 'LocMem' in backend:
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')

View File

@@ -110,3 +110,42 @@ class APINoStoreMiddleware:
if request.path.startswith(_LOGGED_PATH_PREFIX) and not response.has_header('Cache-Control'):
response['Cache-Control'] = 'private, no-store'
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)

View File

@@ -219,6 +219,8 @@ MIDDLEWARE = [
# ۴۰۰) هدرِ CORS نمی‌گیرند و در مرورگر به‌شکلِ خطای مبهمِ شبکه دیده می‌شوند.
'corsheaders.middleware.CorsMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
# سقفِ حدسِ رمزِ /admin/ برای هر IP (config/middleware.py).
'config.middleware.AdminLoginThrottleMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -269,11 +271,25 @@ DATABASES = {
# اینکه فوراً OperationalError('database table is locked') بده.
'OPTIONS': {
'timeout': 20,
# هر atomic() قفلِ نوشتن را از اول می‌گیرد (BEGIN IMMEDIATE).
# SQLite از SELECT ... FOR UPDATE پشتیبانی نمی‌کند و جنگو
# select_for_update را رویش بی‌صدا نادیده می‌گیرد؛ پس محافظ‌های
# «قفلِ ردیف» در پرداخت، تخفیف، قلب و سهمیه‌ی دستیار بدونِ این
# عملاً قفلی نداشتند. با IMMEDIATE کلِ تراکنش سریالی می‌شود (قفلِ
# کلِ دیتابیس، نه ردیف) — روی SQLite درشت‌تر ولی درست. هزینه:
# تراکنش‌ها پشتِ هم صف می‌کشند؛ فراخوانی‌های شبکه‌ای (AI، زرین‌پال)
# عمداً بیرون از atomic() هستند. بعد از Postgres این را بردارید.
'transaction_mode': 'IMMEDIATE',
},
# کانکشنِ DB به‌ازای هر request باز/بسته نشه؛ تا این تعداد ثانیه
# persistent بمونه. با SQLite بی‌اثرِ عملی ولی بی‌ضرره؛ بعد از
# مهاجرت به Postgres جلوی هزینه‌ی TCP+auth در هر request رو می‌گیره.
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
# تست‌ها روی فایل، مثلِ پروداکشن. دیتابیسِ پیش‌فرضِ تست در حافظه با
# shared-cache است که قفلش جدول‌به‌جدول و بی‌انتظار است («table is
# locked») — رفتاری که روی فایلِ واقعی پیش نمی‌آید؛ تستِ هم‌زمانیِ
# campaigns با BEGIN IMMEDIATE فقط همین‌جا می‌افتاد.
'TEST': {'NAME': str(BASE_DIR / '.test-db.sqlite3')},
}
}
@@ -584,6 +600,9 @@ OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
# (که جلویِ burst رو نمی‌گیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
# هر کدِ ورود بعد از این تعداد حدسِ غلط باطل می‌شود (شمارش در دیتابیس).
# با کدِ ۶رقمی یعنی شانسِ حدس زدنِ هر کد ۵ در یک میلیون.
OTP_MAX_FAILED_ATTEMPTS = 5
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
@@ -640,6 +659,15 @@ OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به
# می‌شه که نباید فوراً به‌عنوان «رهاکردن درس» حساب بشه.
NODE_PROGRESS_ABANDON_DAYS = 14
# قلبِ صفر جلوی جواب دادن را در سرور بگیرد؟ (content.views.QuestionAnswerView)
# خاموش تا وقتی فرانت برای ۴۰۳ِ `no_hearts` صفحه‌ی «قلب‌ها تمام شد» و زمانِ
# nextRefillAt را نشان دهد؛ روشن کردنش پیش از آن، دانش‌آموزِ بی‌قلب را به
# صفحه‌ی خطای عمومی می‌برد. تا آن موقع «قلبِ نامحدودِ» اشتراک فقط در اپ است.
HEARTS_ENFORCED = os.environ.get('HEARTS_ENFORCED', '0') == '1'
# تلاشِ ورودِ ادمین در ۱۵ دقیقه برای هر IP.
ADMIN_LOGIN_MAX_ATTEMPTS = 10
# -----------------------------------------------------------------------
# Payment security
# -----------------------------------------------------------------------
@@ -651,13 +679,6 @@ INTERNAL_SERVICE_KEY = os.environ.get(
'dev-internal-service-key-change-me' if DEBUG else '',
)
# POST /api/payment/webhook باید از درگاه پرداخت بیاید، نه یک کلاینت دلخواه.
# فعلاً یک shared-secret ساده (X-Webhook-Secret) استفاده می‌شود؛ در اتصال
# واقعی باید با بررسی امضای HMAC درگاه جایگزین شود.
PAYMENT_WEBHOOK_SECRET = os.environ.get(
'PAYMENT_WEBHOOK_SECRET',
'dev-webhook-secret-change-me' if DEBUG else '',
)
# -----------------------------------------------------------------------
# Bazaar In-App Billing (پرداخت درون‌برنامه‌ای کافه‌بازار — REST API v2)
@@ -724,7 +745,14 @@ if ZARINPAL_REDIRECT_URL == '-':
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1')
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4o-mini')
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4o')
# اختیاری: مدلِ جدا فقط برای پایه‌های دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
# همه‌ی پایه‌ها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده می‌شود.
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')
# فقط برای مدل‌های reasoning (o-series/gpt-5): low | medium | high؛ خالی = پیش‌فرضِ مدل.
OPENAI_CHAT_REASONING_EFFORT = os.environ.get('OPENAI_CHAT_REASONING_EFFORT', 'low')
# gpt-4.1-mini در سنجشِ ۲۰۲۶-۰۹-۲۰ هم ارزان‌تر و هم دقیق‌تر از gpt-4o بود
# (docs/extraction-playbook.md)؛ پیش‌فرض همان است تا به .env وابسته نباشد.
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4.1-mini')
# نباید تغییر کنه مگر اینکه collectionهای Qdrant هم با همون مدلِ جدید بازساخته بشن.
OPENAI_EMBEDDING_MODEL = os.environ.get('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small')
OPENAI_EMBEDDING_DIM = int(os.environ.get('OPENAI_EMBEDDING_DIM', '1536'))