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
+27 -4
View File
@@ -31,7 +31,9 @@ const defaults = () => ({
},
progress: {}, history: [], keys: { ru: {}, en: {} },
// Раскладка Vial: { file, layout:{layers,rows,cols}, names:[], enabled, split, numLayer:'auto'|n, labelLang }
vial: null
vial: null,
// Аккаунт, под которым последний раз синхронизировали локальный прогресс (чтобы данные не переехали к другому человеку)
accountOwner: null
});
function loadState() {
const d = defaults();
@@ -43,6 +45,7 @@ function loadState() {
d.progress = o.progress || {}; d.history = o.history || [];
d.keys = Object.assign({ ru: {}, en: {} }, o.keys || {});
d.vial = o.vial && o.vial.layout ? o.vial : null;
d.accountOwner = typeof o.accountOwner === 'string' ? o.accountOwner : null;
}
} catch (e) { /* localStorage может быть недоступен - работаем без сохранения */ }
return d;
@@ -644,7 +647,8 @@ function show(v) {
}
function go(v) {
show(v);
({ lessons: renderLessons, practice: renderPractice, keyboard: renderKeyboardPage, stats: renderStats, settings: renderSettings })[v]();
({ lessons: renderLessons, practice: renderPractice, keyboard: renderKeyboardPage, stats: renderStats, settings: renderSettings,
account: () => window.Auth.render() })[v]();
}
const seg = (opts, value, onchange) =>
@@ -1091,6 +1095,7 @@ function finish(now) {
S.history.push({ t: Date.now(), id: run.lesson ? run.lesson.id : null, k: run.kind, lang: run.lang, cpm: +cpm.toFixed(1), acc: +acc.toFixed(1), err: run.errors, len: n, sec: +(ms / 1000).toFixed(1) });
if (S.history.length > 600) S.history.splice(0, S.history.length - 600);
save();
if (window.Auth) window.Auth.afterRun(); // если выполнен вход, прогресс уйдёт на сервер
// Что делать по Enter
const lessons = run.lesson ? LESSONS[run.lang] : null;
@@ -1285,8 +1290,15 @@ function startWeak(lang) {
startRun({ kind: 'weak', lang, title: 'Слабые клавиши', text: weakText(lang, weak), goal: 0 });
}
function resetData() {
if (!confirm('Удалить весь прогресс, историю и статистику? Это нельзя отменить.')) return;
async function resetData() {
const account = !!(window.Auth && window.Auth.isLoggedIn());
const question = account
? 'Удалить прогресс, историю и статистику на этом устройстве и в вашем аккаунте на сервере? Это нельзя отменить. '
+ 'На других устройствах, где вы входили, прогресс может вернуться при синхронизации: очистите его и там.'
: 'Удалить весь прогресс, историю и статистику? Это нельзя отменить.';
if (!confirm(question)) return;
// Без очистки на сервере прогресс вернулся бы при ближайшей синхронизации
if (account && !(await window.Auth.clearServerProgress())) { toast('Не удалось очистить данные на сервере: попробуйте позже'); return; }
const settings = S.settings, d = defaults();
S.progress = d.progress; S.history = d.history; S.keys = d.keys; S.settings = settings;
save(); renderStats(); toast('Данные сброшены');
@@ -1421,6 +1433,17 @@ window.addEventListener('keydown', handleKey);
applyTheme();
go('lessons');
// Аккаунт появляется только там, где рядом есть API (на сервере); локально сайт работает как раньше
if (window.Auth) {
window.Auth.init({
S, save, h, toast, go,
addNav: label => { NAV.push(['account', label]); renderNav(view === 'train' || view === 'result' ? 'lessons' : view); },
setNavLabel: label => { const item = NAV.find(n => n[0] === 'account'); if (item) { item[1] = label; renderNav(view); } },
isView: name => view === name,
refresh: () => { if (view === 'lessons') renderLessons(); else if (view === 'stats') renderStats(); },
});
}
// Для отладочных проверок из консоли
window.__trainer = { LESSONS, LAYOUT, makeText, practiceText, weakChars, S, charOf, vialModel };
})();