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:
darko
2026-10-16 11:25:09 +03:00
parent e897a6c5eb
commit fcafe1ae15
36 changed files with 4520 additions and 22 deletions
+94
View File
@@ -0,0 +1,94 @@
"""Тесты проверки состояния прогресса, которое клиент сохраняет на сервере."""
import json
import unittest
from splitapi.statevalid import StateError, clean_state
GOOD = {
"progress": {"ru-A1": {"best": 120.5, "acc": 97.2, "stars": 2, "tries": 3}},
"history": [{"t": 1760000000000, "id": "ru-A1", "k": "lesson", "lang": "ru", "cpm": 120.5, "acc": 97.2, "err": 2, "len": 60, "sec": 30.1}],
"keys": {"ru": {"ф": {"n": 10, "err": 1, "ms": 2500, "ln": 9}}, "en": {}},
}
class CleanStateTests(unittest.TestCase):
def test_valid_state_roundtrips(self):
self.assertEqual(clean_state(GOOD), GOOD)
def test_empty_state_is_ok(self):
self.assertEqual(clean_state({}), {"progress": {}, "history": [], "keys": {"ru": {}, "en": {}}})
def test_unknown_top_level_fields_are_dropped(self):
out = clean_state({**GOOD, "settings": {"x": 1}, "evil": "<script>"})
self.assertNotIn("settings", out)
self.assertNotIn("evil", out)
def test_not_a_dict(self):
for bad in [None, [], "x", 5]:
with self.subTest(bad=bad), self.assertRaises(StateError):
clean_state(bad)
def test_wrong_section_types(self):
for bad in [{"progress": []}, {"history": {}}, {"keys": []}, {"keys": {"ru": []}}]:
with self.subTest(bad=bad), self.assertRaises(StateError):
clean_state(bad)
def test_numbers_are_coerced_and_clamped(self):
out = clean_state({"history": [{"t": 1, "cpm": "oops", "acc": 1e9, "err": -5, "len": 10, "sec": 1}]})
row = out["history"][0]
self.assertEqual(row["cpm"], 0)
self.assertEqual(row["acc"], 100)
self.assertEqual(row["err"], 0)
def test_strings_are_truncated_and_non_strings_dropped(self):
out = clean_state({"history": [{"t": 1, "id": "x" * 500, "lang": "<img onerror=1>", "k": 7}]})
row = out["history"][0]
self.assertLessEqual(len(row["id"]), 40)
self.assertEqual(row["lang"], "") # допустимы только ru и en
self.assertEqual(row["k"], "")
def test_history_is_capped(self):
rows = [{"t": i, "cpm": 1, "acc": 1, "err": 0, "len": 1, "sec": 1} for i in range(1000)]
out = clean_state({"history": rows})
self.assertEqual(len(out["history"]), 600)
self.assertEqual(out["history"][-1]["t"], 999) # хвост (самые свежие) сохраняется
def test_progress_keys_limited_and_stars_clamped(self):
prog = {f"id{i}": {"best": 1, "acc": 1, "stars": 99, "tries": 1} for i in range(500)}
out = clean_state({"progress": prog})
self.assertLessEqual(len(out["progress"]), 200)
self.assertTrue(all(v["stars"] <= 3 for v in out["progress"].values()))
def test_key_stats_limited(self):
keys = {"ru": {chr(0x400 + i): {"n": 1, "err": 0, "ms": 1, "ln": 1} for i in range(400)}, "en": {}}
out = clean_state({"keys": keys})
self.assertLessEqual(len(out["keys"]["ru"]), 200)
def test_lone_surrogate_key_is_dropped_so_serialization_cannot_fail(self):
out = clean_state({"keys": {"ru": {"\ud800": {"n": 1}, "ф": {"n": 1}}, "en": {}}})
self.assertEqual(list(out["keys"]["ru"]), ["ф"])
json.dumps(out, ensure_ascii=False).encode("utf-8") # не должно падать
def test_progress_is_truncated_in_sorted_order_like_the_client(self):
prog = {f"id{i:03d}": {"best": 1, "acc": 1, "stars": 1, "tries": 1} for i in reversed(range(300))}
out = clean_state({"progress": prog})
self.assertEqual(sorted(out["progress"])[0], "id000")
self.assertEqual(len(out["progress"]), 200)
self.assertEqual(sorted(out["progress"])[-1], "id199")
def test_multi_char_key_names_are_dropped(self):
out = clean_state({"keys": {"ru": {"ab": {"n": 1}, "ф": {"n": 1}}, "en": {}}})
self.assertEqual(list(out["keys"]["ru"]), ["ф"])
def test_output_is_json_serializable_and_bounded(self):
out = clean_state(GOOD)
self.assertLess(len(json.dumps(out)), 300_000)
def test_does_not_mutate_input(self):
original = json.loads(json.dumps(GOOD))
clean_state(GOOD)
self.assertEqual(GOOD, original)
if __name__ == "__main__":
unittest.main()