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:
+131
-10
@@ -1,6 +1,6 @@
|
||||
# Развёртывание на своём сервере (Debian 12 / 13)
|
||||
|
||||
Сплит-тренер состоит из статических файлов (HTML, CSS, JS) и ничего не хранит на сервере. Нет бэкенда, базы данных, куки и обращений к внешним сайтам. Сервер только отдаёт файлы, поэтому требования минимальные.
|
||||
Сплит-тренер состоит из статических файлов (HTML, CSS, JS) и необязательного API аккаунтов (вход по почте и хранение прогресса на сервере). Без API сервер только отдаёт файлы, ничего не хранит и не ставит куки; с API появляются вход, подтверждение почты, сброс пароля и синхронизация прогресса между устройствами (раздел 4, «Аккаунты и почта»). Обращений к внешним сайтам нет в обоих режимах, кроме отправки писем через ваш SMTP.
|
||||
|
||||
## 1. Минимальные требования
|
||||
|
||||
@@ -16,20 +16,24 @@
|
||||
| ПО (вариант A) | Docker Engine 24+ с плагином Compose v2 | рекомендуется официальный репозиторий: docs.docker.com/engine/install/debian |
|
||||
| ПО (вариант B) | nginx 1.22+ из пакетов Debian (в Debian 12 это 1.22, в 13 это 1.26) | ставится скриптом |
|
||||
| Права | root или sudo на время установки | сайт работает без root-прав |
|
||||
| Аккаунты: ПО | вариант A: ничего дополнительного (API в отдельном контейнере); вариант B: python3 3.11+ (в Debian 12 это 3.11, в 13 это 3.13) | только стандартная библиотека Python, `pip` не нужен |
|
||||
| Аккаунты: память | ещё до 256 МБ свободной памяти | каждый вход хеширует пароль (scrypt, около 32 МБ на вход); для контейнера задан лимит 384 МБ |
|
||||
| Аккаунты: почта | доступ к SMTP-серверу (порт 587 или 465) | без SMTP письма печатаются в лог: годится только для проверки |
|
||||
|
||||
Нужно выбрать один вариант: A или B.
|
||||
Нужно выбрать один вариант: A или B. Аккаунты необязательны: без API сайт работает как раньше, просто без входа.
|
||||
|
||||
### Домен и HTTPS
|
||||
- Для работы сайта HTTPS **не обязателен**: приложение не использует API, требующие защищённого контекста.
|
||||
- Но на публичном адресе HTTPS нужен: его даёт ваш обратный прокси (раздел 4).
|
||||
- Но на публичном адресе HTTPS нужен: его даёт ваш обратный прокси (раздел 5). С аккаунтами это особенно важно: пароли и cookie входа не должны ходить по открытому каналу. Если `PUBLIC_URL` начинается с `https://`, cookie входа получает флаг `Secure`.
|
||||
|
||||
### Браузеры пользователей
|
||||
Современные: Chrome и Edge 111+, Firefox 113+, Safari 16.2+ (примерно 2023 год и новее). Для работы нужен включённый JavaScript и `localStorage`.
|
||||
|
||||
## 2. Что важно знать про данные
|
||||
|
||||
- Прогресс и статистика хранятся **в браузере пользователя** (`localStorage`), на сервер ничего не отправляется. Резервировать на сервере нечего.
|
||||
- Данные привязаны к адресу сайта (схема + домен + порт). Если сменить домен, пользователи начнут «с нуля»; смена пути (`/trainer/`) прогресс не затрагивает.
|
||||
- **Без входа (гость)** прогресс и статистика хранятся в браузере пользователя (`localStorage`), на сервер ничего не отправляется. Данные привязаны к адресу сайта (схема + домен + порт): при смене домена гость начинает «с нуля»; смена пути (`/trainer/`) прогресс не затрагивает.
|
||||
- **После входа** прогресс, история упражнений и статистика клавиш копируются на сервер и подтягиваются на других устройствах. Настройки и раскладка Vial остаются на устройстве.
|
||||
- **Что хранит сервер** (файл SQLite, в Docker это том `splitapi-data`): адрес почты, хеш пароля (scrypt, пароль в открытом виде не хранится), хеши токенов входа, подтверждения и сброса (сами токены не хранятся), JSON с прогрессом. Резервное копирование и удаление данных: раздел 4.
|
||||
- Файл раскладки Vial (.vil) читается локально в браузере и никуда не загружается.
|
||||
|
||||
## 3. Установка
|
||||
@@ -89,9 +93,120 @@ sudo ./deploy/install-debian.sh # вариант B
|
||||
|
||||
Браузеры подхватят новую версию сами: сайт отдаётся с `Cache-Control: no-cache`, то есть при каждом открытии версия сверяется по ETag.
|
||||
|
||||
## 4. За обратным прокси
|
||||
## 4. Аккаунты и почта
|
||||
|
||||
От прокси ничего особенного не требуется: сайт статический, без WebSocket, без нестандартных заголовков, все ссылки внутри относительные. Достаточно передать запрос на `127.0.0.1:8080`.
|
||||
Аккаунты необязательны: без API сайт работает как раньше, просто без пункта «Войти». С API появляются регистрация с подтверждением почты, вход, сброс пароля по почте, удаление аккаунта и хранение прогресса на сервере.
|
||||
|
||||
### Как включить
|
||||
|
||||
**Docker (вариант A).** `docker compose up -d --build` поднимает сайт и API вместе. Скопируйте `.env.example` в `.env` и задайте:
|
||||
- `PUBLIC_URL`: внешний адрес сайта, как его видят пользователи (из него строятся ссылки в письмах и проверяется `Origin`);
|
||||
- настройки почты (`SMTP_*`, см. ниже). Если сайт публичный, а SMTP не задан, API **не запустится**: в `docker compose logs splitapi` будет подсказка. Это сделано намеренно: без почты ссылки подтверждения оказались бы в логах. Для проверки можно явно указать `MAIL_BACKEND=console`: письма тогда печатаются в лог (`docker compose logs splitapi`).
|
||||
|
||||
Если аккаунты не нужны, удалите сервис `splitapi` из `docker-compose.yml`: сайт сам поймёт, что API нет, и не покажет вход.
|
||||
|
||||
**Без Docker (вариант B).** После `install-debian.sh`:
|
||||
|
||||
```bash
|
||||
sudo PUBLIC_URL=https://trainer.example.com \
|
||||
SMTP_HOST=smtp.example.com SMTP_USER=noreply@example.com SMTP_PASSWORD='...' \
|
||||
./deploy/install-api-debian.sh
|
||||
```
|
||||
|
||||
Скрипт ставит python3 (нужен 3.11 или новее: это Debian 12 и 13), копирует код в `/opt/split-trainer/api`, создаёт `/etc/split-trainer/api.env` (права `0600`) и службу `split-trainer-api` с изоляцией systemd (`DynamicUser`, `ProtectSystem=strict`, лимит памяти). База лежит в `/var/lib/split-trainer`. Повторный запуск обновляет код и перезапускает службу, а `api.env` не трогает: его правят вручную и затем `sudo systemctl restart split-trainer-api`. Логи: `journalctl -u split-trainer-api`.
|
||||
|
||||
### Как это выглядит для пользователя
|
||||
|
||||
1. **Регистрация.** Человек вводит только почту и получает письмо со ссылкой (действует 24 часа, работает один раз). По ссылке он подтверждает адрес **и сам задаёт пароль**: аккаунт создаётся только в этот момент. Поэтому чужая регистрация на ваш адрес ничего не подменит.
|
||||
2. **Вход.** Почта и пароль. После входа прогресс подтягивается с сервера и объединяется с тем, что уже есть на устройстве.
|
||||
3. **Сброс пароля.** «Забыли пароль?» → письмо со ссылкой (1 час, один раз). После смены пароля все прежние входы завершаются и приходит уведомление.
|
||||
4. **Синхронизация.** После каждого упражнения прогресс уходит на сервер. Слияние простое: лучший результат по урокам, объединение истории, для статистики клавиш берётся запись с большим числом нажатий (если два устройства долго работали офлайн, часть нажатий одного из них может не учесться).
|
||||
5. **Удаление.** В разделе «Аккаунт» есть «Удалить аккаунт» (нужен пароль): удаляются аккаунт, сессии и прогресс на сервере. «Сбросить все данные» в «Статистике» при входе очищает и серверную копию (на других устройствах локальные данные при следующей синхронизации вернутся: очистите и их).
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
| Переменная | По умолчанию | Назначение |
|
||||
|---|---|---|
|
||||
| `PUBLIC_URL` | в compose `http://127.0.0.1:8080` | **обязательна**: внешний адрес сайта (если сайт на подпути, с ним: `https://example.com/trainer`) |
|
||||
| `SMTP_HOST`, `SMTP_PORT` | порт зависит от `SMTP_SECURITY` | почтовый сервер |
|
||||
| `SMTP_SECURITY` | `starttls` | `starttls` (порт 587), `ssl` (465) или `none` (только для локального релея) |
|
||||
| `SMTP_USER`, `SMTP_PASSWORD` | пусто | логин и пароль SMTP; вместо пароля можно `SMTP_PASSWORD_FILE=/путь/к/файлу` (Docker secrets) |
|
||||
| `MAIL_FROM` | `Сплит-тренер <noreply@домен-сайта>` | от чьего имени уходят письма |
|
||||
| `MAIL_BACKEND` | `smtp`, если задан `SMTP_HOST`; `console` только для localhost | `console` печатает письма в лог: только для проверки |
|
||||
| `ALLOW_REGISTRATION` | `true` | `false` закроет регистрацию (прежние пользователи продолжат входить) |
|
||||
| `SESSION_DAYS` | `30` | сколько дней действует вход на устройстве |
|
||||
| `DB_PATH`, `BIND`, `PORT`, `TRUST_PROXY` | заданы compose и скриптом установки | служебные: менять не нужно |
|
||||
|
||||
После правки `.env`: `docker compose up -d`. Если сайт открывают не по тому адресу, что указан в `PUBLIC_URL`, запросы получат «Запрос отклонён», а в логе API будет строка с полученным и ожидаемым `Origin`.
|
||||
|
||||
### Почта (SMTP)
|
||||
|
||||
Примеры (подставьте данные вашего провайдера):
|
||||
|
||||
```
|
||||
# STARTTLS, порт 587
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURITY=starttls
|
||||
SMTP_USER=noreply@example.com
|
||||
SMTP_PASSWORD=секрет
|
||||
|
||||
# SSL, порт 465
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURITY=ssl
|
||||
```
|
||||
|
||||
Чтобы письма не попадали в «Спам», настройте для домена отправителя SPF, DKIM и DMARC. Письма отправляются в фоне: если SMTP недоступен, в логе API будет строка «не удалось отправить письмо» (с типом ошибки, без адреса и текста письма). Очередь рассчитана на 100 писем; при переполнении пользователь получает «сервер занят», а не ложное «проверьте почту». Очередь хранится в памяти: при перезапуске API неотправленные письма теряются.
|
||||
|
||||
### Резервное копирование и обслуживание
|
||||
|
||||
Данные лежат в SQLite (Docker: том `splitapi-data`; без Docker: `/var/lib/split-trainer/splitapi.sqlite3`). Копировать файл базы «на лету» нельзя, нужна встроенная резервная копия.
|
||||
|
||||
Docker:
|
||||
|
||||
```bash
|
||||
docker compose exec -T splitapi python -c "import sqlite3; s=sqlite3.connect('/data/splitapi.sqlite3'); d=sqlite3.connect('/data/backup.sqlite3'); s.backup(d); d.close(); s.close()"
|
||||
docker cp splitapi:/data/backup.sqlite3 ./backup-$(date +%F).sqlite3
|
||||
docker compose exec -T splitapi rm /data/backup.sqlite3
|
||||
```
|
||||
|
||||
Без Docker:
|
||||
|
||||
```bash
|
||||
sudo python3 -c "import sqlite3; s=sqlite3.connect('/var/lib/split-trainer/splitapi.sqlite3'); d=sqlite3.connect('/root/backup.sqlite3'); s.backup(d)"
|
||||
```
|
||||
|
||||
Удалить пользователя вручную (по просьбе, когда нет доступа к его паролю): в базе включите внешние ключи, иначе его сессии и прогресс останутся:
|
||||
|
||||
```bash
|
||||
docker compose exec -T splitapi python -c "import sqlite3; c=sqlite3.connect('/data/splitapi.sqlite3'); c.execute('pragma foreign_keys=on'); c.execute('delete from users where email=?', ('user@example.com',)); c.commit()"
|
||||
```
|
||||
|
||||
### Безопасность в двух словах
|
||||
|
||||
- Пароли хранятся только как хеш scrypt; токены входа, подтверждения и сброса хранятся только как SHA-256 и работают один раз.
|
||||
- Ссылки в письмах строятся из `PUBLIC_URL`, а не из заголовка `Host`. Токен лежит во фрагменте адреса (`#…`): он не попадает в логи сервера и заголовок `Referer`.
|
||||
- Ограничения частоты: вход считает только неудачи: по паре «адрес + IP» (5 за 10 минут), по IP (20 за 10 минут) и по всему аккаунту (100 за час, против распределённого подбора). Регистрация и сброс пароля ограничены по IP и по адресу. IPv6-клиенты считаются по сети /64. Успешные входы лимиты не расходуют: класс или семья за одним NAT входят без помех.
|
||||
- Порт API **не публикуется наружу**: к нему ходит только nginx из контейнера сайта. Не публикуйте порт 8000 и не ставьте API за другой прокси: API верит заголовку `X-Real-IP` (`TRUST_PROXY=1`) и только от приватных адресов.
|
||||
- Ваш внешний обратный прокси обязан **добавлять** настоящий адрес клиента в `X-Forwarded-For` (пример в разделе 5: `$proxy_add_x_forwarded_for`). Если он передаёт заголовок клиента без изменений, ограничения частоты можно обойти подменой адреса.
|
||||
- Режим `console` выключен по умолчанию на публичных адресах: ссылки из писем нельзя оставлять в логах.
|
||||
- Две вкладки с разными аккаунтами не смешивают прогресс: сервер сверяет, от чьего имени сохраняются данные, а устаревшая вкладка обновляется.
|
||||
|
||||
### Что сознательно не решено (принятые риски)
|
||||
|
||||
- **Счётчики ограничений хранятся в памяти одного процесса.** После перезапуска API они обнуляются, а запускать несколько процессов нельзя.
|
||||
- **Чужой «шум» мешает владельцу.** Кто-то может засыпать чужой адрес письмами (3 в час на адрес, 5 в час с IP) и этим же лимитом на время лишить владельца письма для сброса пароля. Это цена защиты ящика от рассылки.
|
||||
- **Пароли.** Минимум 8 символов и короткий список плохих паролей; проверки по базам утечек и двухфакторной защиты нет. Поэтому действуют лимиты на подбор.
|
||||
- **Доверие к `X-Real-IP`** от любых приватных адресов, а не только от nginx: на хосте любой локальный процесс может подделать IP (лимиты по почте при этом остаются). Закрыто тем, что API слушает только `127.0.0.1`.
|
||||
- **Данные последнего пользователя остаются на устройстве после выхода**, включая его адрес почты в локальном хранилище браузера. На общем компьютере используйте отдельный профиль браузера.
|
||||
- **Нет выхода со всех устройств и смены пароля внутри сессии:** сброс через почту завершает все входы.
|
||||
- **Пароль SMTP** передаётся переменной окружения и виден через `docker inspect`; вместо этого можно задать `SMTP_PASSWORD_FILE` (Docker secrets).
|
||||
- **Очередь писем в памяти:** при перезапуске API неотправленные письма теряются.
|
||||
|
||||
## 5. За обратным прокси
|
||||
|
||||
От прокси ничего особенного не требуется: без WebSocket, без нестандартных заголовков, все ссылки внутри относительные. Достаточно передать все запросы (в том числе `/api/`) на `127.0.0.1:8080`.
|
||||
|
||||
### nginx
|
||||
|
||||
@@ -167,16 +282,22 @@ example.com {
|
||||
|
||||
Конфиг доверяет заголовку `X-Forwarded-For` только от прокси из частных сетей и localhost (`127.0.0.0/8`, `10/8`, `172.16/12`, `192.168/16`). Если ваш прокси стоит на другом сервере с публичным адресом, добавьте его в `set_real_ip_from` в `deploy/split-trainer.conf` и пересоберите или переустановите.
|
||||
|
||||
## 5. Что отдаёт сервер
|
||||
## 6. Что отдаёт сервер
|
||||
|
||||
- `GET /healthz` отвечает `200 ok` (для мониторинга и healthcheck).
|
||||
- `GET /healthz` отвечает `200 ok` (для мониторинга и healthcheck сайта), `GET /api/health` отвечает `{"ok": true}` (то же для API).
|
||||
- Запросы `/api/*` nginx передаёт API аккаунтов; ответы API не кешируются (`Cache-Control: no-store`).
|
||||
- Заголовки безопасности: строгий `Content-Security-Policy` (только свои скрипты и стили, никаких внешних адресов), `X-Content-Type-Options`, `Referrer-Policy: no-referrer`, `X-Frame-Options`, `Permissions-Policy`.
|
||||
- Файлы сжимаются gzip, скрытые файлы (`.git`, `.env`) не отдаются, версия nginx в ответах скрыта.
|
||||
|
||||
## 6. Если что-то не работает
|
||||
## 7. Если что-то не работает
|
||||
|
||||
| Симптом | Причина и решение |
|
||||
|---|---|
|
||||
| На сайте нет пункта «Войти» | API не запущен или недоступен: `docker compose ps`, `curl http://127.0.0.1:8080/api/health` (ожидается `{"ok": true}`) |
|
||||
| Письмо не приходит | Проверьте «Спам» и `docker compose logs splitapi`: при ошибке SMTP там строка «не удалось отправить письмо». Без `SMTP_HOST` письма только в логе |
|
||||
| «Ссылка недействительна или устарела» | Ссылка одноразовая и живёт 24 часа (подтверждение) или 1 час (сброс); повторная регистрация или запрос сброса присылает новую, а старая перестаёт работать |
|
||||
| «Запрос отклонён» при входе | `PUBLIC_URL` не совпадает с адресом, по которому открыт сайт (схема, домен, порт): исправьте в `.env` и перезапустите `splitapi` |
|
||||
| «Слишком много попыток» | Сработало ограничение частоты: подождите несколько минут |
|
||||
| 502 / 504 от прокси | Сайт не запущен или слушает другой порт: `docker compose ps`, `curl http://127.0.0.1:8080/healthz` |
|
||||
| Белая страница под `/trainer/` | Нет завершающего `/` в `proxy_pass` или нет редиректа `/trainer` на `/trainer/` (см. выше) |
|
||||
| После обновления видна старая версия | Обновите страницу с Ctrl+F5; если стоит CDN или кеш на прокси, исключите сайт из кеширования |
|
||||
|
||||
+13
-1
@@ -108,6 +108,18 @@
|
||||
| Прогресс пропал | Он хранится в браузере: проверьте, тем ли браузером и тем же файлом вы пользуетесь |
|
||||
| Нужно всё обнулить | «Статистика» → «Сбросить все данные» |
|
||||
|
||||
## 9. Файлы приложения
|
||||
## 9. Аккаунт и синхронизация (если сайт установлен на сервере с API)
|
||||
|
||||
Пункт **«Войти»** в меню появляется только на сервере, где установлен API. Без входа всё работает как раньше: прогресс хранится в этом браузере.
|
||||
|
||||
- **Регистрация.** Введите только почту. Придёт письмо со ссылкой (действует сутки): по ней вы подтверждаете адрес и придумываете пароль, и только после этого создаётся аккаунт. Если письма нет, загляните в «Спам» и проверьте адрес.
|
||||
- **Вход.** Почта и пароль. Прогресс с устройства объединяется с тем, что уже лежит на сервере, и после каждого упражнения отправляется туда. На другом устройстве достаточно войти, и всё на месте.
|
||||
- **Забыли пароль.** «Забыли пароль?» → письмо со ссылкой (действует час, работает один раз) → новый пароль. Все прежние входы на устройствах завершатся.
|
||||
- **Выход.** Прогресс остаётся на устройстве. Если потом в этом же браузере войдёт другой человек, ваши локальные данные в его аккаунт **не** попадут: они будут очищены.
|
||||
- **Сброс данных.** «Статистика» → «Сбросить все данные» при входе очищает и копию на сервере. На других устройствах локальные данные при следующей синхронизации вернутся: очистите их и там.
|
||||
- **Удаление аккаунта.** «Аккаунт» → «Удалить аккаунт…» (нужен пароль): удаляются аккаунт и прогресс на сервере.
|
||||
- **Что синхронизируется.** Прогресс по урокам, история упражнений и статистика клавиш. Настройки и раскладка Vial остаются на устройстве.
|
||||
|
||||
## 10. Файлы приложения
|
||||
|
||||
В папке `public/`: `index.html` (запуск), `app.js` (логика), `vial.js` (чтение Vial и маршруты по слоям), `data.js` (раскладки, слова и тексты упражнений), `styles.css` (оформление). Слова и предложения для упражнений можно править в `data.js`. Установка на сервер описана в `docs/DEPLOY.md`.
|
||||
|
||||
Reference in New Issue
Block a user