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:
Executable
+140
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user