Files
split-trainer/server/splitapi/statevalid.py
T
darko fcafe1ae15 feat: аккаунты по почте с подтверждением, сбросом пароля и синхронизацией прогресса
API аккаунтов на чистой стандартной библиотеке Python (server/splitapi):
- регистрация по почте: пароль задаётся по ссылке из письма, аккаунт создаётся только после подтверждения
- вход, выход, сброс пароля по почте (токены одноразовые, в базе только SHA-256), удаление аккаунта
- пароли scrypt, лимиты попыток по паре «адрес + IP», IP и аккаунту, защита от CSRF
- хранение прогресса пользователя с оптимистичной блокировкой версии
- SMTP через переменные окружения; без SMTP на публичном адресе API не стартует

Клиент: public/auth.js (вход, регистрация, сброс, синхронизация), public/sync.js
(чистое слияние прогресса). Без API сайт работает как раньше.

Развёртывание: Dockerfile.api, сервис splitapi в docker-compose, проксирование /api/
в nginx с ограничением частоты, deploy/install-api-debian.sh (systemd), .env.example.

Тесты: 216 для бэкенда (unittest, покрытие около 97%), 16 для клиентского слияния.
Документация: раздел «Аккаунты и почта» в docs/DEPLOY.md, раздел 9 в docs/ИНСТРУКЦИЯ.md.
2026-10-16 11:25:09 +03:00

110 lines
4.0 KiB
Python

"""Проверка и очистка состояния прогресса, которое клиент хранит на сервере.
Сервер не доверяет присланному JSON: оставляем только известные поля, приводим числа к допустимым
диапазонам и ограничиваем размеры. Так испорченные данные не попадут ни на другие устройства, ни в интерфейс.
"""
from __future__ import annotations
import math
import re
MAX_HISTORY = 600
MAX_PROGRESS = 200
MAX_KEYS = 200
_ID_MAX = 40
_ID_RE = re.compile(r"[^A-Za-z0-9_.-]")
_LANGS = ("ru", "en")
_KINDS = ("lesson", "practice", "weak")
class StateError(ValueError):
"""Состояние имеет неверную структуру."""
def _num(value: object, low: float, high: float) -> int | float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return 0
if isinstance(value, float) and not math.isfinite(value):
return 0
clamped = min(max(value, low), high)
return clamped
def _ident(value: object) -> str:
return _ID_RE.sub("", value)[:_ID_MAX] if isinstance(value, str) else ""
def _is_key_char(char: object) -> bool:
# одиночные суррогаты не кодируются в UTF-8 и ломали бы сохранение
return isinstance(char, str) and len(char) == 1 and not 0xD800 <= ord(char) <= 0xDFFF
def _choice(value: object, allowed: tuple[str, ...]) -> str:
return value if isinstance(value, str) and value in allowed else ""
def _history_row(row: dict) -> dict:
return {
"t": _num(row.get("t"), 0, 4_000_000_000_000),
"id": _ident(row.get("id")),
"k": _choice(row.get("k"), _KINDS),
"lang": _choice(row.get("lang"), _LANGS),
"cpm": _num(row.get("cpm"), 0, 3000),
"acc": _num(row.get("acc"), 0, 100),
"err": _num(row.get("err"), 0, 100_000),
"len": _num(row.get("len"), 0, 100_000),
"sec": _num(row.get("sec"), 0, 1_000_000),
}
def _progress_row(row: dict) -> dict:
return {
"best": _num(row.get("best"), 0, 3000),
"acc": _num(row.get("acc"), 0, 100),
"stars": int(_num(row.get("stars"), 0, 3)),
"tries": int(_num(row.get("tries"), 0, 100_000)),
}
def _key_row(row: dict) -> dict:
return {name: _num(row.get(name), 0, 1_000_000_000) for name in ("n", "err", "ms", "ln")}
def _section(data: dict, name: str, kind: type) -> object:
value = data.get(name)
if value is None:
return kind()
if not isinstance(value, kind):
raise StateError(f"поле {name} имеет неверный тип")
return value
def clean_state(data: object) -> dict:
"""Возвращает очищенную копию состояния или поднимает StateError."""
if not isinstance(data, dict):
raise StateError("ожидался объект")
progress_in = _section(data, "progress", dict)
history_in = _section(data, "history", list)
keys_in = _section(data, "keys", dict)
progress = {}
for key, row in sorted(progress_in.items()): # порядок как у клиента: обрезка одинакова на обеих сторонах
ident = _ident(key)
if ident and isinstance(row, dict) and len(progress) < MAX_PROGRESS:
progress[ident] = _progress_row(row)
history = [_history_row(row) for row in history_in if isinstance(row, dict)][-MAX_HISTORY:]
keys = {}
for lang in _LANGS:
per_lang = keys_in.get(lang, {})
if not isinstance(per_lang, dict):
raise StateError("поле keys имеет неверный тип")
cleaned = {}
for char, row in per_lang.items():
if _is_key_char(char) and isinstance(row, dict) and len(cleaned) < MAX_KEYS:
cleaned[char] = _key_row(row)
keys[lang] = cleaned
return {"progress": progress, "history": history, "keys": keys}