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
|
||||
Reference in New Issue
Block a user