Files
darko fcafe1ae15 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.
2026-10-16 11:25:09 +03:00

400 lines
20 KiB
JavaScript

/* Аккаунт: регистрация с подтверждением почты, вход, сброс пароля, удаление аккаунта и синхронизация прогресса.
Работает только если рядом есть 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 };
})();