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 };
|
||||
})();
|
||||
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
/* Аккаунт: регистрация с подтверждением почты, вход, сброс пароля, удаление аккаунта и синхронизация прогресса.
|
||||
Работает только если рядом есть API (/api/health). Без него сайт остаётся обычным локальным приложением. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const NO_CONNECTION = 'Нет связи с сервером. Проверьте подключение и повторите.';
|
||||
const UNEXPECTED = 'Что-то пошло не так. Обновите страницу и повторите.';
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
const SYNC_DELAY_MS = 2000;
|
||||
const RETRY_FIRST_MS = 15000;
|
||||
const RETRY_MAX_MS = 5 * 60 * 1000;
|
||||
const MAX_SYNC_ATTEMPTS = 3;
|
||||
const TOKEN_LINK = /^#(verify|reset)=([A-Za-z0-9_-]{16,100})$/;
|
||||
|
||||
let ctx = null; // зависимости, которые передаёт app.js
|
||||
const state = {
|
||||
available: false, user: null,
|
||||
mode: 'login', // login | register | forgot | reset | setpass | info
|
||||
message: null, // { type: 'ok' | 'err', text }
|
||||
email: '', busy: false, pending: null, confirmDelete: false,
|
||||
syncing: false, syncText: '',
|
||||
};
|
||||
let syncTimer = null, retryTimer = null, retryDelay = 0, dirty = false;
|
||||
|
||||
// ---------- Запросы к API ----------
|
||||
async function call(method, path, body) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const opts = { method, cache: 'no-store', credentials: 'same-origin', signal: controller.signal, headers: { 'X-Requested-With': 'split-trainer' } };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
try {
|
||||
const res = await fetch('api/' + path, opts);
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (e) { /* не JSON: например, страница ошибки от прокси */ }
|
||||
return { ok: res.ok, status: res.status, data: data || {} };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const errText = r => r.data.message || `Не удалось выполнить запрос (код ${r.status}).`;
|
||||
const isNetworkError = e => e instanceof TypeError || (e && e.name === 'AbortError');
|
||||
|
||||
// ---------- Ссылки из писем ----------
|
||||
function takeTokenFromLink() {
|
||||
const m = TOKEN_LINK.exec(location.hash);
|
||||
if (!m) return null;
|
||||
// Токен сразу убираем из адресной строки и истории браузера
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
return { kind: m[1], token: m[2] };
|
||||
}
|
||||
|
||||
function handleLink() {
|
||||
const found = takeTokenFromLink();
|
||||
if (!found) return;
|
||||
if (state.user) { // ссылка нужна гостю: при открытом входе она ничего бы не сделала
|
||||
ctx.toast('Вы уже вошли в аккаунт. Чтобы воспользоваться ссылкой из письма, сначала выйдите.');
|
||||
return;
|
||||
}
|
||||
state.pending = found;
|
||||
state.mode = found.kind === 'reset' ? 'reset' : 'setpass';
|
||||
state.message = null;
|
||||
ctx.go('account');
|
||||
}
|
||||
|
||||
// ---------- Данные устройства и аккаунта ----------
|
||||
const T = () => window.TrainerSync;
|
||||
|
||||
/* Локальный прогресс принадлежит тому аккаунту, под которым его синхронизировали. Если на этом браузере входит другой
|
||||
человек, чужие данные не должны уйти в его аккаунт. Данные гостя (владельца нет) объединяются с аккаунтом. */
|
||||
function adoptAccount(email) {
|
||||
if (ctx.S.accountOwner && ctx.S.accountOwner !== email) {
|
||||
ctx.S.progress = {};
|
||||
ctx.S.history = [];
|
||||
ctx.S.keys = { ru: {}, en: {} };
|
||||
}
|
||||
ctx.S.accountOwner = email;
|
||||
ctx.save();
|
||||
}
|
||||
|
||||
function applyLocal(merged) {
|
||||
if (JSON.stringify(merged) === JSON.stringify(T().pick(ctx.S))) return;
|
||||
ctx.S.progress = merged.progress;
|
||||
ctx.S.history = merged.history;
|
||||
ctx.S.keys = merged.keys;
|
||||
ctx.save();
|
||||
ctx.refresh();
|
||||
}
|
||||
|
||||
// ---------- Синхронизация ----------
|
||||
function setSync(text) {
|
||||
state.syncText = text;
|
||||
if (ctx.isView('account')) render();
|
||||
}
|
||||
|
||||
function sessionLost() {
|
||||
state.user = null;
|
||||
state.mode = 'login';
|
||||
state.message = { type: 'err', text: 'Сессия истекла: войдите снова.' };
|
||||
updateNav();
|
||||
setSync('');
|
||||
}
|
||||
|
||||
/* Cookie общая для всех вкладок: если в другой вкладке вошли в другой аккаунт, эта вкладка со своими данными в памяти
|
||||
не должна ничего записывать на сервер. Перезагрузка подхватит актуальное состояние. */
|
||||
function accountSwitched() {
|
||||
ctx.toast('В другой вкладке выполнен вход в другой аккаунт: обновляю страницу');
|
||||
setTimeout(() => location.reload(), 1200);
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
clearTimeout(retryTimer);
|
||||
retryDelay = Math.min(retryDelay ? retryDelay * 2 : RETRY_FIRST_MS, RETRY_MAX_MS);
|
||||
retryTimer = setTimeout(syncNow, retryDelay);
|
||||
}
|
||||
|
||||
async function syncNow() {
|
||||
if (!state.user) return;
|
||||
if (state.syncing) { dirty = true; return; } // изменения во время синхронизации: повторим сразу после неё
|
||||
state.syncing = true;
|
||||
dirty = false;
|
||||
setSync('Синхронизация…');
|
||||
try {
|
||||
for (let attempt = 0; attempt < MAX_SYNC_ATTEMPTS; attempt++) {
|
||||
const got = await call('GET', 'state');
|
||||
if (got.status === 401) return sessionLost();
|
||||
if (!got.ok) throw new Error('state');
|
||||
if (got.data.email && got.data.email !== state.user) return accountSwitched(); // в другой вкладке вошли иначе
|
||||
const remote = T().sanitize(got.data.data);
|
||||
const merged = T().merge(T().pick(ctx.S), remote);
|
||||
applyLocal(merged);
|
||||
const upToDate = JSON.stringify(merged) === JSON.stringify(remote);
|
||||
const put = upToDate ? { ok: true } : await call('PUT', 'state', { version: got.data.version, data: merged, email: state.user });
|
||||
if (put.ok) {
|
||||
retryDelay = 0;
|
||||
return setSync('Синхронизировано в ' + new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }));
|
||||
}
|
||||
if (put.status === 401) return sessionLost();
|
||||
if (put.data.error === 'account_mismatch') return accountSwitched();
|
||||
if (put.status !== 409) throw new Error('put'); // 409: параллельное изменение, повторяем со свежими данными
|
||||
}
|
||||
throw new Error('conflict');
|
||||
} catch (e) {
|
||||
setSync('Не удалось синхронизировать. Прогресс сохранён на этом устройстве, повторим автоматически.');
|
||||
scheduleRetry();
|
||||
} finally {
|
||||
state.syncing = false;
|
||||
if (dirty) syncNow();
|
||||
}
|
||||
}
|
||||
|
||||
function afterRun() {
|
||||
if (!state.user) return;
|
||||
dirty = true;
|
||||
clearTimeout(syncTimer);
|
||||
syncTimer = setTimeout(syncNow, SYNC_DELAY_MS);
|
||||
}
|
||||
|
||||
/* Очистка прогресса на сервере (для «Сбросить все данные» при входе в аккаунт). Возвращает true при успехе. */
|
||||
async function clearServerProgress() {
|
||||
if (!state.user) return true;
|
||||
clearTimeout(syncTimer); // отложенная синхронизация вернула бы очищаемый прогресс обратно
|
||||
dirty = false;
|
||||
try {
|
||||
for (let attempt = 0; attempt < MAX_SYNC_ATTEMPTS; attempt++) {
|
||||
const got = await call('GET', 'state');
|
||||
if (!got.ok || (got.data.email && got.data.email !== state.user)) return false;
|
||||
const put = await call('PUT', 'state', { version: got.data.version, data: {}, email: state.user });
|
||||
if (put.ok) return true;
|
||||
if (put.status !== 409) return false;
|
||||
}
|
||||
} catch (e) { /* нет связи: сообщаем вызывающему */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- Действия ----------
|
||||
const updateNav = () => ctx.setNavLabel(state.user ? 'Аккаунт' : 'Войти');
|
||||
|
||||
async function run(action) {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
state.message = null;
|
||||
render();
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
if (!isNetworkError(e)) console.error(e);
|
||||
state.message = { type: 'err', text: isNetworkError(e) ? NO_CONNECTION : UNEXPECTED };
|
||||
}
|
||||
state.busy = false;
|
||||
render();
|
||||
}
|
||||
|
||||
const fail = r => { state.message = { type: 'err', text: errText(r) }; };
|
||||
const ok = text => { state.message = { type: 'ok', text }; };
|
||||
|
||||
const actions = {
|
||||
login: (email, password) => run(async () => {
|
||||
const r = await call('POST', 'login', { email, password });
|
||||
if (!r.ok) return fail(r);
|
||||
state.user = r.data.email;
|
||||
state.message = null;
|
||||
adoptAccount(state.user);
|
||||
updateNav();
|
||||
ctx.toast('Вы вошли в аккаунт');
|
||||
syncNow();
|
||||
}),
|
||||
register: email => run(async () => {
|
||||
const r = await call('POST', 'register', { email });
|
||||
if (!r.ok) return fail(r);
|
||||
state.mode = 'info';
|
||||
ok(r.data.message);
|
||||
}),
|
||||
forgot: email => run(async () => {
|
||||
const r = await call('POST', 'forgot', { email });
|
||||
if (!r.ok) return fail(r);
|
||||
state.mode = 'info';
|
||||
ok(r.data.message);
|
||||
}),
|
||||
reset: password => run(async () => {
|
||||
const r = await call('POST', 'reset', { token: state.pending.token, password });
|
||||
if (r.ok) {
|
||||
state.pending = null;
|
||||
state.mode = 'login';
|
||||
ok(r.data.message);
|
||||
} else {
|
||||
if (r.data.error === 'invalid_token') { state.pending = null; state.mode = 'forgot'; }
|
||||
fail(r);
|
||||
}
|
||||
}),
|
||||
setPassword: password => run(async () => {
|
||||
const r = await call('POST', 'verify', { token: state.pending.token, password });
|
||||
if (r.ok) {
|
||||
state.pending = null;
|
||||
state.mode = 'login';
|
||||
ok(r.data.message);
|
||||
} else {
|
||||
if (r.data.error === 'invalid_token') { state.pending = null; state.mode = 'register'; }
|
||||
fail(r);
|
||||
}
|
||||
}),
|
||||
logout: () => run(async () => {
|
||||
const r = await call('POST', 'logout', {});
|
||||
if (!r.ok) return fail(r); // иначе интерфейс сказал бы «вышли», а cookie осталась бы жива
|
||||
state.user = null;
|
||||
state.mode = 'login';
|
||||
ok('Вы вышли из аккаунта. Прогресс остался на этом устройстве.');
|
||||
updateNav();
|
||||
setSync('');
|
||||
}),
|
||||
deleteAccount: password => run(async () => {
|
||||
const r = await call('POST', 'delete-account', { password });
|
||||
if (!r.ok) return fail(r);
|
||||
state.user = null;
|
||||
state.confirmDelete = false;
|
||||
state.mode = 'login';
|
||||
ctx.S.accountOwner = null; // локальные данные снова ничьи (гостевые)
|
||||
ctx.save();
|
||||
ok(r.data.message + ' Прогресс на этом устройстве сохранён: удалить его можно в разделе «Статистика».');
|
||||
updateNav();
|
||||
setSync('');
|
||||
}),
|
||||
};
|
||||
|
||||
// ---------- Экран ----------
|
||||
const mode = m => { state.mode = m; state.message = null; render(); };
|
||||
const h = (...args) => ctx.h(...args);
|
||||
|
||||
function messageBox() {
|
||||
return state.message ? h('div', { class: 'msg ' + state.message.type, role: state.message.type === 'err' ? 'alert' : 'status' }, state.message.text) : null;
|
||||
}
|
||||
|
||||
function emailField(autocomplete) {
|
||||
return h('div', { class: 'field' }, h('label', { for: 'au-email' }, 'Почта'),
|
||||
h('input', { id: 'au-email', class: 'text', type: 'email', required: true, maxlength: 254, autocomplete, value: state.email,
|
||||
oninput: e => { state.email = e.target.value; } }));
|
||||
}
|
||||
|
||||
function passwordField(label, autocomplete, id) {
|
||||
return h('div', { class: 'field' }, h('label', { for: id }, label),
|
||||
h('input', { id, class: 'text', type: 'password', required: true, minlength: 8, maxlength: 128, autocomplete }));
|
||||
}
|
||||
|
||||
const linkButton = (text, onclick) => h('button', { type: 'button', class: 'linkbtn', onclick }, text);
|
||||
const submitButton = text => h('button', { type: 'submit', class: 'btn primary', disabled: state.busy }, state.busy ? 'Подождите…' : text);
|
||||
const form = (onsubmit, ...kids) => h('form', { class: 'form', onsubmit: e => { e.preventDefault(); onsubmit(e.target); } }, ...kids);
|
||||
const valueOf = (f, id) => f.querySelector('#' + id).value;
|
||||
|
||||
/* Форма «новый пароль» для сброса и для первого пароля после подтверждения почты */
|
||||
function newPasswordForm(title, hint, submitText, action) {
|
||||
return [h('h2', null, title), hint ? h('p', { class: 'muted small' }, hint) : null,
|
||||
form(f => {
|
||||
const password = valueOf(f, 'au-pass');
|
||||
if (password !== valueOf(f, 'au-pass2')) { state.message = { type: 'err', text: 'Пароли не совпадают.' }; return render(); }
|
||||
action(password);
|
||||
}, passwordField('Пароль (не короче 8 символов)', 'new-password', 'au-pass'), passwordField('Повторите пароль', 'new-password', 'au-pass2'),
|
||||
submitButton(submitText))].filter(Boolean);
|
||||
}
|
||||
|
||||
function guestPanel() {
|
||||
const box = h('div', { class: 'panel authbox' });
|
||||
if (state.mode === 'login' || state.mode === 'register') {
|
||||
box.append(h('div', { class: 'seg', role: 'group', 'aria-label': 'Вход или регистрация' },
|
||||
['login', 'register'].map(m => h('button', { type: 'button', class: state.mode === m ? 'on' : '', 'aria-pressed': String(state.mode === m), onclick: () => mode(m) },
|
||||
m === 'login' ? 'Вход' : 'Регистрация'))));
|
||||
}
|
||||
const message = messageBox();
|
||||
if (message) box.append(message); // append(null) напечатал бы слово «null»
|
||||
|
||||
if (state.mode === 'login') {
|
||||
box.append(form(f => actions.login(valueOf(f, 'au-email'), valueOf(f, 'au-pass')),
|
||||
emailField('username'), passwordField('Пароль', 'current-password', 'au-pass'),
|
||||
h('div', { class: 'row' }, submitButton('Войти'), linkButton('Забыли пароль?', () => mode('forgot')))));
|
||||
} else if (state.mode === 'register') {
|
||||
box.append(form(f => actions.register(valueOf(f, 'au-email')),
|
||||
emailField('username'),
|
||||
h('p', { class: 'muted small' }, 'Мы отправим письмо со ссылкой. По ней вы подтвердите адрес и задайте пароль: до этого аккаунта нет.'),
|
||||
submitButton('Получить ссылку')));
|
||||
} else if (state.mode === 'forgot') {
|
||||
box.append(h('h2', null, 'Сброс пароля'), form(f => actions.forgot(valueOf(f, 'au-email')),
|
||||
emailField('username'), h('p', { class: 'muted small' }, 'Пришлём ссылку для нового пароля, если такой адрес зарегистрирован.'),
|
||||
h('div', { class: 'row' }, submitButton('Отправить ссылку'), linkButton('Назад ко входу', () => mode('login')))));
|
||||
} else if (state.mode === 'reset') {
|
||||
box.append(...newPasswordForm('Новый пароль', null, 'Сохранить пароль', actions.reset));
|
||||
} else if (state.mode === 'setpass') {
|
||||
box.append(...newPasswordForm('Подтверждение почты', 'Адрес подтверждён. Придумайте пароль: после этого аккаунт будет создан.', 'Создать аккаунт', actions.setPassword));
|
||||
} else {
|
||||
box.append(h('div', { class: 'row' }, linkButton('← Ко входу', () => mode('login'))));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function deleteForm() {
|
||||
return h('div', { class: 'panel authbox danger-zone' }, messageBox(), h('h2', null, 'Удалить аккаунт'),
|
||||
h('p', null, 'Будут удалены аккаунт и прогресс на сервере, все входы завершатся. Это нельзя отменить. На других устройствах локальные данные останутся.'),
|
||||
form(f => actions.deleteAccount(valueOf(f, 'au-del-pass')), passwordField('Введите пароль для подтверждения', 'current-password', 'au-del-pass'),
|
||||
h('div', { class: 'row' }, h('button', { type: 'submit', class: 'btn danger', disabled: state.busy }, 'Удалить навсегда'),
|
||||
linkButton('Отмена', () => { state.confirmDelete = false; state.message = null; render(); }))));
|
||||
}
|
||||
|
||||
function accountPanel() {
|
||||
if (state.confirmDelete) return deleteForm();
|
||||
return h('div', { class: 'panel authbox' }, messageBox(),
|
||||
h('div', null, h('b', null, state.user), h('div', { class: 'muted small' }, 'Вы вошли в аккаунт')),
|
||||
h('p', null, 'Прогресс, история и статистика клавиш сохраняются на сервере и доступны на любом устройстве после входа. Настройки и раскладка Vial остаются на этом устройстве.'),
|
||||
h('div', { class: 'muted small', role: 'status' }, state.syncText || 'Синхронизация ещё не выполнялась.'),
|
||||
h('div', { class: 'row' },
|
||||
h('button', { class: 'btn', type: 'button', disabled: state.syncing, onclick: syncNow }, 'Синхронизировать сейчас'),
|
||||
h('button', { class: 'btn', type: 'button', disabled: state.busy, onclick: actions.logout }, 'Выйти'),
|
||||
linkButton('Удалить аккаунт…', () => { state.confirmDelete = true; state.message = null; render(); })));
|
||||
}
|
||||
|
||||
function render() {
|
||||
const root = document.getElementById('v-account');
|
||||
if (!root) return;
|
||||
root.innerHTML = '';
|
||||
const subtitle = state.user ? 'Прогресс сохраняется на сервере и доступен на других устройствах.'
|
||||
: 'Войдите, чтобы хранить прогресс на сервере. Без входа всё работает как раньше: данные остаются в этом браузере.';
|
||||
root.append(h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Аккаунт'), h('p', { class: 'muted' }, subtitle))));
|
||||
const panel = state.user ? accountPanel() : guestPanel();
|
||||
root.append(...[].concat(panel));
|
||||
if (state.message && state.message.type === 'err' && !state.busy) {
|
||||
const first = root.querySelector('input');
|
||||
if (first) first.focus(); // после ошибки возвращаем фокус в форму, а не на начало страницы
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Запуск ----------
|
||||
async function init(context) {
|
||||
ctx = context;
|
||||
try {
|
||||
const health = await call('GET', 'health');
|
||||
state.available = health.ok && health.data.ok === true;
|
||||
} catch (e) {
|
||||
state.available = false;
|
||||
}
|
||||
if (!state.available) return false;
|
||||
|
||||
try {
|
||||
const me = await call('GET', 'me');
|
||||
state.user = me.ok ? me.data.email : null;
|
||||
} catch (e) { /* остаёмся гостем */ }
|
||||
|
||||
ctx.addNav(state.user ? 'Аккаунт' : 'Войти');
|
||||
window.addEventListener('hashchange', handleLink); // ссылка из письма, вставленная в уже открытую вкладку
|
||||
window.addEventListener('online', () => syncNow());
|
||||
handleLink();
|
||||
if (state.user) {
|
||||
adoptAccount(state.user);
|
||||
syncNow();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
window.Auth = { init, render, afterRun, syncNow, clearServerProgress, isLoggedIn: () => !!state.user };
|
||||
})();
|
||||
@@ -20,6 +20,7 @@
|
||||
<section class="view" id="v-lessons"></section>
|
||||
<section class="view" id="v-practice"></section>
|
||||
<section class="view" id="v-keyboard"></section>
|
||||
<section class="view" id="v-account"></section>
|
||||
<section class="view" id="v-train"></section>
|
||||
<section class="view" id="v-result"></section>
|
||||
<section class="view" id="v-stats"></section>
|
||||
@@ -30,6 +31,8 @@
|
||||
|
||||
<script src="data.js"></script>
|
||||
<script src="vial.js"></script>
|
||||
<script src="sync.js"></script>
|
||||
<script src="auth.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -301,3 +301,17 @@ td.r, th.r { text-align: right; }
|
||||
.nm input { font: inherit; color: var(--fg); background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 5px 9px; width: 150px; }
|
||||
.card.locked { opacity: .55; }
|
||||
.sec-head .btn { padding: 3px 12px; font-size: 13px; }
|
||||
|
||||
/* ---------- Аккаунт ---------- */
|
||||
.authbox { max-width: 520px; display: grid; gap: 16px; }
|
||||
.authbox .form { max-width: none; }
|
||||
.text {
|
||||
width: 100%; font: inherit; color: var(--fg); background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px;
|
||||
}
|
||||
.text:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.msg { padding: 10px 14px; border-radius: 10px; font-weight: 550; border: 1px solid transparent; }
|
||||
.msg.ok { background: color-mix(in srgb, var(--ok) 16%, transparent); border-color: var(--ok); }
|
||||
.msg.err { background: color-mix(in srgb, var(--bad) 16%, transparent); border-color: var(--bad); }
|
||||
.linkbtn { border: 0; background: none; padding: 0; color: var(--accent); font: inherit; font-weight: 600; cursor: pointer; text-decoration: underline; }
|
||||
.linkbtn:hover { filter: brightness(1.15); }
|
||||
.danger-zone { border-color: var(--bad); }
|
||||
|
||||
+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