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:
darko
2026-10-16 11:25:09 +03:00
parent e897a6c5eb
commit fcafe1ae15
36 changed files with 4520 additions and 22 deletions
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Установка API аккаунтов на Debian 12/13 без Docker: python3 из пакетов + служба systemd.
# Сначала поставьте сайт: sudo ./deploy/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
#
# Первый запуск создаёт /etc/split-trainer/api.env из переданных переменных. Повторный запуск обновляет код и
# перезапускает службу, а файл настроек не трогает (меняйте его вручную и перезапускайте: systemctl restart split-trainer-api).
# Пока SMTP не настроен, а сайт публичный, API не стартует (так задумано): укажите SMTP_HOST или MAIL_BACKEND=console для проверки.
set -euo pipefail
APP_DIR="/opt/split-trainer/api"
ENV_DIR="/etc/split-trainer"
ENV_FILE="$ENV_DIR/api.env"
UNIT="/etc/systemd/system/split-trainer-api.service"
API_PORT="${API_PORT:-8000}"
die() { echo "Ошибка: $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "запустите от root: sudo $0"
[[ "$API_PORT" =~ ^[0-9]{1,5}$ ]] || die "некорректный API_PORT: $API_PORT"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC="$HERE/../server/splitapi"
[ -f "$SRC/__main__.py" ] || die "не найден $SRC: запускайте скрипт из клонированного репозитория"
. /etc/os-release
if [ "${ID:-}" != "debian" ] || { [ "${VERSION_ID:-}" != "12" ] && [ "${VERSION_ID:-}" != "13" ]; }; then
[ "${FORCE:-0}" = "1" ] || die "скрипт рассчитан на Debian 12/13 (у вас: ${PRETTY_NAME:-неизвестно}). Чтобы всё равно продолжить: FORCE=1"
fi
echo "==> Проверяю python3"
export DEBIAN_FRONTEND=noninteractive
command -v python3 >/dev/null 2>&1 || { apt-get update -qq; apt-get install -y -qq --no-install-recommends python3 >/dev/null; }
command -v curl >/dev/null 2>&1 || { apt-get update -qq; apt-get install -y -qq --no-install-recommends curl >/dev/null; } # для проверки в конце
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)' || die "нужен Python 3.11 или новее"
python3 -c 'import hashlib, sqlite3; hashlib.scrypt' >/dev/null 2>&1 || die "в python3 нет sqlite3 или scrypt (установите пакет python3 целиком)"
echo "==> Копирую код в $APP_DIR"
install -d -m 755 "$APP_DIR"
rm -rf "$APP_DIR/splitapi" # наш собственный каталог: заменяем целиком
cp -r "$SRC" "$APP_DIR/splitapi"
find "$APP_DIR/splitapi" -name '__pycache__' -type d -prune -exec rm -rf {} +
chown -R root:root "$APP_DIR"
chmod -R go-w "$APP_DIR"
# Значение переменной в формате EnvironmentFile systemd: в двойных кавычках, \ и " экранируются, перевод строки недопустим
env_line() {
local name="$1" value="$2"
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "в значении $name не должно быть перевода строки"
printf '%s="%s"\n' "$name" "$(printf '%s' "$value" | sed -e 's/[\\"]/\\&/g')"
}
if [ -f "$ENV_FILE" ]; then
echo "==> $ENV_FILE уже есть: не меняю"
else
[ -n "${PUBLIC_URL:-}" ] || die "задайте PUBLIC_URL (внешний адрес сайта), например PUBLIC_URL=https://trainer.example.com"
echo "==> Создаю $ENV_FILE (права 0600: в нём может быть пароль почты)"
install -d -m 755 "$ENV_DIR"
umask 077
{
env_line PUBLIC_URL "$PUBLIC_URL"
env_line BIND "127.0.0.1"
env_line PORT "$API_PORT"
env_line TRUST_PROXY "1"
env_line DB_PATH "/var/lib/split-trainer/splitapi.sqlite3"
for name in MAIL_BACKEND MAIL_FROM SMTP_HOST SMTP_PORT SMTP_SECURITY SMTP_USER SMTP_PASSWORD ALLOW_REGISTRATION SESSION_DAYS; do
if [ -n "${!name:-}" ]; then env_line "$name" "${!name}"; fi
done
} > "$ENV_FILE"
chmod 600 "$ENV_FILE"
chown root:root "$ENV_FILE"
fi
echo "==> Настраиваю службу systemd"
cat > "$UNIT" <<'UNIT_EOF'
[Unit]
Description=Сплит-тренер: API аккаунтов
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/split-trainer/api
EnvironmentFile=/etc/split-trainer/api.env
Environment=PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
ExecStart=/usr/bin/python3 -m splitapi
Restart=on-failure
RestartSec=3
# Без постоянного пользователя и почти без прав
DynamicUser=yes
StateDirectory=split-trainer
StateDirectoryMode=0700
NoNewPrivileges=yes
CapabilityBoundingSet=
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
UMask=0077
SystemCallArchitectures=native
SystemCallFilter=@system-service
MemoryMax=384M
TasksMax=64
[Install]
WantedBy=multi-user.target
UNIT_EOF
chmod 644 "$UNIT"
systemctl daemon-reload
systemctl enable split-trainer-api >/dev/null 2>&1
systemctl restart split-trainer-api
echo "==> Проверяю, что API отвечает"
PORT_IN_USE="$(sed -n 's/^PORT="\([0-9]*\)"$/\1/p' "$ENV_FILE" | head -n1)"
PORT_IN_USE="${PORT_IN_USE:-$API_PORT}"
for _ in $(seq 1 20); do
if curl -fsS -m 3 -o /dev/null "http://127.0.0.1:$PORT_IN_USE/api/health" 2>/dev/null; then
echo "==> Готово: API слушает 127.0.0.1:$PORT_IN_USE. Сайт обращается к нему через nginx (install-debian.sh, API_UPSTREAM)."
exit 0
fi
sleep 1
done
echo "API не ответил за 20 секунд. Последние строки журнала:" >&2
journalctl -u split-trainer-api -n 20 --no-pager >&2 || true
exit 1
+10 -1
View File
@@ -10,6 +10,7 @@ set -euo pipefail
LISTEN="${LISTEN:-127.0.0.1:8080}" # адрес:порт или просто порт
DOMAIN="${DOMAIN:-_}" # server_name; "_" = любое имя
WEBROOT="${WEBROOT:-/var/www/split-trainer}"
API_UPSTREAM="${API_UPSTREAM:-http://127.0.0.1:8000}" # где слушает API аккаунтов (ставится deploy/install-api-debian.sh)
NAME="split-trainer"
die() { echo "Ошибка: $*" >&2; exit 1; }
@@ -26,6 +27,12 @@ CONF_TEMPLATE="$HERE/$NAME.conf"
[[ "$LISTEN" =~ ^[][0-9A-Za-z.:_-]+$ ]] || die "некорректный LISTEN: $LISTEN"
[[ "$DOMAIN" =~ ^[0-9A-Za-z._*-]+( [0-9A-Za-z._*-]+)*$ ]] || die "некорректный DOMAIN: $DOMAIN"
[[ "$WEBROOT" =~ ^/[0-9A-Za-z._/-]+$ ]] || die "некорректный WEBROOT: $WEBROOT"
[[ "$API_UPSTREAM" =~ ^http://(127\.0\.0\.1|\[::1\]):[0-9]{1,5}$ ]] || die "некорректный API_UPSTREAM (нужен http://127.0.0.1:порт или http://[::1]:порт, без имён: DNS здесь не работает): $API_UPSTREAM"
# Скрипт стирает старые файлы в WEBROOT: системные каталоги и каталоги верхнего уровня запрещаем
case "$WEBROOT" in
/|/etc|/etc/*|/usr|/usr/*|/bin|/bin/*|/sbin|/sbin/*|/lib*|/boot|/boot/*|/root|/root/*|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/home|/var|/opt|/srv|/run|/run/*|/tmp)
die "WEBROOT не может быть системным каталогом: $WEBROOT (подойдёт, например, /var/www/split-trainer)";;
esac
. /etc/os-release
if [ "${ID:-}" != "debian" ] || { [ "${VERSION_ID:-}" != "12" ] && [ "${VERSION_ID:-}" != "13" ]; }; then
@@ -47,7 +54,9 @@ CONF="/etc/nginx/sites-available/$NAME.conf"
sed -e "s|^\(\s*\)listen 8080;.*|\1listen $LISTEN;|" \
-e "s|^\(\s*\)server_name _;.*|\1server_name $DOMAIN;|" \
-e "s|^\(\s*\)root /usr/share/nginx/html;.*|\1root $WEBROOT;|" \
"$CONF_TEMPLATE" > "$CONF"
-e "s|^\(\s*\)set \$api_upstream .*|\1set \$api_upstream $API_UPSTREAM;|" \
-e '/^\s*resolver 127\.0\.0\.11/d' \
"$CONF_TEMPLATE" > "$CONF" # DNS Docker на хосте не нужен: API по адресу 127.0.0.1
ln -sf "$CONF" "/etc/nginx/sites-enabled/$NAME.conf"
nginx -t
+22 -2
View File
@@ -1,7 +1,10 @@
# Сплит-тренер: статический сайт. Один и тот же файл годится и для контейнера, и для nginx на хосте.
# Скрипт deploy/install-debian.sh заменяет ровно три строки с пометкой "# <-": listen, server_name и root.
# Сплит-тренер: статический сайт и прокси к API аккаунтов. Один и тот же файл годится и для контейнера, и для nginx на хосте.
# Скрипт deploy/install-debian.sh заменяет строки с пометкой "# <-": listen, server_name, root и адрес API.
# Совместимо с nginx 1.22 (Debian 12) и 1.26 (Debian 13).
# Грубая защита API от потока запросов (точные лимиты на вход, регистрацию и сброс пароля считает сам API)
limit_req_zone $binary_remote_addr zone=splitapi_zone:10m rate=10r/s;
server {
listen 8080; # <- порт или адрес:порт
server_name _; # <- имя сайта
@@ -29,6 +32,23 @@ server {
gzip_min_length 512;
gzip_types text/css application/javascript text/javascript text/plain application/json image/svg+xml;
# API аккаунтов (отдельный процесс). Если его нет, ответ 502: сайт это замечает и просто не показывает вход.
# Адрес в переменной и resolver (DNS Docker): так nginx стартует, даже если контейнер API ещё не поднялся.
resolver 127.0.0.11 valid=10s ipv6=off;
location /api/ {
set $api_upstream http://splitapi:8000; # <- адрес API
proxy_pass $api_upstream;
limit_req zone=splitapi_zone burst=20 nodelay;
limit_req_status 429;
client_max_body_size 300k;
proxy_connect_timeout 3s;
proxy_read_timeout 20s;
proxy_set_header Host $host;
# $remote_addr здесь уже настоящий клиент (real_ip выше); API верит этому заголовку, только если порт API недоступен снаружи
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Проверка живости для мониторинга и healthcheck контейнера
location = /healthz {
access_log off;