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