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:
+27
-4
@@ -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 };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user