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
+129
View File
@@ -0,0 +1,129 @@
// Тесты слияния и очистки прогресса (запуск: node --test tests/)
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { sanitize, merge, pick, emptyState } = require('../public/sync.js');
const row = (t, over = {}) => ({ t, id: 'ru-A1', k: 'lesson', lang: 'ru', cpm: 100, acc: 95, err: 1, len: 60, sec: 30, ...over });
const state = (over = {}) => ({ progress: {}, history: [], keys: { ru: {}, en: {} }, ...over });
test('emptyState has the expected shape', () => {
assert.deepEqual(emptyState(), { progress: {}, history: [], keys: { ru: {}, en: {} } });
});
test('sanitize: garbage input gives an empty state', () => {
for (const bad of [null, undefined, 5, 'x', [], true]) assert.deepEqual(sanitize(bad), emptyState());
});
test('sanitize: drops unknown fields and coerces numbers', () => {
const out = sanitize({
evil: '<script>', progress: { 'ru-A1': { best: '120', acc: 1e9, stars: 99, tries: -3, extra: 1 } },
history: [row(1, { cpm: 'oops', lang: '<img>', id: 'a b<c>' })], keys: { ru: { ab: { n: 1 }, 'ф': { n: 5, err: 1, ms: 10, ln: 2 } }, de: {} },
});
assert.equal('evil' in out, false);
assert.deepEqual(out.progress['ru-A1'], { best: 0, acc: 100, stars: 3, tries: 0 });
assert.equal(out.history[0].cpm, 0);
assert.equal(out.history[0].lang, '');
assert.equal(out.history[0].id, 'abc');
assert.deepEqual(Object.keys(out.keys.ru), ['ф']);
assert.equal('de' in out.keys, false);
});
test('sanitize: caps history to the latest 600 rows', () => {
const rows = Array.from({ length: 700 }, (_, i) => row(i + 1));
const out = sanitize({ history: rows });
assert.equal(out.history.length, 600);
assert.equal(out.history.at(-1).t, 700);
});
test('sanitize: the __proto__ key cannot replace the prototype of the progress map', () => {
const evil = JSON.parse('{"progress":{"__proto__":{"best":5,"acc":5,"stars":3,"tries":5},"ru-A1":{"best":1,"acc":1,"stars":1,"tries":1}}}');
const out = sanitize(evil);
assert.equal(Object.getPrototypeOf(out.progress), Object.prototype);
assert.equal(out.progress.best, undefined);
assert.deepEqual(Object.keys(out.progress), ['ru-A1']);
});
test('sanitize: progress and key tables are capped at 200 entries like on the server', () => {
const progress = {};
for (let i = 0; i < 300; i++) progress[`id${String(i).padStart(3, '0')}`] = { best: 1, acc: 1, stars: 1, tries: 1 };
const keys = { ru: {} };
for (let i = 0; i < 300; i++) keys.ru[String.fromCharCode(0x400 + i)] = { n: 1, err: 0, ms: 1, ln: 1 };
const out = sanitize({ progress, keys });
assert.equal(Object.keys(out.progress).length, 200);
assert.equal(Object.keys(out.progress)[0], 'id000'); // обрезка по отсортированному порядку, как на сервере
assert.equal(Object.keys(out.progress).at(-1), 'id199');
assert.equal(Object.keys(out.keys.ru).length, 200);
});
test('sanitize: lone surrogates are dropped (the server drops them too), whole emoji are kept', () => {
const out = sanitize({ keys: { ru: { '\ud800': { n: 1 }, '😀': { n: 2, err: 0, ms: 0, ln: 0 }, 'ф': { n: 3, err: 0, ms: 0, ln: 0 } }, en: {} } });
assert.deepEqual(Object.keys(out.keys.ru).sort(), ['ф', '😀'].sort());
});
test('merge: equal-sized key records are resolved the same way in either order', () => {
const a = state({ keys: { ru: { 'ф': { n: 5, err: 0, ms: 10, ln: 4 } }, en: {} } });
const b = state({ keys: { ru: { 'ф': { n: 5, err: 0, ms: 20, ln: 4 } }, en: {} } });
assert.deepEqual(merge(a, b), merge(b, a));
assert.equal(merge(a, b).keys.ru['ф'].ms, 20);
});
test('merge: progress takes the best value of each field', () => {
const a = state({ progress: { x: { best: 100, acc: 90, stars: 1, tries: 2 } } });
const b = state({ progress: { x: { best: 120, acc: 85, stars: 2, tries: 1 }, y: { best: 50, acc: 99, stars: 3, tries: 1 } } });
const m = merge(a, b);
assert.deepEqual(m.progress.x, { best: 120, acc: 90, stars: 2, tries: 2 });
assert.deepEqual(m.progress.y, { best: 50, acc: 99, stars: 3, tries: 1 });
});
test('merge: history is a deduplicated union ordered by time', () => {
const a = state({ history: [row(3), row(1)] });
const b = state({ history: [row(2), row(3)] });
assert.deepEqual(merge(a, b).history.map(r => r.t), [1, 2, 3]);
});
test('merge: history keeps at most 600 latest rows', () => {
const a = state({ history: Array.from({ length: 400 }, (_, i) => row(i + 1)) });
const b = state({ history: Array.from({ length: 400 }, (_, i) => row(i + 301)) });
const m = merge(a, b);
assert.equal(m.history.length, 600);
assert.equal(m.history.at(-1).t, 700);
});
test('merge: key stats keep the record with more samples', () => {
const a = state({ keys: { ru: { 'ф': { n: 10, err: 1, ms: 100, ln: 9 } }, en: {} } });
const b = state({ keys: { ru: { 'ф': { n: 50, err: 5, ms: 500, ln: 40 }, 'ы': { n: 3, err: 0, ms: 9, ln: 2 } }, en: {} } });
const m = merge(a, b);
assert.equal(m.keys.ru['ф'].n, 50);
assert.equal(m.keys.ru['ы'].n, 3);
});
test('merge is commutative and idempotent', () => {
const a = state({ progress: { x: { best: 1, acc: 2, stars: 1, tries: 1 } }, history: [row(1), row(5)], keys: { ru: { 'а': { n: 4, err: 0, ms: 1, ln: 1 } }, en: {} } });
const b = state({ progress: { x: { best: 3, acc: 1, stars: 2, tries: 4 }, z: { best: 9, acc: 9, stars: 3, tries: 1 } }, history: [row(2), row(5)], keys: { ru: { 'а': { n: 6, err: 1, ms: 2, ln: 2 } }, en: { a: { n: 1, err: 0, ms: 1, ln: 1 } } } });
assert.deepEqual(merge(a, b), merge(b, a));
assert.deepEqual(merge(merge(a, b), b), merge(a, b));
assert.deepEqual(merge(merge(a, b), a), merge(a, b));
});
test('merge with an empty state returns the other state unchanged', () => {
const a = state({ progress: { x: { best: 1, acc: 2, stars: 1, tries: 1 } }, history: [row(1)] });
const expected = { progress: { x: { best: 1, acc: 2, stars: 1, tries: 1 } }, history: [row(1)], keys: { ru: {}, en: {} } };
assert.deepEqual(merge(a, emptyState()), expected);
assert.deepEqual(merge(emptyState(), a), expected);
assert.deepEqual(merge(emptyState(), emptyState()), emptyState());
});
test('merge does not mutate its inputs', () => {
const a = state({ progress: { x: { best: 1, acc: 2, stars: 1, tries: 1 } }, history: [row(2), row(1)] });
const b = state({ history: [row(3)] });
const copyA = JSON.parse(JSON.stringify(a));
merge(a, b);
assert.deepEqual(a, copyA);
});
test('pick extracts only the synced parts of the app state', () => {
const S = { settings: { theme: 'dark' }, vial: { secret: 1 }, progress: { x: { best: 1, acc: 1, stars: 1, tries: 1 } }, history: [row(1)], keys: { ru: {}, en: {} } };
const p = pick(S);
assert.deepEqual(Object.keys(p).sort(), ['history', 'keys', 'progress']);
});