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.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""Проверка и очистка состояния прогресса, которое клиент хранит на сервере.
|
||||
|
||||
Сервер не доверяет присланному 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}
|
||||
Reference in New Issue
Block a user