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.
28 KiB
Развёртывание на своём сервере (Debian 12 / 13)
Сплит-тренер состоит из статических файлов (HTML, CSS, JS) и необязательного API аккаунтов (вход по почте и хранение прогресса на сервере). Без API сервер только отдаёт файлы, ничего не хранит и не ставит куки; с API появляются вход, подтверждение почты, сброс пароля и синхронизация прогресса между устройствами (раздел 4, «Аккаунты и почта»). Обращений к внешним сайтам нет в обоих режимах, кроме отправки писем через ваш SMTP.
1. Минимальные требования
Сервер
| Минимум | Комментарий | |
|---|---|---|
| ОС | Debian 12 (bookworm) или Debian 13 (trixie), amd64 или arm64 | |
| CPU / RAM | 1 vCPU, 128 МБ свободной памяти | nginx в простое занимает 5–10 МБ; для контейнера задан лимит 64 МБ |
| Диск | порядка 500 МБ с Docker Engine (сам образ занимает несколько десятков МБ) или ~10 МБ с nginx из пакетов | сам сайт меньше 1 МБ |
| Сеть | один TCP-порт; по умолчанию 127.0.0.1:8080 |
наружу выставляет обратный прокси |
| Исходящий доступ | только на время установки (apt или docker pull) | во время работы сайту интернет не нужен |
| ПО (вариант 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. Аккаунты необязательны: без API сайт работает как раньше, просто без входа.
Домен и HTTPS
- Для работы сайта HTTPS не обязателен: приложение не использует API, требующие защищённого контекста.
- Но на публичном адресе HTTPS нужен: его даёт ваш обратный прокси (раздел 5). С аккаунтами это особенно важно: пароли и cookie входа не должны ходить по открытому каналу. Если
PUBLIC_URLначинается сhttps://, cookie входа получает флагSecure.
Браузеры пользователей
Современные: Chrome и Edge 111+, Firefox 113+, Safari 16.2+ (примерно 2023 год и новее). Для работы нужен включённый JavaScript и localStorage.
2. Что важно знать про данные
- Без входа (гость) прогресс и статистика хранятся в браузере пользователя (
localStorage), на сервер ничего не отправляется. Данные привязаны к адресу сайта (схема + домен + порт): при смене домена гость начинает «с нуля»; смена пути (/trainer/) прогресс не затрагивает. - После входа прогресс, история упражнений и статистика клавиш копируются на сервер и подтягиваются на других устройствах. Настройки и раскладка Vial остаются на устройстве.
- Что хранит сервер (файл SQLite, в Docker это том
splitapi-data): адрес почты, хеш пароля (scrypt, пароль в открытом виде не хранится), хеши токенов входа, подтверждения и сброса (сами токены не хранятся), JSON с прогрессом. Резервное копирование и удаление данных: раздел 4. - Файл раскладки Vial (.vil) читается локально в браузере и никуда не загружается.
3. Установка
Склонируйте репозиторий на сервер:
git clone https://gittea.khad1997pav.ru/khadzhibekovpa/split-trainer.git
cd split-trainer
Вариант A: Docker (рекомендуется)
docker compose up -d --build
curl -i http://127.0.0.1:8080/healthz # должно ответить 200 и "ok"
- Порт по умолчанию открыт только на localhost. Изменить: файл
.envрядом сdocker-compose.yml, напримерPORT=9000и/илиBIND=0.0.0.0(откроет наружу: тогда закройте порт файрволом для всех, кроме прокси). - Контейнер работает без root-прав, с файловой системой «только чтение», без лишних возможностей ядра и с лимитом памяти.
- Если прокси тоже в Docker, уберите блок
ports:и подключите сервис к общей сети прокси: он слушает8080внутри контейнера.
Вариант B: nginx из пакетов Debian
sudo ./deploy/install-debian.sh
curl -i http://127.0.0.1:8080/healthz
Скрипт ставит nginx, копирует файлы в /var/www/split-trainer и подключает отдельный конфиг split-trainer.conf. Чужие сайты и настройки он не трогает.
Параметры (переменные окружения):
| Переменная | По умолчанию | Назначение |
|---|---|---|
LISTEN |
127.0.0.1:8080 |
адрес и порт (или только порт) |
DOMAIN |
_ |
server_name, можно несколько через пробел |
WEBROOT |
/var/www/split-trainer |
куда копировать файлы |
FORCE |
0 |
1: разрешить запуск не на Debian 12/13 |
Без прокси, прямо на порту 80:
sudo LISTEN=80 DOMAIN=trainer.example.com ./deploy/install-debian.sh
Если порт 80 уже занят «сайтом по умолчанию» nginx и вы не задали DOMAIN, будет открываться страница «Welcome to nginx»: укажите домен или отключите сайт по умолчанию (sudo rm /etc/nginx/sites-enabled/default && sudo systemctl reload nginx).
Обновление
git pull
docker compose up -d --build # вариант A
# или
sudo ./deploy/install-debian.sh # вариант B
Браузеры подхватят новую версию сами: сайт отдаётся с Cache-Control: no-cache, то есть при каждом открытии версия сверяется по ETag.
4. Аккаунты и почта
Аккаунты необязательны: без 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:
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.
Как это выглядит для пользователя
- Регистрация. Человек вводит только почту и получает письмо со ссылкой (действует 24 часа, работает один раз). По ссылке он подтверждает адрес и сам задаёт пароль: аккаунт создаётся только в этот момент. Поэтому чужая регистрация на ваш адрес ничего не подменит.
- Вход. Почта и пароль. После входа прогресс подтягивается с сервера и объединяется с тем, что уже есть на устройстве.
- Сброс пароля. «Забыли пароль?» → письмо со ссылкой (1 час, один раз). После смены пароля все прежние входы завершаются и приходит уведомление.
- Синхронизация. После каждого упражнения прогресс уходит на сервер. Слияние простое: лучший результат по урокам, объединение истории, для статистики клавиш берётся запись с большим числом нажатий (если два устройства долго работали офлайн, часть нажатий одного из них может не учесться).
- Удаление. В разделе «Аккаунт» есть «Удалить аккаунт» (нужен пароль): удаляются аккаунт, сессии и прогресс на сервере. «Сбросить все данные» в «Статистике» при входе очищает и серверную копию (на других устройствах локальные данные при следующей синхронизации вернутся: очистите и их).
Переменные окружения
| Переменная | По умолчанию | Назначение |
|---|---|---|
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:
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:
sudo python3 -c "import sqlite3; s=sqlite3.connect('/var/lib/split-trainer/splitapi.sqlite3'); d=sqlite3.connect('/root/backup.sqlite3'); s.backup(d)"
Удалить пользователя вручную (по просьбе, когда нет доступа к его паролю): в базе включите внешние ключи, иначе его сессии и прогресс останутся:
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
server {
listen 443 ssl;
server_name trainer.example.com;
ssl_certificate /etc/letsencrypt/live/trainer.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/trainer.example.com/privkey.pem;
# HTTPS завершается здесь, поэтому HSTS должен ставить именно прокси
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Caddy
trainer.example.com {
reverse_proxy 127.0.0.1:8080
}
HTTPS-сертификат Caddy получит сам.
Traefik (Docker, метки в docker-compose.override.yml)
services:
split-trainer:
ports: !reset []
networks: [traefik]
labels:
- traefik.enable=true
- traefik.http.routers.trainer.rule=Host(`trainer.example.com`)
- traefik.http.routers.trainer.tls.certresolver=letsencrypt
- traefik.http.services.trainer.loadbalancer.server.port=8080
networks:
traefik:
external: true
Имена сети и резолвера замените на свои. Тег !reset требует Docker Compose 2.24 или новее.
Под подпутём (https://example.com/trainer/)
Приложение можно отдавать из подкаталога: все пути в нём относительные, свой конфиг менять не нужно. Прокси должен убрать префикс (завершающий / в proxy_pass обязателен):
location = /trainer { return 301 /trainer/; }
location /trainer/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; }
Для Caddy:
example.com {
redir /trainer /trainer/
handle_path /trainer/* { reverse_proxy 127.0.0.1:8080 }
}
Настоящий IP клиента в логах
Конфиг доверяет заголовку 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 и пересоберите или переустановите.
6. Что отдаёт сервер
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 в ответах скрыта.
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 или кеш на прокси, исключите сайт из кеширования |
| Прогресс пропал после смены адреса | localStorage привязан к адресу сайта (схема + домен + порт); вернуть старый адрес |
nginx -t ругается (вариант B) |
Покажите вывод sudo nginx -t; чаще всего порт занят другим сайтом |
| Нужно изменить текст упражнений | Правьте public/data.js и обновите сайт командой из раздела «Обновление» |