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:
+104
@@ -0,0 +1,104 @@
|
||||
/* Очистка и слияние прогресса для синхронизации между устройствами.
|
||||
Чистые функции без обращения к DOM: их можно проверять в Node (tests/sync.test.js).
|
||||
Слияние коммутативно и идемпотентно: порядок синхронизаций не важен, повтор ничего не портит.
|
||||
Ограничения совпадают с серверными (server/splitapi/statevalid.py): расхождение дало бы вечные «изменения» при синхронизации. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
const MAX_HISTORY = 600, MAX_PROGRESS = 200, MAX_KEYS = 200, ID_MAX = 40;
|
||||
const LANGS = ['ru', 'en'];
|
||||
const KINDS = ['lesson', 'practice', 'weak'];
|
||||
|
||||
const num = (v, lo, hi) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : 0);
|
||||
const ident = v => (typeof v === 'string' ? v.replace(/[^A-Za-z0-9_.-]/g, '').slice(0, ID_MAX) : '');
|
||||
const choice = (v, allowed) => (allowed.includes(v) ? v : '');
|
||||
const isObj = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
|
||||
// Одна клавиша = один символ; одиночный суррогат не кодируется в UTF-8, сервер такие ключи отбрасывает, и мы тоже
|
||||
const isKeyChar = ch => [...ch].length === 1 && !/^[\ud800-\udfff]$/.test(ch);
|
||||
|
||||
const emptyState = () => ({ progress: {}, history: [], keys: { ru: {}, en: {} } });
|
||||
|
||||
const cleanHistoryRow = r => ({
|
||||
t: num(r.t, 0, 4e12), id: ident(r.id), k: choice(r.k, KINDS), lang: choice(r.lang, LANGS),
|
||||
cpm: num(r.cpm, 0, 3000), acc: num(r.acc, 0, 100), err: num(r.err, 0, 1e5), len: num(r.len, 0, 1e5), sec: num(r.sec, 0, 1e6),
|
||||
});
|
||||
const cleanProgressRow = r => ({
|
||||
best: num(r.best, 0, 3000), acc: num(r.acc, 0, 100), stars: Math.trunc(num(r.stars, 0, 3)), tries: Math.trunc(num(r.tries, 0, 1e5)),
|
||||
});
|
||||
const cleanKeyRow = r => ({ n: num(r.n, 0, 1e9), err: num(r.err, 0, 1e9), ms: num(r.ms, 0, 1e9), ln: num(r.ln, 0, 1e9) });
|
||||
|
||||
/* Возвращает очищенную копию: известные поля, числа в диапазонах, ограниченные размеры. Мусор даёт пустое состояние. */
|
||||
function sanitize(raw) {
|
||||
const out = emptyState();
|
||||
if (!isObj(raw)) return out;
|
||||
|
||||
if (isObj(raw.progress)) {
|
||||
for (const id of Object.keys(raw.progress).sort(byCodepoint)) {
|
||||
const clean = ident(id);
|
||||
// __proto__ как ключ подменил бы прототип таблицы, а не создал запись
|
||||
if (clean && clean !== '__proto__' && isObj(raw.progress[id]) && Object.keys(out.progress).length < MAX_PROGRESS) {
|
||||
out.progress[clean] = cleanProgressRow(raw.progress[id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(raw.history)) {
|
||||
out.history = raw.history.filter(isObj).map(cleanHistoryRow).slice(-MAX_HISTORY);
|
||||
}
|
||||
if (isObj(raw.keys)) {
|
||||
for (const lang of LANGS) {
|
||||
const src = raw.keys[lang];
|
||||
if (!isObj(src)) continue;
|
||||
for (const ch of Object.keys(src).sort(byCodepoint)) {
|
||||
if (isKeyChar(ch) && isObj(src[ch]) && Object.keys(out.keys[lang]).length < MAX_KEYS) {
|
||||
out.keys[lang][ch] = cleanKeyRow(src[ch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const historyKey = r => `${r.t}|${r.id}|${r.lang}|${r.k}`;
|
||||
|
||||
/* Из двух записей статистики клавиши выбираем ту, где больше нажатий (сложение удваивало бы данные при повторной синхронизации). */
|
||||
function betterKey(a, b) {
|
||||
const ta = [a.n + a.err, a.ms, a.ln, a.err], tb = [b.n + b.err, b.ms, b.ln, b.err];
|
||||
for (let i = 0; i < ta.length; i++) if (ta[i] !== tb[i]) return ta[i] > tb[i] ? a : b;
|
||||
return a;
|
||||
}
|
||||
|
||||
function merge(first, second) {
|
||||
const a = sanitize(first), b = sanitize(second), out = emptyState();
|
||||
|
||||
for (const id of [...new Set([...Object.keys(a.progress), ...Object.keys(b.progress)])].sort(byCodepoint)) {
|
||||
const x = a.progress[id], y = b.progress[id];
|
||||
out.progress[id] = !x ? y : !y ? x : {
|
||||
best: Math.max(x.best, y.best), acc: Math.max(x.acc, y.acc), stars: Math.max(x.stars, y.stars), tries: Math.max(x.tries, y.tries),
|
||||
};
|
||||
}
|
||||
|
||||
const rows = new Map();
|
||||
for (const r of [...a.history, ...b.history]) rows.set(historyKey(r), r);
|
||||
out.history = [...rows.entries()]
|
||||
.sort((p, q) => p[1].t - q[1].t || byCodepoint(p[0], q[0]))
|
||||
.map(e => e[1])
|
||||
.slice(-MAX_HISTORY);
|
||||
|
||||
for (const lang of LANGS) {
|
||||
for (const ch of [...new Set([...Object.keys(a.keys[lang]), ...Object.keys(b.keys[lang])])].sort(byCodepoint)) {
|
||||
const x = a.keys[lang][ch], y = b.keys[lang][ch];
|
||||
out.keys[lang][ch] = !x ? y : !y ? x : betterKey(x, y);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Часть состояния приложения, которая хранится на сервере (настройки и раскладка Vial остаются на устройстве). */
|
||||
const pick = S => sanitize({ progress: S.progress, history: S.history, keys: S.keys });
|
||||
|
||||
const api = { emptyState, sanitize, merge, pick };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.TrainerSync = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
Reference in New Issue
Block a user