Files
split-trainer/docs/DEPLOY.md
T
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

28 KiB
Raw Blame History

Развёртывание на своём сервере (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.

Как это выглядит для пользователя

  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:

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 и обновите сайт командой из раздела «Обновление»