From fcafe1ae15fe423e5db60db9692fa80d29b64ce2 Mon Sep 17 00:00:00 2001 From: darko Date: Fri, 16 Oct 2026 11:25:09 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D1=87=D1=82=D0=B5=20?= =?UTF-8?q?=D1=81=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC,=20=D1=81=D0=B1=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=BE=D0=BC=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .dockerignore | 5 + .env.example | 30 ++ .gitignore | 7 + Dockerfile.api | 21 + README.md | 10 +- deploy/install-api-debian.sh | 140 ++++++ deploy/install-debian.sh | 11 +- deploy/split-trainer.conf | 24 +- docker-compose.yml | 47 +- docs/DEPLOY.md | 141 +++++- docs/ИНСТРУКЦИЯ.md | 14 +- public/app.js | 31 +- public/auth.js | 399 ++++++++++++++++ public/index.html | 3 + public/styles.css | 14 + public/sync.js | 104 ++++ server/splitapi/__init__.py | 1 + server/splitapi/__main__.py | 69 +++ server/splitapi/config.py | 166 +++++++ server/splitapi/http_api.py | 241 ++++++++++ server/splitapi/mailer.py | 188 ++++++++ server/splitapi/ratelimit.py | 79 ++++ server/splitapi/security.py | 164 +++++++ server/splitapi/service.py | 277 +++++++++++ server/splitapi/statevalid.py | 109 +++++ server/splitapi/store.py | 245 ++++++++++ server/tests/__init__.py | 0 server/tests/test_api.py | 810 ++++++++++++++++++++++++++++++++ server/tests/test_config.py | 120 +++++ server/tests/test_mailer.py | 206 ++++++++ server/tests/test_main.py | 115 +++++ server/tests/test_ratelimit.py | 117 +++++ server/tests/test_security.py | 149 ++++++ server/tests/test_statevalid.py | 94 ++++ server/tests/test_store.py | 262 +++++++++++ tests/sync.test.js | 129 +++++ 36 files changed, 4520 insertions(+), 22 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile.api create mode 100755 deploy/install-api-debian.sh create mode 100644 public/auth.js create mode 100644 public/sync.js create mode 100644 server/splitapi/__init__.py create mode 100644 server/splitapi/__main__.py create mode 100644 server/splitapi/config.py create mode 100644 server/splitapi/http_api.py create mode 100644 server/splitapi/mailer.py create mode 100644 server/splitapi/ratelimit.py create mode 100644 server/splitapi/security.py create mode 100644 server/splitapi/service.py create mode 100644 server/splitapi/statevalid.py create mode 100644 server/splitapi/store.py create mode 100644 server/tests/__init__.py create mode 100644 server/tests/test_api.py create mode 100644 server/tests/test_config.py create mode 100644 server/tests/test_mailer.py create mode 100644 server/tests/test_main.py create mode 100644 server/tests/test_ratelimit.py create mode 100644 server/tests/test_security.py create mode 100644 server/tests/test_statevalid.py create mode 100644 server/tests/test_store.py create mode 100644 tests/sync.test.js diff --git a/.dockerignore b/.dockerignore index 20517f0..562d0d1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,11 @@ .claude .gitignore .gitattributes +.env *.md *.vil +*.sqlite3* docs +tests +server/tests +**/__pycache__ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c5c505b --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Образец настроек. Скопируйте в файл .env рядом с docker-compose.yml и измените под себя: +# cp .env.example .env +# Файл .env содержит пароль почтового сервиса: не добавляйте его в git (он уже в .gitignore). + +# --- Сайт --- +# Порт и адрес, на котором сайт слушает на сервере (по умолчанию только localhost: за обратным прокси) +#BIND=127.0.0.1 +#PORT=8080 + +# Внешний адрес сайта, как его видят пользователи (из него строятся ссылки в письмах и проверяется Origin). +# Если сайт открывается по подпути, укажите его: https://example.com/trainer +PUBLIC_URL=http://127.0.0.1:8080 + +# --- Аккаунты --- +# false закроет регистрацию новых пользователей (уже зарегистрированные продолжат входить) +#ALLOW_REGISTRATION=true +# Сколько дней действует вход на устройстве +#SESSION_DAYS=30 + +# --- Почта (подтверждение регистрации и сброс пароля) --- +# Пока SMTP_HOST не задан, письма не отправляются, а печатаются в лог контейнера splitapi. +# Это только для проверки: на боевом сервере настройте SMTP. +#SMTP_HOST=smtp.example.com +#SMTP_PORT=587 +# starttls (порт 587), ssl (порт 465) или none (только для локального релея) +#SMTP_SECURITY=starttls +#SMTP_USER=noreply@example.com +#SMTP_PASSWORD= +# От чьего имени уходят письма (по умолчанию: noreply@домен-сайта) +#MAIL_FROM=Сплит-тренер diff --git a/.gitignore b/.gitignore index 87cdcfa..6ac13e4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,10 @@ *.vil Thumbs.db .DS_Store +# секреты и данные: никогда не коммитим +.env +*.sqlite3 +*.sqlite3-wal +*.sqlite3-shm +__pycache__/ +*.pyc diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..30a6070 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,21 @@ +# API аккаунтов Сплит-тренера: только стандартная библиотека Python, без сторонних пакетов. +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + DB_PATH=/data/splitapi.sqlite3 \ + BIND=0.0.0.0 \ + PORT=8000 + +WORKDIR /app +COPY server/splitapi /app/splitapi + +# Без root-прав; /data - единственное место для записи (том с базой SQLite) +RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin app \ + && mkdir /data && chown 10001:10001 /data +USER 10001 + +EXPOSE 8000 +VOLUME /data +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["python", "-m", "splitapi", "--healthcheck"] +CMD ["python", "-m", "splitapi"] diff --git a/README.md b/README.md index aaf3113..6d2f153 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,15 @@ Как пользоваться: [docs/ИНСТРУКЦИЯ.md](docs/ИНСТРУКЦИЯ.md). +## Аккаунты (необязательно) + +На сервере можно включить вход по почте: регистрация с подтверждением, сброс пароля, прогресс на сервере. API написан на чистой стандартной библиотеке Python (`server/`), настройки почты задаются переменными окружения (образец: `.env.example`). Подробнее: раздел «Аккаунты и почта» в [docs/DEPLOY.md](docs/DEPLOY.md). + ## Структура - `public/` - сайт: `index.html`, `app.js` (логика), `vial.js` (разбор Vial и маршруты по слоям), `data.js` (раскладки, слова, тексты), `styles.css` -- `deploy/` - конфиг nginx и скрипт установки для Debian -- `Dockerfile`, `docker-compose.yml` - запуск в контейнере +- `server/` - API аккаунтов (Python, без зависимостей) и его тесты +- `tests/` - тесты клиентского кода (`node --test "tests/*.test.js"`) +- `deploy/` - конфиг nginx и скрипты установки для Debian +- `Dockerfile` (сайт), `Dockerfile.api` (API), `docker-compose.yml` - запуск в контейнерах - `docs/` - инструкции diff --git a/deploy/install-api-debian.sh b/deploy/install-api-debian.sh new file mode 100755 index 0000000..7a6074f --- /dev/null +++ b/deploy/install-api-debian.sh @@ -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 diff --git a/deploy/install-debian.sh b/deploy/install-debian.sh index 915fb04..e3f2334 100755 --- a/deploy/install-debian.sh +++ b/deploy/install-debian.sh @@ -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 diff --git a/deploy/split-trainer.conf b/deploy/split-trainer.conf index 28a37af..63b5c43 100644 --- a/deploy/split-trainer.conf +++ b/deploy/split-trainer.conf @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml index 8afd0d9..377858b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ # Запуск: docker compose up -d --build -# По умолчанию порт открыт только на localhost: сайт предназначен для работы за обратным прокси. -# BIND=0.0.0.0 откроет его наружу, PORT=9000 сменит порт (переменные можно положить в файл .env рядом). +# Сайт по умолчанию открыт только на localhost: он предназначен для работы за обратным прокси. +# BIND=0.0.0.0 откроет его наружу, PORT=9000 сменит порт. Все переменные можно положить в файл .env рядом (образец: .env.example). services: split-trainer: build: . @@ -23,3 +23,46 @@ services: options: max-size: "5m" max-file: "3" + + # API аккаунтов. Порт наружу не публикуется: к нему ходит только nginx из контейнера выше по имени splitapi. + # Если API не нужен, сайт работает и без него (просто без входа): уберите этот сервис. + splitapi: + build: + context: . + dockerfile: Dockerfile.api + image: split-trainer-api:latest + container_name: splitapi + restart: unless-stopped + environment: + # Должен совпадать с адресом, по которому открывают сайт (схема, домен, порт): из него строятся ссылки в письмах и проверяется Origin + PUBLIC_URL: ${PUBLIC_URL:-http://127.0.0.1:${PORT:-8080}} + # Верим X-Real-IP, потому что порт API не публикуется и до него доходит только nginx. Не публикуйте порт 8000. + TRUST_PROXY: "1" + MAIL_BACKEND: ${MAIL_BACKEND:-} + MAIL_FROM: ${MAIL_FROM:-} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-} + SMTP_SECURITY: ${SMTP_SECURITY:-} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-true} + SESSION_DAYS: ${SESSION_DAYS:-} + volumes: + - splitapi-data:/data + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + mem_limit: 384m # scrypt занимает десятки МБ на каждый одновременный вход + pids_limit: 64 + logging: + driver: json-file + options: + max-size: "5m" + max-file: "3" + +volumes: + splitapi-data: diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index ae255be..2a0495a 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -1,6 +1,6 @@ # Развёртывание на своём сервере (Debian 12 / 13) -Сплит-тренер состоит из статических файлов (HTML, CSS, JS) и ничего не хранит на сервере. Нет бэкенда, базы данных, куки и обращений к внешним сайтам. Сервер только отдаёт файлы, поэтому требования минимальные. +Сплит-тренер состоит из статических файлов (HTML, CSS, JS) и необязательного API аккаунтов (вход по почте и хранение прогресса на сервере). Без API сервер только отдаёт файлы, ничего не хранит и не ставит куки; с API появляются вход, подтверждение почты, сброс пароля и синхронизация прогресса между устройствами (раздел 4, «Аккаунты и почта»). Обращений к внешним сайтам нет в обоих режимах, кроме отправки писем через ваш SMTP. ## 1. Минимальные требования @@ -16,20 +16,24 @@ | ПО (вариант 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. +Нужно выбрать один вариант: A или B. Аккаунты необязательны: без API сайт работает как раньше, просто без входа. ### Домен и HTTPS - Для работы сайта HTTPS **не обязателен**: приложение не использует API, требующие защищённого контекста. -- Но на публичном адресе HTTPS нужен: его даёт ваш обратный прокси (раздел 4). +- Но на публичном адресе HTTPS нужен: его даёт ваш обратный прокси (раздел 5). С аккаунтами это особенно важно: пароли и cookie входа не должны ходить по открытому каналу. Если `PUBLIC_URL` начинается с `https://`, cookie входа получает флаг `Secure`. ### Браузеры пользователей Современные: Chrome и Edge 111+, Firefox 113+, Safari 16.2+ (примерно 2023 год и новее). Для работы нужен включённый JavaScript и `localStorage`. ## 2. Что важно знать про данные -- Прогресс и статистика хранятся **в браузере пользователя** (`localStorage`), на сервер ничего не отправляется. Резервировать на сервере нечего. -- Данные привязаны к адресу сайта (схема + домен + порт). Если сменить домен, пользователи начнут «с нуля»; смена пути (`/trainer/`) прогресс не затрагивает. +- **Без входа (гость)** прогресс и статистика хранятся в браузере пользователя (`localStorage`), на сервер ничего не отправляется. Данные привязаны к адресу сайта (схема + домен + порт): при смене домена гость начинает «с нуля»; смена пути (`/trainer/`) прогресс не затрагивает. +- **После входа** прогресс, история упражнений и статистика клавиш копируются на сервер и подтягиваются на других устройствах. Настройки и раскладка Vial остаются на устройстве. +- **Что хранит сервер** (файл SQLite, в Docker это том `splitapi-data`): адрес почты, хеш пароля (scrypt, пароль в открытом виде не хранится), хеши токенов входа, подтверждения и сброса (сами токены не хранятся), JSON с прогрессом. Резервное копирование и удаление данных: раздел 4. - Файл раскладки Vial (.vil) читается локально в браузере и никуда не загружается. ## 3. Установка @@ -89,9 +93,120 @@ sudo ./deploy/install-debian.sh # вариант B Браузеры подхватят новую версию сами: сайт отдаётся с `Cache-Control: no-cache`, то есть при каждом открытии версия сверяется по ETag. -## 4. За обратным прокси +## 4. Аккаунты и почта -От прокси ничего особенного не требуется: сайт статический, без WebSocket, без нестандартных заголовков, все ссылки внутри относительные. Достаточно передать запрос на `127.0.0.1:8080`. +Аккаунты необязательны: без 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`: + +```bash +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` | `Сплит-тренер ` | от чьего имени уходят письма | +| `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: + +```bash +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: + +```bash +sudo python3 -c "import sqlite3; s=sqlite3.connect('/var/lib/split-trainer/splitapi.sqlite3'); d=sqlite3.connect('/root/backup.sqlite3'); s.backup(d)" +``` + +Удалить пользователя вручную (по просьбе, когда нет доступа к его паролю): в базе включите внешние ключи, иначе его сессии и прогресс останутся: + +```bash +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 @@ -167,16 +282,22 @@ example.com { Конфиг доверяет заголовку `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` и пересоберите или переустановите. -## 5. Что отдаёт сервер +## 6. Что отдаёт сервер -- `GET /healthz` отвечает `200 ok` (для мониторинга и healthcheck). +- `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 в ответах скрыта. -## 6. Если что-то не работает +## 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 или кеш на прокси, исключите сайт из кеширования | diff --git a/docs/ИНСТРУКЦИЯ.md b/docs/ИНСТРУКЦИЯ.md index 345c062..00cdbbf 100644 --- a/docs/ИНСТРУКЦИЯ.md +++ b/docs/ИНСТРУКЦИЯ.md @@ -108,6 +108,18 @@ | Прогресс пропал | Он хранится в браузере: проверьте, тем ли браузером и тем же файлом вы пользуетесь | | Нужно всё обнулить | «Статистика» → «Сбросить все данные» | -## 9. Файлы приложения +## 9. Аккаунт и синхронизация (если сайт установлен на сервере с API) + +Пункт **«Войти»** в меню появляется только на сервере, где установлен API. Без входа всё работает как раньше: прогресс хранится в этом браузере. + +- **Регистрация.** Введите только почту. Придёт письмо со ссылкой (действует сутки): по ней вы подтверждаете адрес и придумываете пароль, и только после этого создаётся аккаунт. Если письма нет, загляните в «Спам» и проверьте адрес. +- **Вход.** Почта и пароль. Прогресс с устройства объединяется с тем, что уже лежит на сервере, и после каждого упражнения отправляется туда. На другом устройстве достаточно войти, и всё на месте. +- **Забыли пароль.** «Забыли пароль?» → письмо со ссылкой (действует час, работает один раз) → новый пароль. Все прежние входы на устройствах завершатся. +- **Выход.** Прогресс остаётся на устройстве. Если потом в этом же браузере войдёт другой человек, ваши локальные данные в его аккаунт **не** попадут: они будут очищены. +- **Сброс данных.** «Статистика» → «Сбросить все данные» при входе очищает и копию на сервере. На других устройствах локальные данные при следующей синхронизации вернутся: очистите их и там. +- **Удаление аккаунта.** «Аккаунт» → «Удалить аккаунт…» (нужен пароль): удаляются аккаунт и прогресс на сервере. +- **Что синхронизируется.** Прогресс по урокам, история упражнений и статистика клавиш. Настройки и раскладка Vial остаются на устройстве. + +## 10. Файлы приложения В папке `public/`: `index.html` (запуск), `app.js` (логика), `vial.js` (чтение Vial и маршруты по слоям), `data.js` (раскладки, слова и тексты упражнений), `styles.css` (оформление). Слова и предложения для упражнений можно править в `data.js`. Установка на сервер описана в `docs/DEPLOY.md`. diff --git a/public/app.js b/public/app.js index 972769b..0af1f0d 100644 --- a/public/app.js +++ b/public/app.js @@ -31,7 +31,9 @@ const defaults = () => ({ }, progress: {}, history: [], keys: { ru: {}, en: {} }, // Раскладка Vial: { file, layout:{layers,rows,cols}, names:[], enabled, split, numLayer:'auto'|n, labelLang } - vial: null + vial: null, + // Аккаунт, под которым последний раз синхронизировали локальный прогресс (чтобы данные не переехали к другому человеку) + accountOwner: null }); function loadState() { const d = defaults(); @@ -43,6 +45,7 @@ function loadState() { d.progress = o.progress || {}; d.history = o.history || []; d.keys = Object.assign({ ru: {}, en: {} }, o.keys || {}); d.vial = o.vial && o.vial.layout ? o.vial : null; + d.accountOwner = typeof o.accountOwner === 'string' ? o.accountOwner : null; } } catch (e) { /* localStorage может быть недоступен - работаем без сохранения */ } return d; @@ -644,7 +647,8 @@ function show(v) { } function go(v) { show(v); - ({ lessons: renderLessons, practice: renderPractice, keyboard: renderKeyboardPage, stats: renderStats, settings: renderSettings })[v](); + ({ lessons: renderLessons, practice: renderPractice, keyboard: renderKeyboardPage, stats: renderStats, settings: renderSettings, + account: () => window.Auth.render() })[v](); } const seg = (opts, value, onchange) => @@ -1091,6 +1095,7 @@ function finish(now) { S.history.push({ t: Date.now(), id: run.lesson ? run.lesson.id : null, k: run.kind, lang: run.lang, cpm: +cpm.toFixed(1), acc: +acc.toFixed(1), err: run.errors, len: n, sec: +(ms / 1000).toFixed(1) }); if (S.history.length > 600) S.history.splice(0, S.history.length - 600); save(); + if (window.Auth) window.Auth.afterRun(); // если выполнен вход, прогресс уйдёт на сервер // Что делать по Enter const lessons = run.lesson ? LESSONS[run.lang] : null; @@ -1285,8 +1290,15 @@ function startWeak(lang) { startRun({ kind: 'weak', lang, title: 'Слабые клавиши', text: weakText(lang, weak), goal: 0 }); } -function resetData() { - if (!confirm('Удалить весь прогресс, историю и статистику? Это нельзя отменить.')) return; +async function resetData() { + const account = !!(window.Auth && window.Auth.isLoggedIn()); + const question = account + ? 'Удалить прогресс, историю и статистику на этом устройстве и в вашем аккаунте на сервере? Это нельзя отменить. ' + + 'На других устройствах, где вы входили, прогресс может вернуться при синхронизации: очистите его и там.' + : 'Удалить весь прогресс, историю и статистику? Это нельзя отменить.'; + if (!confirm(question)) return; + // Без очистки на сервере прогресс вернулся бы при ближайшей синхронизации + if (account && !(await window.Auth.clearServerProgress())) { toast('Не удалось очистить данные на сервере: попробуйте позже'); return; } const settings = S.settings, d = defaults(); S.progress = d.progress; S.history = d.history; S.keys = d.keys; S.settings = settings; save(); renderStats(); toast('Данные сброшены'); @@ -1421,6 +1433,17 @@ window.addEventListener('keydown', handleKey); applyTheme(); go('lessons'); +// Аккаунт появляется только там, где рядом есть API (на сервере); локально сайт работает как раньше +if (window.Auth) { + window.Auth.init({ + S, save, h, toast, go, + addNav: label => { NAV.push(['account', label]); renderNav(view === 'train' || view === 'result' ? 'lessons' : view); }, + setNavLabel: label => { const item = NAV.find(n => n[0] === 'account'); if (item) { item[1] = label; renderNav(view); } }, + isView: name => view === name, + refresh: () => { if (view === 'lessons') renderLessons(); else if (view === 'stats') renderStats(); }, + }); +} + // Для отладочных проверок из консоли window.__trainer = { LESSONS, LAYOUT, makeText, practiceText, weakChars, S, charOf, vialModel }; })(); diff --git a/public/auth.js b/public/auth.js new file mode 100644 index 0000000..8296d60 --- /dev/null +++ b/public/auth.js @@ -0,0 +1,399 @@ +/* Аккаунт: регистрация с подтверждением почты, вход, сброс пароля, удаление аккаунта и синхронизация прогресса. + Работает только если рядом есть API (/api/health). Без него сайт остаётся обычным локальным приложением. */ +(function () { + 'use strict'; + + const NO_CONNECTION = 'Нет связи с сервером. Проверьте подключение и повторите.'; + const UNEXPECTED = 'Что-то пошло не так. Обновите страницу и повторите.'; + const REQUEST_TIMEOUT_MS = 15000; + const SYNC_DELAY_MS = 2000; + const RETRY_FIRST_MS = 15000; + const RETRY_MAX_MS = 5 * 60 * 1000; + const MAX_SYNC_ATTEMPTS = 3; + const TOKEN_LINK = /^#(verify|reset)=([A-Za-z0-9_-]{16,100})$/; + + let ctx = null; // зависимости, которые передаёт app.js + const state = { + available: false, user: null, + mode: 'login', // login | register | forgot | reset | setpass | info + message: null, // { type: 'ok' | 'err', text } + email: '', busy: false, pending: null, confirmDelete: false, + syncing: false, syncText: '', + }; + let syncTimer = null, retryTimer = null, retryDelay = 0, dirty = false; + + // ---------- Запросы к API ---------- + async function call(method, path, body) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const opts = { method, cache: 'no-store', credentials: 'same-origin', signal: controller.signal, headers: { 'X-Requested-With': 'split-trainer' } }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + try { + const res = await fetch('api/' + path, opts); + let data = null; + try { data = await res.json(); } catch (e) { /* не JSON: например, страница ошибки от прокси */ } + return { ok: res.ok, status: res.status, data: data || {} }; + } finally { + clearTimeout(timer); + } + } + + const errText = r => r.data.message || `Не удалось выполнить запрос (код ${r.status}).`; + const isNetworkError = e => e instanceof TypeError || (e && e.name === 'AbortError'); + + // ---------- Ссылки из писем ---------- + function takeTokenFromLink() { + const m = TOKEN_LINK.exec(location.hash); + if (!m) return null; + // Токен сразу убираем из адресной строки и истории браузера + history.replaceState(null, '', location.pathname + location.search); + return { kind: m[1], token: m[2] }; + } + + function handleLink() { + const found = takeTokenFromLink(); + if (!found) return; + if (state.user) { // ссылка нужна гостю: при открытом входе она ничего бы не сделала + ctx.toast('Вы уже вошли в аккаунт. Чтобы воспользоваться ссылкой из письма, сначала выйдите.'); + return; + } + state.pending = found; + state.mode = found.kind === 'reset' ? 'reset' : 'setpass'; + state.message = null; + ctx.go('account'); + } + + // ---------- Данные устройства и аккаунта ---------- + const T = () => window.TrainerSync; + + /* Локальный прогресс принадлежит тому аккаунту, под которым его синхронизировали. Если на этом браузере входит другой + человек, чужие данные не должны уйти в его аккаунт. Данные гостя (владельца нет) объединяются с аккаунтом. */ + function adoptAccount(email) { + if (ctx.S.accountOwner && ctx.S.accountOwner !== email) { + ctx.S.progress = {}; + ctx.S.history = []; + ctx.S.keys = { ru: {}, en: {} }; + } + ctx.S.accountOwner = email; + ctx.save(); + } + + function applyLocal(merged) { + if (JSON.stringify(merged) === JSON.stringify(T().pick(ctx.S))) return; + ctx.S.progress = merged.progress; + ctx.S.history = merged.history; + ctx.S.keys = merged.keys; + ctx.save(); + ctx.refresh(); + } + + // ---------- Синхронизация ---------- + function setSync(text) { + state.syncText = text; + if (ctx.isView('account')) render(); + } + + function sessionLost() { + state.user = null; + state.mode = 'login'; + state.message = { type: 'err', text: 'Сессия истекла: войдите снова.' }; + updateNav(); + setSync(''); + } + + /* Cookie общая для всех вкладок: если в другой вкладке вошли в другой аккаунт, эта вкладка со своими данными в памяти + не должна ничего записывать на сервер. Перезагрузка подхватит актуальное состояние. */ + function accountSwitched() { + ctx.toast('В другой вкладке выполнен вход в другой аккаунт: обновляю страницу'); + setTimeout(() => location.reload(), 1200); + } + + function scheduleRetry() { + clearTimeout(retryTimer); + retryDelay = Math.min(retryDelay ? retryDelay * 2 : RETRY_FIRST_MS, RETRY_MAX_MS); + retryTimer = setTimeout(syncNow, retryDelay); + } + + async function syncNow() { + if (!state.user) return; + if (state.syncing) { dirty = true; return; } // изменения во время синхронизации: повторим сразу после неё + state.syncing = true; + dirty = false; + setSync('Синхронизация…'); + try { + for (let attempt = 0; attempt < MAX_SYNC_ATTEMPTS; attempt++) { + const got = await call('GET', 'state'); + if (got.status === 401) return sessionLost(); + if (!got.ok) throw new Error('state'); + if (got.data.email && got.data.email !== state.user) return accountSwitched(); // в другой вкладке вошли иначе + const remote = T().sanitize(got.data.data); + const merged = T().merge(T().pick(ctx.S), remote); + applyLocal(merged); + const upToDate = JSON.stringify(merged) === JSON.stringify(remote); + const put = upToDate ? { ok: true } : await call('PUT', 'state', { version: got.data.version, data: merged, email: state.user }); + if (put.ok) { + retryDelay = 0; + return setSync('Синхронизировано в ' + new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })); + } + if (put.status === 401) return sessionLost(); + if (put.data.error === 'account_mismatch') return accountSwitched(); + if (put.status !== 409) throw new Error('put'); // 409: параллельное изменение, повторяем со свежими данными + } + throw new Error('conflict'); + } catch (e) { + setSync('Не удалось синхронизировать. Прогресс сохранён на этом устройстве, повторим автоматически.'); + scheduleRetry(); + } finally { + state.syncing = false; + if (dirty) syncNow(); + } + } + + function afterRun() { + if (!state.user) return; + dirty = true; + clearTimeout(syncTimer); + syncTimer = setTimeout(syncNow, SYNC_DELAY_MS); + } + + /* Очистка прогресса на сервере (для «Сбросить все данные» при входе в аккаунт). Возвращает true при успехе. */ + async function clearServerProgress() { + if (!state.user) return true; + clearTimeout(syncTimer); // отложенная синхронизация вернула бы очищаемый прогресс обратно + dirty = false; + try { + for (let attempt = 0; attempt < MAX_SYNC_ATTEMPTS; attempt++) { + const got = await call('GET', 'state'); + if (!got.ok || (got.data.email && got.data.email !== state.user)) return false; + const put = await call('PUT', 'state', { version: got.data.version, data: {}, email: state.user }); + if (put.ok) return true; + if (put.status !== 409) return false; + } + } catch (e) { /* нет связи: сообщаем вызывающему */ } + return false; + } + + // ---------- Действия ---------- + const updateNav = () => ctx.setNavLabel(state.user ? 'Аккаунт' : 'Войти'); + + async function run(action) { + if (state.busy) return; + state.busy = true; + state.message = null; + render(); + try { + await action(); + } catch (e) { + if (!isNetworkError(e)) console.error(e); + state.message = { type: 'err', text: isNetworkError(e) ? NO_CONNECTION : UNEXPECTED }; + } + state.busy = false; + render(); + } + + const fail = r => { state.message = { type: 'err', text: errText(r) }; }; + const ok = text => { state.message = { type: 'ok', text }; }; + + const actions = { + login: (email, password) => run(async () => { + const r = await call('POST', 'login', { email, password }); + if (!r.ok) return fail(r); + state.user = r.data.email; + state.message = null; + adoptAccount(state.user); + updateNav(); + ctx.toast('Вы вошли в аккаунт'); + syncNow(); + }), + register: email => run(async () => { + const r = await call('POST', 'register', { email }); + if (!r.ok) return fail(r); + state.mode = 'info'; + ok(r.data.message); + }), + forgot: email => run(async () => { + const r = await call('POST', 'forgot', { email }); + if (!r.ok) return fail(r); + state.mode = 'info'; + ok(r.data.message); + }), + reset: password => run(async () => { + const r = await call('POST', 'reset', { token: state.pending.token, password }); + if (r.ok) { + state.pending = null; + state.mode = 'login'; + ok(r.data.message); + } else { + if (r.data.error === 'invalid_token') { state.pending = null; state.mode = 'forgot'; } + fail(r); + } + }), + setPassword: password => run(async () => { + const r = await call('POST', 'verify', { token: state.pending.token, password }); + if (r.ok) { + state.pending = null; + state.mode = 'login'; + ok(r.data.message); + } else { + if (r.data.error === 'invalid_token') { state.pending = null; state.mode = 'register'; } + fail(r); + } + }), + logout: () => run(async () => { + const r = await call('POST', 'logout', {}); + if (!r.ok) return fail(r); // иначе интерфейс сказал бы «вышли», а cookie осталась бы жива + state.user = null; + state.mode = 'login'; + ok('Вы вышли из аккаунта. Прогресс остался на этом устройстве.'); + updateNav(); + setSync(''); + }), + deleteAccount: password => run(async () => { + const r = await call('POST', 'delete-account', { password }); + if (!r.ok) return fail(r); + state.user = null; + state.confirmDelete = false; + state.mode = 'login'; + ctx.S.accountOwner = null; // локальные данные снова ничьи (гостевые) + ctx.save(); + ok(r.data.message + ' Прогресс на этом устройстве сохранён: удалить его можно в разделе «Статистика».'); + updateNav(); + setSync(''); + }), + }; + + // ---------- Экран ---------- + const mode = m => { state.mode = m; state.message = null; render(); }; + const h = (...args) => ctx.h(...args); + + function messageBox() { + return state.message ? h('div', { class: 'msg ' + state.message.type, role: state.message.type === 'err' ? 'alert' : 'status' }, state.message.text) : null; + } + + function emailField(autocomplete) { + return h('div', { class: 'field' }, h('label', { for: 'au-email' }, 'Почта'), + h('input', { id: 'au-email', class: 'text', type: 'email', required: true, maxlength: 254, autocomplete, value: state.email, + oninput: e => { state.email = e.target.value; } })); + } + + function passwordField(label, autocomplete, id) { + return h('div', { class: 'field' }, h('label', { for: id }, label), + h('input', { id, class: 'text', type: 'password', required: true, minlength: 8, maxlength: 128, autocomplete })); + } + + const linkButton = (text, onclick) => h('button', { type: 'button', class: 'linkbtn', onclick }, text); + const submitButton = text => h('button', { type: 'submit', class: 'btn primary', disabled: state.busy }, state.busy ? 'Подождите…' : text); + const form = (onsubmit, ...kids) => h('form', { class: 'form', onsubmit: e => { e.preventDefault(); onsubmit(e.target); } }, ...kids); + const valueOf = (f, id) => f.querySelector('#' + id).value; + + /* Форма «новый пароль» для сброса и для первого пароля после подтверждения почты */ + function newPasswordForm(title, hint, submitText, action) { + return [h('h2', null, title), hint ? h('p', { class: 'muted small' }, hint) : null, + form(f => { + const password = valueOf(f, 'au-pass'); + if (password !== valueOf(f, 'au-pass2')) { state.message = { type: 'err', text: 'Пароли не совпадают.' }; return render(); } + action(password); + }, passwordField('Пароль (не короче 8 символов)', 'new-password', 'au-pass'), passwordField('Повторите пароль', 'new-password', 'au-pass2'), + submitButton(submitText))].filter(Boolean); + } + + function guestPanel() { + const box = h('div', { class: 'panel authbox' }); + if (state.mode === 'login' || state.mode === 'register') { + box.append(h('div', { class: 'seg', role: 'group', 'aria-label': 'Вход или регистрация' }, + ['login', 'register'].map(m => h('button', { type: 'button', class: state.mode === m ? 'on' : '', 'aria-pressed': String(state.mode === m), onclick: () => mode(m) }, + m === 'login' ? 'Вход' : 'Регистрация')))); + } + const message = messageBox(); + if (message) box.append(message); // append(null) напечатал бы слово «null» + + if (state.mode === 'login') { + box.append(form(f => actions.login(valueOf(f, 'au-email'), valueOf(f, 'au-pass')), + emailField('username'), passwordField('Пароль', 'current-password', 'au-pass'), + h('div', { class: 'row' }, submitButton('Войти'), linkButton('Забыли пароль?', () => mode('forgot'))))); + } else if (state.mode === 'register') { + box.append(form(f => actions.register(valueOf(f, 'au-email')), + emailField('username'), + h('p', { class: 'muted small' }, 'Мы отправим письмо со ссылкой. По ней вы подтвердите адрес и задайте пароль: до этого аккаунта нет.'), + submitButton('Получить ссылку'))); + } else if (state.mode === 'forgot') { + box.append(h('h2', null, 'Сброс пароля'), form(f => actions.forgot(valueOf(f, 'au-email')), + emailField('username'), h('p', { class: 'muted small' }, 'Пришлём ссылку для нового пароля, если такой адрес зарегистрирован.'), + h('div', { class: 'row' }, submitButton('Отправить ссылку'), linkButton('Назад ко входу', () => mode('login'))))); + } else if (state.mode === 'reset') { + box.append(...newPasswordForm('Новый пароль', null, 'Сохранить пароль', actions.reset)); + } else if (state.mode === 'setpass') { + box.append(...newPasswordForm('Подтверждение почты', 'Адрес подтверждён. Придумайте пароль: после этого аккаунт будет создан.', 'Создать аккаунт', actions.setPassword)); + } else { + box.append(h('div', { class: 'row' }, linkButton('← Ко входу', () => mode('login')))); + } + return box; + } + + function deleteForm() { + return h('div', { class: 'panel authbox danger-zone' }, messageBox(), h('h2', null, 'Удалить аккаунт'), + h('p', null, 'Будут удалены аккаунт и прогресс на сервере, все входы завершатся. Это нельзя отменить. На других устройствах локальные данные останутся.'), + form(f => actions.deleteAccount(valueOf(f, 'au-del-pass')), passwordField('Введите пароль для подтверждения', 'current-password', 'au-del-pass'), + h('div', { class: 'row' }, h('button', { type: 'submit', class: 'btn danger', disabled: state.busy }, 'Удалить навсегда'), + linkButton('Отмена', () => { state.confirmDelete = false; state.message = null; render(); })))); + } + + function accountPanel() { + if (state.confirmDelete) return deleteForm(); + return h('div', { class: 'panel authbox' }, messageBox(), + h('div', null, h('b', null, state.user), h('div', { class: 'muted small' }, 'Вы вошли в аккаунт')), + h('p', null, 'Прогресс, история и статистика клавиш сохраняются на сервере и доступны на любом устройстве после входа. Настройки и раскладка Vial остаются на этом устройстве.'), + h('div', { class: 'muted small', role: 'status' }, state.syncText || 'Синхронизация ещё не выполнялась.'), + h('div', { class: 'row' }, + h('button', { class: 'btn', type: 'button', disabled: state.syncing, onclick: syncNow }, 'Синхронизировать сейчас'), + h('button', { class: 'btn', type: 'button', disabled: state.busy, onclick: actions.logout }, 'Выйти'), + linkButton('Удалить аккаунт…', () => { state.confirmDelete = true; state.message = null; render(); }))); + } + + function render() { + const root = document.getElementById('v-account'); + if (!root) return; + root.innerHTML = ''; + const subtitle = state.user ? 'Прогресс сохраняется на сервере и доступен на других устройствах.' + : 'Войдите, чтобы хранить прогресс на сервере. Без входа всё работает как раньше: данные остаются в этом браузере.'; + root.append(h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Аккаунт'), h('p', { class: 'muted' }, subtitle)))); + const panel = state.user ? accountPanel() : guestPanel(); + root.append(...[].concat(panel)); + if (state.message && state.message.type === 'err' && !state.busy) { + const first = root.querySelector('input'); + if (first) first.focus(); // после ошибки возвращаем фокус в форму, а не на начало страницы + } + } + + // ---------- Запуск ---------- + async function init(context) { + ctx = context; + try { + const health = await call('GET', 'health'); + state.available = health.ok && health.data.ok === true; + } catch (e) { + state.available = false; + } + if (!state.available) return false; + + try { + const me = await call('GET', 'me'); + state.user = me.ok ? me.data.email : null; + } catch (e) { /* остаёмся гостем */ } + + ctx.addNav(state.user ? 'Аккаунт' : 'Войти'); + window.addEventListener('hashchange', handleLink); // ссылка из письма, вставленная в уже открытую вкладку + window.addEventListener('online', () => syncNow()); + handleLink(); + if (state.user) { + adoptAccount(state.user); + syncNow(); + } + return true; + } + + window.Auth = { init, render, afterRun, syncNow, clearServerProgress, isLoggedIn: () => !!state.user }; +})(); diff --git a/public/index.html b/public/index.html index 5a09376..97d4d2f 100644 --- a/public/index.html +++ b/public/index.html @@ -20,6 +20,7 @@
+
@@ -30,6 +31,8 @@ + + diff --git a/public/styles.css b/public/styles.css index 4e23ba8..7f4c1d4 100644 --- a/public/styles.css +++ b/public/styles.css @@ -301,3 +301,17 @@ td.r, th.r { text-align: right; } .nm input { font: inherit; color: var(--fg); background: var(--panel2); border: 1px solid var(--line); border-radius: 8px; padding: 5px 9px; width: 150px; } .card.locked { opacity: .55; } .sec-head .btn { padding: 3px 12px; font-size: 13px; } + +/* ---------- Аккаунт ---------- */ +.authbox { max-width: 520px; display: grid; gap: 16px; } +.authbox .form { max-width: none; } +.text { + width: 100%; font: inherit; color: var(--fg); background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; +} +.text:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.msg { padding: 10px 14px; border-radius: 10px; font-weight: 550; border: 1px solid transparent; } +.msg.ok { background: color-mix(in srgb, var(--ok) 16%, transparent); border-color: var(--ok); } +.msg.err { background: color-mix(in srgb, var(--bad) 16%, transparent); border-color: var(--bad); } +.linkbtn { border: 0; background: none; padding: 0; color: var(--accent); font: inherit; font-weight: 600; cursor: pointer; text-decoration: underline; } +.linkbtn:hover { filter: brightness(1.15); } +.danger-zone { border-color: var(--bad); } diff --git a/public/sync.js b/public/sync.js new file mode 100644 index 0000000..1f99c8b --- /dev/null +++ b/public/sync.js @@ -0,0 +1,104 @@ +/* Очистка и слияние прогресса для синхронизации между устройствами. + Чистые функции без обращения к DOM: их можно проверять в Node (tests/sync.test.js). + Слияние коммутативно и идемпотентно: порядок синхронизаций не важен, повтор ничего не портит. + Ограничения совпадают с серверными (server/splitapi/statevalid.py): расхождение дало бы вечные «изменения» при синхронизации. */ +(function (root) { + 'use strict'; + + const MAX_HISTORY = 600, MAX_PROGRESS = 200, MAX_KEYS = 200, ID_MAX = 40; + const LANGS = ['ru', 'en']; + const KINDS = ['lesson', 'practice', 'weak']; + + const num = (v, lo, hi) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : 0); + const ident = v => (typeof v === 'string' ? v.replace(/[^A-Za-z0-9_.-]/g, '').slice(0, ID_MAX) : ''); + const choice = (v, allowed) => (allowed.includes(v) ? v : ''); + const isObj = v => v !== null && typeof v === 'object' && !Array.isArray(v); + const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + + // Одна клавиша = один символ; одиночный суррогат не кодируется в UTF-8, сервер такие ключи отбрасывает, и мы тоже + const isKeyChar = ch => [...ch].length === 1 && !/^[\ud800-\udfff]$/.test(ch); + + const emptyState = () => ({ progress: {}, history: [], keys: { ru: {}, en: {} } }); + + const cleanHistoryRow = r => ({ + t: num(r.t, 0, 4e12), id: ident(r.id), k: choice(r.k, KINDS), lang: choice(r.lang, LANGS), + cpm: num(r.cpm, 0, 3000), acc: num(r.acc, 0, 100), err: num(r.err, 0, 1e5), len: num(r.len, 0, 1e5), sec: num(r.sec, 0, 1e6), + }); + const cleanProgressRow = r => ({ + best: num(r.best, 0, 3000), acc: num(r.acc, 0, 100), stars: Math.trunc(num(r.stars, 0, 3)), tries: Math.trunc(num(r.tries, 0, 1e5)), + }); + const cleanKeyRow = r => ({ n: num(r.n, 0, 1e9), err: num(r.err, 0, 1e9), ms: num(r.ms, 0, 1e9), ln: num(r.ln, 0, 1e9) }); + + /* Возвращает очищенную копию: известные поля, числа в диапазонах, ограниченные размеры. Мусор даёт пустое состояние. */ + function sanitize(raw) { + const out = emptyState(); + if (!isObj(raw)) return out; + + if (isObj(raw.progress)) { + for (const id of Object.keys(raw.progress).sort(byCodepoint)) { + const clean = ident(id); + // __proto__ как ключ подменил бы прототип таблицы, а не создал запись + if (clean && clean !== '__proto__' && isObj(raw.progress[id]) && Object.keys(out.progress).length < MAX_PROGRESS) { + out.progress[clean] = cleanProgressRow(raw.progress[id]); + } + } + } + if (Array.isArray(raw.history)) { + out.history = raw.history.filter(isObj).map(cleanHistoryRow).slice(-MAX_HISTORY); + } + if (isObj(raw.keys)) { + for (const lang of LANGS) { + const src = raw.keys[lang]; + if (!isObj(src)) continue; + for (const ch of Object.keys(src).sort(byCodepoint)) { + if (isKeyChar(ch) && isObj(src[ch]) && Object.keys(out.keys[lang]).length < MAX_KEYS) { + out.keys[lang][ch] = cleanKeyRow(src[ch]); + } + } + } + } + return out; + } + + const historyKey = r => `${r.t}|${r.id}|${r.lang}|${r.k}`; + + /* Из двух записей статистики клавиши выбираем ту, где больше нажатий (сложение удваивало бы данные при повторной синхронизации). */ + function betterKey(a, b) { + const ta = [a.n + a.err, a.ms, a.ln, a.err], tb = [b.n + b.err, b.ms, b.ln, b.err]; + for (let i = 0; i < ta.length; i++) if (ta[i] !== tb[i]) return ta[i] > tb[i] ? a : b; + return a; + } + + function merge(first, second) { + const a = sanitize(first), b = sanitize(second), out = emptyState(); + + for (const id of [...new Set([...Object.keys(a.progress), ...Object.keys(b.progress)])].sort(byCodepoint)) { + const x = a.progress[id], y = b.progress[id]; + out.progress[id] = !x ? y : !y ? x : { + best: Math.max(x.best, y.best), acc: Math.max(x.acc, y.acc), stars: Math.max(x.stars, y.stars), tries: Math.max(x.tries, y.tries), + }; + } + + const rows = new Map(); + for (const r of [...a.history, ...b.history]) rows.set(historyKey(r), r); + out.history = [...rows.entries()] + .sort((p, q) => p[1].t - q[1].t || byCodepoint(p[0], q[0])) + .map(e => e[1]) + .slice(-MAX_HISTORY); + + for (const lang of LANGS) { + for (const ch of [...new Set([...Object.keys(a.keys[lang]), ...Object.keys(b.keys[lang])])].sort(byCodepoint)) { + const x = a.keys[lang][ch], y = b.keys[lang][ch]; + out.keys[lang][ch] = !x ? y : !y ? x : betterKey(x, y); + } + } + return out; + } + + /* Часть состояния приложения, которая хранится на сервере (настройки и раскладка Vial остаются на устройстве). */ + const pick = S => sanitize({ progress: S.progress, history: S.history, keys: S.keys }); + + const api = { emptyState, sanitize, merge, pick }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.TrainerSync = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/server/splitapi/__init__.py b/server/splitapi/__init__.py new file mode 100644 index 0000000..d90d743 --- /dev/null +++ b/server/splitapi/__init__.py @@ -0,0 +1 @@ +"""Минимальный API авторизации для Сплит-тренера (только стандартная библиотека Python).""" diff --git a/server/splitapi/__main__.py b/server/splitapi/__main__.py new file mode 100644 index 0000000..5bdbf3c --- /dev/null +++ b/server/splitapi/__main__.py @@ -0,0 +1,69 @@ +"""Запуск: python -m splitapi (настройки из переменных окружения, см. docs/DEPLOY.md).""" +from __future__ import annotations + +import logging +import os +import signal +import sys +import threading +import urllib.request + +from . import security +from .config import ConfigError, from_env, is_local_host +from .http_api import make_server +from .mailer import AsyncMailer, make_mailer +from .ratelimit import RateLimiter +from .service import Service +from .store import Store + +log = logging.getLogger("splitapi") + + +def healthcheck() -> int: + """Для HEALTHCHECK контейнера: в образе нет curl, поэтому проверяем сами.""" + port = os.environ.get("PORT") or "8000" + try: + with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=3) as response: + return 0 if response.status == 200 else 1 + except OSError: + return 1 + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + if "--healthcheck" in sys.argv: + return healthcheck() + try: + cfg = from_env(os.environ) + except ConfigError as exc: + log.critical("Неверные настройки: %s", exc) + return 2 + + if cfg.mail_backend == "console": + log.warning("SMTP не настроен: письма (со ссылками подтверждения и сброса) будут печататься в этот лог. Только для разработки!") + elif cfg.smtp_security == "none" and not is_local_host(cfg.smtp_host): + log.warning("SMTP_SECURITY=none: письма и пароль SMTP уходят на %s без шифрования. Используйте starttls или ssl.", cfg.smtp_host) + security.verify_dummy("прогрев") # первый вход с несуществующей почтой не должен выдавать себя лишней задержкой + mailer = make_mailer(cfg) + service = Service(cfg, Store(cfg.db_path), mailer, RateLimiter()) + server = make_server(cfg, service) + + def stop(signum: int, _frame: object) -> None: + log.info("получен сигнал %s, останавливаюсь", signum) + threading.Thread(target=server.shutdown, daemon=True).start() + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + log.info("API слушает %s:%s, сайт: %s", cfg.bind, cfg.port, cfg.public_url) + try: + server.serve_forever() + finally: + server.server_close() + if isinstance(mailer, AsyncMailer): + mailer.flush(timeout=5) + mailer.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/splitapi/config.py b/server/splitapi/config.py new file mode 100644 index 0000000..964b96e --- /dev/null +++ b/server/splitapi/config.py @@ -0,0 +1,166 @@ +"""Настройки сервиса из переменных окружения.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping +from urllib.parse import urlsplit + +_TRUE = {"1", "true", "yes", "on"} +_FALSE = {"0", "false", "no", "off"} +_SMTP_PORTS = {"starttls": 587, "ssl": 465, "none": 25} +_DEFAULT_PORTS = {"http": 80, "https": 443} +_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + + +class ConfigError(ValueError): + """Настройки заданы неверно: сервис не должен стартовать.""" + + +@dataclass(frozen=True) +class Config: + public_url: str # внешний адрес сайта: из него строятся ссылки в письмах и проверяется Origin + db_path: str = "/data/splitapi.sqlite3" + bind: str = "127.0.0.1" + port: int = 8000 + mail_backend: str = "console" # console | smtp + mail_from: str = "" + smtp_host: str = "" + smtp_port: int = 587 + smtp_security: str = "starttls" # starttls | ssl | none + smtp_user: str = "" + smtp_password: str = "" + allow_registration: bool = True + trust_proxy: bool = False # доверять X-Real-IP (его выставляет только наш nginx) + session_days: int = 30 + verify_ttl: int = 24 * 3600 + reset_ttl: int = 3600 + + @property + def _parts(self): + return urlsplit(self.public_url) + + @property + def origin(self) -> str: + return f"{self._parts.scheme}://{self._parts.netloc}" + + @property + def base_url(self) -> str: + return self.public_url.rstrip("/") + "/" + + @property + def secure_cookie(self) -> bool: + return self._parts.scheme == "https" + + @property + def cookie_name(self) -> str: + # Префикс __Host- (только при https): браузер не примет такую cookie с соседнего поддомена и без Secure + return "__Host-session" if self.secure_cookie else "session" + + @property + def session_seconds(self) -> int: + return self.session_days * 86400 + + +def _bool(env: Mapping[str, str], key: str, default: bool) -> bool: + raw = (env.get(key) or "").strip().lower() + if not raw: + return default + if raw in _TRUE: + return True + if raw in _FALSE: + return False + raise ConfigError(f"{key}: ожидалось true/false, получено {raw!r}") + + +def _int(env: Mapping[str, str], key: str, default: int, low: int, high: int) -> int: + raw = (env.get(key) or "").strip() + if not raw: + return default + try: + value = int(raw) + except ValueError: + raise ConfigError(f"{key}: ожидалось целое число") from None + if not low <= value <= high: + raise ConfigError(f"{key}: допустимы значения от {low} до {high}") + return value + + +def _public_url(raw: str) -> str: + """Приводит адрес к виду, в котором браузер присылает Origin: схема и хост в нижнем регистре, без порта по умолчанию.""" + parts = urlsplit(raw.strip()) + if parts.scheme not in ("http", "https") or not parts.hostname or parts.query or parts.fragment or parts.username or parts.password: + raise ConfigError("PUBLIC_URL: нужен адрес вида https://example.com или https://example.com/путь, без ?, # и имени пользователя") + try: + port = parts.port + except ValueError: + raise ConfigError("PUBLIC_URL: некорректный порт") from None + try: + hostname = parts.hostname.encode("idna").decode("ascii") # браузер присылает Origin с punycode + except UnicodeError: + raise ConfigError("PUBLIC_URL: некорректное имя домена") from None + host = f"[{hostname}]" if ":" in hostname else hostname + netloc = host if port in (None, _DEFAULT_PORTS[parts.scheme]) else f"{host}:{port}" + return f"{parts.scheme}://{netloc}{parts.path.rstrip('/')}" + + +def is_local_host(hostname: str) -> bool: + return hostname in _LOCAL_HOSTS or hostname.endswith(".localhost") + + +def _mail_backend(env: Mapping[str, str], smtp_host: str, hostname: str) -> str: + explicit = (env.get("MAIL_BACKEND") or "").strip().lower() + if explicit: + if explicit not in ("console", "smtp"): + raise ConfigError("MAIL_BACKEND: допустимы console или smtp") + if explicit == "smtp" and not smtp_host: + raise ConfigError("MAIL_BACKEND=smtp требует SMTP_HOST") + return explicit + if smtp_host: + return "smtp" + if is_local_host(hostname): + return "console" # локальная проверка: письма печатаются в лог + raise ConfigError( + "SMTP не настроен: задайте SMTP_HOST (и SMTP_USER/SMTP_PASSWORD) либо явно MAIL_BACKEND=console. " + "Режим console только для разработки: ссылки подтверждения и сброса пароля тогда печатаются в лог, " + "и любой, кто читает логи, может захватить аккаунт." + ) + + +def _smtp_password(env: Mapping[str, str]) -> str: + path = (env.get("SMTP_PASSWORD_FILE") or "").strip() + if not path: + return env.get("SMTP_PASSWORD", "") + try: + with open(path, encoding="utf-8") as handle: + return handle.read().rstrip("\r\n") + except OSError as exc: + raise ConfigError(f"SMTP_PASSWORD_FILE: не удалось прочитать файл ({exc.strerror})") from None + + +def from_env(env: Mapping[str, str]) -> Config: + public_url = _public_url(env.get("PUBLIC_URL") or "") + hostname = urlsplit(public_url).hostname or "localhost" + + smtp_host = (env.get("SMTP_HOST") or "").strip() + backend = _mail_backend(env, smtp_host, hostname) + + security = (env.get("SMTP_SECURITY") or "starttls").strip().lower() + if security not in _SMTP_PORTS: + raise ConfigError("SMTP_SECURITY: допустимы starttls, ssl или none") + + return Config( + public_url=public_url, + db_path=env.get("DB_PATH") or "/data/splitapi.sqlite3", + bind=env.get("BIND") or "127.0.0.1", + port=_int(env, "PORT", 8000, 1, 65535), + mail_backend=backend, + mail_from=(env.get("MAIL_FROM") or "").strip() or f"Сплит-тренер ", + smtp_host=smtp_host, + smtp_port=_int(env, "SMTP_PORT", _SMTP_PORTS[security], 1, 65535), + smtp_security=security, + smtp_user=env.get("SMTP_USER", ""), + smtp_password=_smtp_password(env), + allow_registration=_bool(env, "ALLOW_REGISTRATION", True), + trust_proxy=_bool(env, "TRUST_PROXY", False), + session_days=_int(env, "SESSION_DAYS", 30, 1, 365), + ) diff --git a/server/splitapi/http_api.py b/server/splitapi/http_api.py new file mode 100644 index 0000000..7fb90f6 --- /dev/null +++ b/server/splitapi/http_api.py @@ -0,0 +1,241 @@ +"""HTTP-слой: маршруты /api/*, JSON, cookie сессии, защита от CSRF. Только стандартная библиотека.""" +from __future__ import annotations + +import ipaddress +import json +import logging +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlsplit + +from . import security +from .config import Config +from .mailer import MailQueueFull +from .service import MESSAGES, ApiError, Service + +log = logging.getLogger("splitapi") +access = logging.getLogger("splitapi.access") + +MAX_BODY = 300_000 +CSRF_HEADER = "X-Requested-With" +CSRF_VALUE = "split-trainer" +BUSY_RETRY_SECONDS = 5 + +# путь -> {метод: имя обработчика} +ROUTES: dict[str, dict[str, str]] = { + "/api/health": {"GET": "health"}, + "/api/register": {"POST": "register"}, + "/api/verify": {"POST": "verify"}, + "/api/login": {"POST": "login"}, + "/api/logout": {"POST": "logout"}, + "/api/me": {"GET": "me"}, + "/api/forgot": {"POST": "forgot"}, + "/api/reset": {"POST": "reset"}, + "/api/delete-account": {"POST": "delete_account"}, + "/api/state": {"GET": "get_state", "PUT": "put_state"}, +} + +# Откуда принимаем X-Real-IP: тот же список, что в set_real_ip_from у nginx (частные сети и localhost) +_TRUSTED_PEERS = tuple( + ipaddress.ip_network(net) + for net in ("127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "fc00::/7", "fe80::/10") +) + + +def _parse_ip(raw: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: + try: + ip = ipaddress.ip_address(raw) + except ValueError: + return None + return ip.ipv4_mapped if ip.version == 6 and ip.ipv4_mapped else ip + + +def is_trusted_proxy_peer(address: str) -> bool: + ip = _parse_ip(address) + return ip is not None and any(ip in net for net in _TRUSTED_PEERS) + + +def normalize_ip(raw: str) -> str | None: + """IPv4 как есть; IPv6 сводим к сети /64: у одного абонента их миллиарды, поэтому счётчик общий.""" + ip = _parse_ip(raw) + if ip is None: + return None + if ip.version == 6: + return str(ipaddress.ip_network(f"{ip}/64", strict=False)) + return str(ip) + + +def _forbid_constant(name: str) -> None: + raise ValueError(f"недопустимая константа {name}") + + +def make_handler(cfg: Config, service: Service) -> type[BaseHTTPRequestHandler]: + class Handler(BaseHTTPRequestHandler): + server_version = "splitapi" + sys_version = "" + timeout = 10 # защита от медленных соединений (основную работу делает nginx) + + # --- вспомогательное --- + def log_message(self, format: str, *args: object) -> None: # noqa: A002 - сигнатура базового класса + pass + + def client_ip(self) -> str: + peer = self.client_address[0] + chosen = peer + if cfg.trust_proxy and is_trusted_proxy_peer(peer): # заголовку верим только от своего прокси + candidate = (self.headers.get("X-Real-IP") or "").strip() + if normalize_ip(candidate): + chosen = candidate + return normalize_ip(chosen) or peer + + def session_token(self) -> str | None: + """Токен из cookie. Если cookie с таким именем несколько, не верим ни одной (подброс cookie с поддомена).""" + values = [] + for part in (self.headers.get("Cookie") or "").split(";"): + name, _, value = part.strip().partition("=") + if name == cfg.cookie_name: + values.append(value.strip()) + return values[0] if len(values) == 1 else None + + def cookie_header(self, token: str, max_age: int) -> str: + parts = [f"{cfg.cookie_name}={token}", f"Max-Age={max_age}", "Path=/", "HttpOnly", "SameSite=Lax"] + if cfg.secure_cookie: + parts.append("Secure") + return "; ".join(parts) + + def send_json(self, status: int, body: dict, headers: dict[str, str] | None = None, cookies: list[str] | None = None) -> None: + payload = json.dumps(body, ensure_ascii=False).encode("utf-8") + try: + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + for name, value in (headers or {}).items(): + self.send_header(name, value) + for cookie in cookies or []: + self.send_header("Set-Cookie", cookie) + self.end_headers() + self.wfile.write(payload) + except OSError: + self.close_connection = True # клиент ушёл, не дождавшись ответа: это не ошибка сервера + return + access.info("%s %s %r -> %s", self.client_ip(), self.command, urlsplit(self.path).path, status) + + def error(self, err: ApiError) -> None: + headers = {"Retry-After": str(err.retry_after)} if err.retry_after else None + self.send_json(err.status, {"error": err.code, "message": err.message, **err.extra}, headers) + + # --- проверки запроса --- + def check_csrf(self) -> None: + origin = self.headers.get("Origin") + if origin is not None and origin != cfg.origin: + log.warning("запрос отклонён: Origin %r, ожидался %r (проверьте PUBLIC_URL)", origin, cfg.origin) + raise ApiError(403, "csrf") + if self.headers.get(CSRF_HEADER) != CSRF_VALUE: + raise ApiError(403, "csrf") + + def read_json(self) -> dict: + content_type = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower() + if content_type != "application/json": + raise ApiError(415, "unsupported_media_type") + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + raise ApiError(400, "invalid_json") from None + if length < 0: + raise ApiError(400, "invalid_json") + if length > MAX_BODY: + raise ApiError(413, "payload_too_large") + raw = self.rfile.read(length) if length else b"" + try: + data = json.loads(raw.decode("utf-8"), parse_constant=_forbid_constant) + except (ValueError, RecursionError): + raise ApiError(400, "invalid_json") from None + if not isinstance(data, dict): + raise ApiError(400, "invalid_json") + return data + + def require_user(self) -> dict: + user = service.me(self.session_token()) + if user is None: + raise ApiError(401, "unauthorized") + return user + + # --- обработчики маршрутов --- + def h_health(self, body: dict | None) -> None: + self.send_json(200, {"ok": True}) + + def h_register(self, body: dict) -> None: + self.send_json(200, service.register(body.get("email"), self.client_ip())) + + def h_verify(self, body: dict) -> None: + self.send_json(200, service.verify(body.get("token"), body.get("password"), self.client_ip())) + + def h_login(self, body: dict) -> None: + token, email = service.login(body.get("email"), body.get("password"), self.client_ip()) + self.send_json(200, {"ok": True, "email": email}, cookies=[self.cookie_header(token, cfg.session_seconds)]) + + def h_logout(self, body: dict) -> None: + service.logout(self.session_token()) + self.send_json(200, {"ok": True}, cookies=[self.cookie_header("", 0)]) + + def h_me(self, body: dict | None) -> None: + # Гостю тоже 200 (email: null): иначе каждый заход анонима оставляет «ошибку» в консоли браузера + user = service.me(self.session_token()) + self.send_json(200, {"email": user["email"] if user else None}) + + def h_forgot(self, body: dict) -> None: + self.send_json(200, service.forgot(body.get("email"), self.client_ip())) + + def h_reset(self, body: dict) -> None: + self.send_json(200, service.reset(body.get("token"), body.get("password"), self.client_ip())) + + def h_delete_account(self, body: dict) -> None: + result = service.delete_account(self.session_token(), body.get("password")) + self.send_json(200, result, cookies=[self.cookie_header("", 0)]) + + def h_get_state(self, body: dict | None) -> None: + user = self.require_user() + self.send_json(200, {**service.get_state(user["id"]), "email": user["email"]}) + + def h_put_state(self, body: dict) -> None: + user = self.require_user() + self.send_json(200, service.put_state(user["id"], body.get("version"), body.get("data"), user["email"], body.get("email"))) + + # --- маршрутизация --- + def dispatch(self) -> None: + self.close_connection = True + path = urlsplit(self.path).path + try: + methods = ROUTES.get(path) + if methods is None: + raise ApiError(404, "not_found") + if self.command not in methods: + self.send_json(405, {"error": "method_not_allowed", "message": MESSAGES["method_not_allowed"]}, + {"Allow": ", ".join(methods)}) + return + body = None + if self.command != "GET": + self.check_csrf() + body = self.read_json() + getattr(self, "h_" + methods[self.command])(body) + except ApiError as err: + self.error(err) + except (security.HashBusy, MailQueueFull): + self.error(ApiError(503, "busy", retry_after=BUSY_RETRY_SECONDS)) + except (TimeoutError, ConnectionError): + log.debug("соединение оборвано клиентом: %s %r", self.command, path) + except Exception: + log.exception("необработанная ошибка в %s %r", self.command, path) + self.send_json(500, {"error": "server_error", "message": MESSAGES["server_error"]}) + + do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = dispatch + + return Handler + + +def make_server(cfg: Config, service: Service, host: str | None = None, port: int | None = None) -> ThreadingHTTPServer: + server = ThreadingHTTPServer((host or cfg.bind, cfg.port if port is None else port), make_handler(cfg, service)) + server.daemon_threads = True # не держим процесс из-за зависших соединений при остановке + return server diff --git a/server/splitapi/mailer.py b/server/splitapi/mailer.py new file mode 100644 index 0000000..e8a264e --- /dev/null +++ b/server/splitapi/mailer.py @@ -0,0 +1,188 @@ +"""Отправка писем: SMTP, вывод в лог (для разработки), память (для тестов), фоновая очередь.""" +from __future__ import annotations + +import logging +import queue +import smtplib +import ssl +import threading +import time +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formatdate, make_msgid, parseaddr +from typing import Protocol + +from .config import Config + +log = logging.getLogger("splitapi.mail") + + +@dataclass(frozen=True) +class Mail: + to: str + subject: str + body: str + + +class Mailer(Protocol): + def send(self, mail: Mail) -> None: ... + + +class MailQueueFull(RuntimeError): + """Очередь отправки переполнена: письмо не принято (API ответит «сервер занят», а не «проверьте почту»).""" + + +def _plural(n: int, one: str, few: str, many: str) -> str: + if 11 <= n % 100 <= 14: + return many + return one if n % 10 == 1 else few if 2 <= n % 10 <= 4 else many + + +def human_duration(seconds: int) -> str: + """«24 часа», «3 дня», «30 минут»: срок действия ссылки в тексте письма берётся из настроек.""" + if seconds >= 2 * 86400 and seconds % 86400 == 0: + n = seconds // 86400 + return f"{n} {_plural(n, 'день', 'дня', 'дней')}" + if seconds >= 3600 and seconds % 3600 == 0: + n = seconds // 3600 + return f"{n} {_plural(n, 'час', 'часа', 'часов')}" + n = max(1, round(seconds / 60)) + return f"{n} {_plural(n, 'минута', 'минуты', 'минут')}" + + +# --- шаблоны писем --- +def verify_mail(cfg: Config, to: str, token: str) -> Mail: + # Токен во фрагменте (#): он не уходит на сервер, не попадает в логи и заголовок Referer. + link = f"{cfg.base_url}#verify={token}" + return Mail( + to, + "Подтвердите почту в Сплит-тренере", + "Здравствуйте!\n\nЧтобы завершить регистрацию в Сплит-тренере, подтвердите адрес почты и задайте пароль. " + f"Откройте ссылку:\n\n{link}\n\nСсылка действует {human_duration(cfg.verify_ttl)} и работает один раз.\n\n" + "Если вы не регистрировались, просто проигнорируйте это письмо: без перехода по ссылке учётная запись не создаётся.\n", + ) + + +def reset_mail(cfg: Config, to: str, token: str) -> Mail: + link = f"{cfg.base_url}#reset={token}" + return Mail( + to, + "Сброс пароля в Сплит-тренере", + "Здравствуйте!\n\nМы получили запрос на сброс пароля. Чтобы задать новый пароль, откройте ссылку:\n\n" + f"{link}\n\nСсылка действует {human_duration(cfg.reset_ttl)} и работает один раз.\n\n" + "Если вы не запрашивали сброс, проигнорируйте это письмо: пароль останется прежним.\n", + ) + + +def already_registered_mail(cfg: Config, to: str) -> Mail: + return Mail( + to, + "Вы уже зарегистрированы в Сплит-тренере", + "Здравствуйте!\n\nКто-то (возможно, вы) пытался зарегистрировать этот адрес, но он уже есть в системе.\n\n" + f"Если это были вы и вы забыли пароль, на странице входа нажмите «Забыли пароль?»: {cfg.base_url}\n\n" + "Если это были не вы, ничего делать не нужно.\n", + ) + + +def password_changed_mail(cfg: Config, to: str) -> Mail: + return Mail( + to, + "Пароль в Сплит-тренере изменён", + "Здравствуйте!\n\nПароль вашей учётной записи изменён, все прежние входы завершены.\n\n" + f"Если это были не вы, срочно запросите сброс пароля: {cfg.base_url}\n", + ) + + +def build_message(mail: Mail, from_addr: str) -> EmailMessage: + """Собирает письмо. Переводы строк в заголовках запрещены самим EmailMessage (ValueError): инъекция невозможна.""" + msg = EmailMessage() + msg["From"] = from_addr + msg["To"] = mail.to + msg["Subject"] = mail.subject + msg["Date"] = formatdate() + msg["Message-ID"] = make_msgid(domain=parseaddr(from_addr)[1].rpartition("@")[2] or "localhost") + msg.set_content(mail.body) + return msg + + +# --- способы доставки --- +class MemoryMailer: + def __init__(self) -> None: + self.sent: list[Mail] = [] + + def send(self, mail: Mail) -> None: + self.sent.append(mail) + + +class ConsoleMailer: + """Только для разработки: письмо с ссылкой попадает в лог. Не используйте на боевом сервере.""" + + def send(self, mail: Mail) -> None: + log.warning("[DEV: SMTP не настроен] письмо для %s\nТема: %s\n%s", mail.to, mail.subject, mail.body) + + +class SmtpMailer: + def __init__(self, cfg: Config) -> None: + self._cfg = cfg + + def send(self, mail: Mail) -> None: + cfg = self._cfg + msg = build_message(mail, cfg.mail_from) + if cfg.smtp_security == "ssl": + client = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=15, context=ssl.create_default_context()) + else: + client = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=15) + with client as server: + if cfg.smtp_security == "starttls": + server.starttls(context=ssl.create_default_context()) + if cfg.smtp_user: + server.login(cfg.smtp_user, cfg.smtp_password) + server.send_message(msg) + + +class AsyncMailer: + """Отправка в фоне: ответ API не ждёт SMTP, а время ответа не выдаёт, существует ли адрес.""" + + def __init__(self, inner: Mailer, maxsize: int = 100) -> None: + self._inner = inner + self._queue: queue.Queue[Mail | None] = queue.Queue(maxsize=maxsize) + self._thread = threading.Thread(target=self._run, name="mailer", daemon=True) + self._thread.start() + + def send(self, mail: Mail) -> None: + try: + self._queue.put_nowait(mail) + except queue.Full: + log.error("очередь писем переполнена, письмо не принято (домен получателя: %s)", mail.to.rpartition("@")[2]) + raise MailQueueFull from None + + def _run(self) -> None: + while True: + mail = self._queue.get() + try: + if mail is None: + return + self._inner.send(mail) + except Exception as exc: # одно неудачное письмо не должно останавливать отправку остальных + # Текст исключения не пишем: в нём бывает адрес получателя (например, при отказе SMTP) + log.error("не удалось отправить письмо (домен получателя: %s): %s", mail.to.rpartition("@")[2], type(exc).__name__) + finally: + self._queue.task_done() + + def flush(self, timeout: float = 10) -> None: + deadline = time.monotonic() + timeout + while self._queue.unfinished_tasks and time.monotonic() < deadline: + time.sleep(0.01) + + def close(self) -> None: + try: + self._queue.put(None, timeout=1) + except queue.Full: + return + self._thread.join(timeout=5) + + +def make_mailer(cfg: Config) -> Mailer: + if cfg.mail_backend == "smtp": + return AsyncMailer(SmtpMailer(cfg)) + return ConsoleMailer() diff --git a/server/splitapi/ratelimit.py b/server/splitapi/ratelimit.py new file mode 100644 index 0000000..9a1bcc1 --- /dev/null +++ b/server/splitapi/ratelimit.py @@ -0,0 +1,79 @@ +"""Ограничитель частоты запросов со скользящим окном (в памяти одного процесса).""" +from __future__ import annotations + +import math +import threading +import time +from collections import OrderedDict, deque +from typing import Callable + + +class RateLimiter: + def __init__(self, clock: Callable[[], float] = time.monotonic, max_keys: int = 50_000, purge_interval: int = 60) -> None: + self._clock = clock + self._max_keys = max_keys + self._purge_interval = purge_interval + self._last_purge = float("-inf") + self._lock = threading.Lock() + self._hits: OrderedDict[str, tuple[deque[float], int]] = OrderedDict() + + def check(self, key: str, limit: int, window: int) -> tuple[bool, int]: + """Учитывает попытку. Возвращает (разрешено, через сколько секунд повторить).""" + now = self._clock() + with self._lock: + entry = self._hits.get(key) + hits = entry[0] if entry else deque() + while hits and hits[0] <= now - window: + hits.popleft() + if len(hits) >= limit: + self._hits[key] = (hits, window) + return False, max(1, math.ceil(hits[0] + window - now)) + hits.append(now) + self._hits[key] = (hits, window) + self._hits.move_to_end(key) + self._evict(now) + return True, 0 + + def is_blocked(self, key: str, limit: int, window: int) -> tuple[bool, int]: + """Проверяет лимит, не засчитывая попытку (вместе с record() считает только неудачи).""" + now = self._clock() + with self._lock: + entry = self._hits.get(key) + hits = entry[0] if entry else deque() + while hits and hits[0] <= now - window: + hits.popleft() + if len(hits) >= limit: + return True, max(1, math.ceil(hits[0] + window - now)) + return False, 0 + + def record(self, key: str, window: int) -> None: + now = self._clock() + with self._lock: + entry = self._hits.get(key) + hits = entry[0] if entry else deque() + hits.append(now) + self._hits[key] = (hits, window) + self._hits.move_to_end(key) + self._evict(now) + + def forgive(self, key: str) -> None: + """Возвращает последнюю учтённую попытку (успешный вход не должен приближать блокировку).""" + with self._lock: + entry = self._hits.get(key) + if entry and entry[0]: + entry[0].pop() + + def _evict(self, now: float) -> None: + if len(self._hits) <= self._max_keys: + return + # Полный проход по ключам дорог, поэтому не чаще раза в purge_interval; иначе просто вытесняем самые старые. + if now - self._last_purge >= self._purge_interval: + self._last_purge = now + for key in [k for k, (hits, window) in self._hits.items() if not hits or hits[-1] <= now - window]: + del self._hits[key] + while len(self._hits) > self._max_keys: + self._hits.popitem(last=False) + + def size(self) -> int: + with self._lock: + return len(self._hits) diff --git a/server/splitapi/security.py b/server/splitapi/security.py new file mode 100644 index 0000000..d43ebf7 --- /dev/null +++ b/server/splitapi/security.py @@ -0,0 +1,164 @@ +"""Примитивы безопасности: проверка почты и паролей, хеширование (scrypt), токены.""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import re +import secrets +import threading +import unicodedata + +# Параметры scrypt (OWASP допускает N=2^15, r=8, p=1 как разумный минимум для небольшого сервера). +SCRYPT_N = 2**15 +SCRYPT_R = 8 +SCRYPT_P = 1 +_DKLEN = 32 +_SALT_BYTES = 16 +_MAX_MEM = 256 * 1024 * 1024 + +PASSWORD_MIN = 8 +PASSWORD_MAX = 128 # верхняя граница ограничивает стоимость хеширования для атакующего +_EMAIL_MAX = 254 + +# Не больше нескольких одновременных хешей: каждый занимает десятки МБ памяти. +SLOT_WAIT = 5.0 # сколько секунд ждать свободный слот, прежде чем ответить «сервер занят» +_slots = threading.BoundedSemaphore(4) + + +class HashBusy(RuntimeError): + """Все слоты хеширования заняты: лучше быстро отказать, чем копить зависшие запросы.""" + +# fullmatch, а не match с $: $ срабатывает и перед завершающим переводом строки. +# В адресе допустимы только безопасные символы: % | / ! # $ & * = ? ^ ` { } ~ формально валидны по RFC, +# но опасны для старых почтовых систем (переадресация через %, выполнение через |). +_LOCAL_RE = re.compile(r"[a-z0-9._+'-]{1,64}") +_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?") +_TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{16,100}") + +_COMMON_PASSWORDS = frozenset( + { + "password", "password1", "password123", "12345678", "123456789", "1234567890", "12341234", + "00000000", "11111111", "qwertyui", "qwertyuiop", "qwerty123", "iloveyou", "admin123", + "letmein1", "abc12345", "пароль123", "йцукенгш", "qazwsxedc", "1q2w3e4r", + } +) + + +def normalize_email(raw: object) -> str | None: + """Приводит адрес к каноническому виду (нижний регистр, домен в punycode) или возвращает None.""" + if not isinstance(raw, str): + return None + email = raw.strip().lower() + if not email or len(email) > _EMAIL_MAX or email.count("@") != 1: + return None + local, domain = email.split("@") + if not _LOCAL_RE.fullmatch(local) or local.startswith(".") or local.endswith(".") or ".." in local: + return None + try: + ascii_domain = domain.encode("idna").decode("ascii") + except UnicodeError: + return None + labels = ascii_domain.split(".") + if len(ascii_domain) > 253 or len(labels) < 2 or len(labels[-1]) < 2: + return None + if not all(_LABEL_RE.fullmatch(label) for label in labels): + return None + return f"{local}@{ascii_domain}" + + +def validate_password(password: object, email: str) -> str | None: + """Возвращает код ошибки или None, если пароль допустим.""" + if not isinstance(password, str) or len(password) < PASSWORD_MIN: + return "password_too_short" + if len(password) > PASSWORD_MAX: + return "password_too_long" + try: + password.encode("utf-8") + except UnicodeEncodeError: + return "password_invalid" # одиночный суррогат: json его пропускает, а хешер упал бы + lowered = password.lower() + local = email.split("@")[0].lower() + if lowered in (email.lower(), local) or lowered in _COMMON_PASSWORDS: + return "password_weak" + return None + + +def _prepare(password: str) -> bytes: + return unicodedata.normalize("NFKC", password).encode("utf-8") + + +def _derive(password: str, salt: bytes, n: int, r: int, p: int) -> bytes: + if not _slots.acquire(timeout=SLOT_WAIT): + raise HashBusy("слишком много одновременных проверок пароля") + try: + return hashlib.scrypt(_prepare(password), salt=salt, n=n, r=r, p=p, dklen=_DKLEN, maxmem=_MAX_MEM) + finally: + _slots.release() + + +def hash_password(password: str, n: int | None = None) -> str: + """Формат: scrypt$N$r$p$соль$хеш (параметры внутри позволяют позже усилить хеширование).""" + n = n or SCRYPT_N + salt = secrets.token_bytes(_SALT_BYTES) + digest = _derive(password, salt, n, SCRYPT_R, SCRYPT_P) + return "$".join( + ["scrypt", str(n), str(SCRYPT_R), str(SCRYPT_P), base64.b64encode(salt).decode(), base64.b64encode(digest).decode()] + ) + + +def _parse(stored: str) -> tuple[int, int, int, bytes, bytes] | None: + try: + scheme, n, r, p, salt, digest = stored.split("$") + if scheme != "scrypt": + return None + n_i, r_i, p_i = int(n), int(r), int(p) + # Защита от подменённых параметров: не даём заставить сервер считать гигабайтный хеш + if not (2**10 <= n_i <= 2**20 and n_i & (n_i - 1) == 0 and 1 <= r_i <= 16 and 1 <= p_i <= 4): + return None + return n_i, r_i, p_i, base64.b64decode(salt, validate=True), base64.b64decode(digest, validate=True) + except (ValueError, TypeError): + return None + + +def verify_password(stored: str, password: str) -> bool: + parsed = _parse(stored) if isinstance(stored, str) else None + if parsed is None: + return False + n, r, p, salt, expected = parsed + try: + actual = _derive(password, salt, n, r, p) + except (ValueError, MemoryError): + return False + return hmac.compare_digest(actual, expected) + + +def needs_rehash(stored: str) -> bool: + parsed = _parse(stored) + return parsed is None or parsed[:3] != (SCRYPT_N, SCRYPT_R, SCRYPT_P) + + +_dummy_lock = threading.Lock() +_dummy_hash: str | None = None + + +def verify_dummy(password: str) -> bool: + """Тратит столько же времени, как проверка настоящего пароля: ответ на вход не выдаёт, есть ли такая почта.""" + global _dummy_hash + with _dummy_lock: + if _dummy_hash is None: + _dummy_hash = hash_password(secrets.token_urlsafe(16)) + verify_password(_dummy_hash, password) + return False + + +def new_token() -> tuple[str, str]: + """Возвращает (токен для пользователя, его SHA-256 для базы): в базе токены в открытом виде не хранятся.""" + raw = secrets.token_urlsafe(32) + return raw, hashlib.sha256(raw.encode("ascii")).hexdigest() + + +def hash_token(raw: object) -> str | None: + if not isinstance(raw, str) or not _TOKEN_RE.fullmatch(raw): + return None + return hashlib.sha256(raw.encode("ascii")).hexdigest() diff --git a/server/splitapi/service.py b/server/splitapi/service.py new file mode 100644 index 0000000..47f0def --- /dev/null +++ b/server/splitapi/service.py @@ -0,0 +1,277 @@ +"""Бизнес-логика: регистрация с подтверждением почты, вход, сброс пароля, удаление аккаунта, хранение прогресса. + +Принципы: ответы для существующих и несуществующих адресов одинаковы, токены одноразовые и хранятся только +в виде хеша, пароль хешируется scrypt и задаётся только после подтверждения почты, частота попыток ограничена. +""" +from __future__ import annotations + +import hashlib +import json +import logging +import time +from typing import Callable + +from . import mailer as templates +from . import security +from .config import Config +from .mailer import MailQueueFull, Mailer +from .ratelimit import RateLimiter +from .statevalid import StateError, clean_state +from .store import Store + +log = logging.getLogger("splitapi") + +MAX_STATE_BYTES = 300_000 +PASSWORD_INPUT_MAX = 1024 +PURGE_INTERVAL = 300 # как часто чистить просроченные записи (секунды), а не на каждом запросе + +# Ограничения частоты: имя -> (сколько попыток, за сколько секунд) +LIMITS: dict[str, tuple[int, int]] = { + "register_ip": (5, 3600), + "register_email": (3, 3600), # молча: письмо просто не отправляется + "verify_ip": (20, 600), + "login_ip": (20, 600), # считаются только неудачные входы (успешный вход возвращает попытку) + "login_pair": (5, 600), # неудачи для пары «адрес + IP»: чужой IP не блокирует владельца + "login_email": (100, 3600), # общий потолок на аккаунт против распределённого подбора (с запасом для владельца) + "forgot_ip": (5, 3600), + "forgot_email": (3, 3600), # молча + "reset_ip": (10, 600), + "delete_user": (5, 600), + "state_user": (60, 60), +} + +MESSAGES = { + "invalid_email": "Введите корректный адрес почты.", + "password_too_short": "Пароль должен быть не короче 8 символов.", + "password_too_long": "Пароль слишком длинный: не более 128 символов.", + "password_weak": "Пароль слишком простой: выберите другой.", + "password_invalid": "Пароль содержит недопустимые символы.", + "rate_limited": "Слишком много попыток. Повторите позже.", + "busy": "Сервер сейчас занят. Повторите через несколько секунд.", + "registration_closed": "Регистрация на этом сервере закрыта.", + "invalid_credentials": "Неверная почта или пароль. Если вы только зарегистрировались, подтвердите почту по ссылке из письма.", + "wrong_password": "Неверный пароль.", + "invalid_token": "Ссылка недействительна или устарела. Запросите новую.", + "unauthorized": "Нужно войти в аккаунт.", + "invalid_state": "Некорректные данные прогресса.", + "version_conflict": "На сервере более новая версия прогресса.", + "account_mismatch": "В другой вкладке выполнен вход в другой аккаунт. Страница будет обновлена.", + "csrf": "Запрос отклонён.", + "invalid_json": "Некорректный запрос.", + "payload_too_large": "Слишком большой запрос.", + "unsupported_media_type": "Ожидался JSON.", + "not_found": "Не найдено.", + "method_not_allowed": "Метод не поддерживается.", + "server_error": "Внутренняя ошибка сервера.", +} + +REGISTER_OK = {"ok": True, "message": "Проверьте почту: мы отправили письмо со ссылкой. По ней вы подтвердите адрес и задайте пароль. Если письма нет, загляните в «Спам»."} +FORGOT_OK = {"ok": True, "message": "Если такой адрес зарегистрирован, мы отправили на него письмо со ссылкой для сброса пароля."} +VERIFY_OK = {"ok": True, "message": "Аккаунт создан. Теперь можно войти."} +RESET_OK = {"ok": True, "message": "Пароль изменён. Войдите с новым паролем."} +DELETE_OK = {"ok": True, "message": "Аккаунт и прогресс на сервере удалены."} + + +class ApiError(Exception): + def __init__(self, status: int, code: str, retry_after: int | None = None, extra: dict | None = None) -> None: + super().__init__(code) + self.status = status + self.code = code + self.message = MESSAGES.get(code, code) + self.retry_after = retry_after + self.extra = extra or {} + + +def _key(name: str, value: str) -> str: + return f"{name}:{hashlib.sha256(value.encode('utf-8', 'replace')).hexdigest()[:16]}" + + +class Service: + def __init__(self, cfg: Config, store: Store, mailer: Mailer, limiter: RateLimiter, clock: Callable[[], float] = time.time) -> None: + self._cfg = cfg + self._store = store + self._mailer = mailer + self._limiter = limiter + self._clock = clock + self._last_purge = 0.0 + + # --- ограничения и обслуживание --- + def _reserve(self, *items: tuple[str, str]) -> list[str]: + """Занимает попытку во всех счётчиках ДО дорогой проверки пароля (иначе параллельные запросы обходят лимит). + Успех возвращает попытки через _release, поэтому в счётчиках остаются только неудачи.""" + taken: list[str] = [] + for name, ident in items: + key = _key(name, ident) + allowed, retry = self._limiter.check(key, *LIMITS[name]) + if not allowed: + self._release(taken) + raise ApiError(429, "rate_limited", retry_after=retry) + taken.append(key) + return taken + + def _release(self, keys: list[str]) -> None: + for key in keys: + self._limiter.forgive(key) + + def _quiet_limit(self, name: str, ident: str) -> bool: + """Лимит, о котором пользователь не узнаёт (иначе он раскрывал бы сведения об адресе).""" + return self._limiter.check(_key(name, ident), *LIMITS[name])[0] + + def _maybe_purge(self, now: float) -> None: + if now - self._last_purge >= PURGE_INTERVAL: + self._last_purge = now + self._store.purge_expired(now) + + # --- регистрация --- + def register(self, email_raw: object, ip: str) -> dict: + """Принимает только адрес: пароль человек задаёт по ссылке из письма, поэтому чужая регистрация ничего не подменит.""" + if not self._cfg.allow_registration: + raise ApiError(403, "registration_closed") + self._reserve(("register_ip", ip)) + email = security.normalize_email(email_raw) + if email is None: + raise ApiError(400, "invalid_email") + now = self._clock() + self._maybe_purge(now) + if not self._quiet_limit("register_email", email): + return REGISTER_OK + if self._store.user_by_email(email) is not None: + self._mailer.send(templates.already_registered_mail(self._cfg, email)) + else: + raw, token_hash = security.new_token() + self._store.save_registration(email, token_hash, now + self._cfg.verify_ttl, now) + self._mailer.send(templates.verify_mail(self._cfg, email, raw)) + return REGISTER_OK + + def verify(self, token_raw: object, password: object, ip: str) -> dict: + """Подтверждение почты и выбор пароля: учётная запись создаётся только здесь.""" + self._reserve(("verify_ip", ip)) + token_hash = security.hash_token(token_raw) + now = self._clock() + email = self._store.peek_registration(token_hash, now) if token_hash else None + if email is None: + raise ApiError(400, "invalid_token") + problem = security.validate_password(password, email) + if problem: + raise ApiError(400, problem) # ссылка не сгорает: можно выбрать другой пароль + pw_hash = security.hash_password(password) + if not self._store.activate_registration(token_hash, pw_hash, now): + raise ApiError(400, "invalid_token") + return VERIFY_OK + + # --- вход и выход --- + def login(self, email_raw: object, password: object, ip: str) -> tuple[str, str]: + email = security.normalize_email(email_raw) + ident = email or str(email_raw)[:254].lower() + reserved = self._reserve(("login_ip", ip), ("login_pair", f"{ip}|{ident}"), ("login_email", ident)) + if not isinstance(password, str) or len(password) > PASSWORD_INPUT_MAX: + password = "" + user = self._store.user_by_email(email) if email else None + valid = security.verify_password(user["pw_hash"], password) if user is not None else security.verify_dummy(password) + if not valid: + raise ApiError(401, "invalid_credentials") + current_hash = user["pw_hash"] + if security.needs_rehash(current_hash): + upgraded = security.hash_password(password) + # Условная запись: если пароль успели сменить (сброс), старый пароль не возвращается, а вход отклоняется ниже + if self._store.set_password(user["id"], upgraded, expect_pw_hash=current_hash): + current_hash = upgraded + now = self._clock() + raw, token_hash = security.new_token() + if not self._store.create_session(user["id"], token_hash, now + self._cfg.session_seconds, now, expect_pw_hash=current_hash): + raise ApiError(401, "invalid_credentials") # пароль сменили, пока шла проверка + self._release(reserved) + return raw, user["email"] + + def logout(self, session_raw: object) -> None: + token_hash = security.hash_token(session_raw) + if token_hash: + self._store.delete_session(token_hash) + + def me(self, session_raw: object) -> dict | None: + token_hash = security.hash_token(session_raw) + row = self._store.session_user(token_hash, self._clock()) if token_hash else None + return {"id": row["id"], "email": row["email"]} if row else None + + def delete_account(self, session_raw: object, password: object) -> dict: + """Удаляет аккаунт, сессии и прогресс. Нужен пароль: украденная cookie не должна стирать чужие данные.""" + user = self.me(session_raw) + if user is None: + raise ApiError(401, "unauthorized") + reserved = self._reserve(("delete_user", str(user["id"]))) + row = self._store.user_by_email(user["email"]) + if not isinstance(password, str) or len(password) > PASSWORD_INPUT_MAX: + password = "" + if row is None or not security.verify_password(row["pw_hash"], password): + raise ApiError(401, "wrong_password") + self._store.delete_user(user["id"]) + self._release(reserved) + return DELETE_OK + + # --- сброс пароля --- + def forgot(self, email_raw: object, ip: str) -> dict: + self._reserve(("forgot_ip", ip)) + email = security.normalize_email(email_raw) + if email is None: + raise ApiError(400, "invalid_email") + now = self._clock() + self._maybe_purge(now) + if not self._quiet_limit("forgot_email", email): + return FORGOT_OK + user = self._store.user_by_email(email) + if user is not None: + raw, token_hash = security.new_token() + self._store.save_reset_token(user["id"], token_hash, now + self._cfg.reset_ttl) + try: + self._mailer.send(templates.reset_mail(self._cfg, email, raw)) + except MailQueueFull: + # Ответ обязан быть тем же, что для незарегистрированного адреса, иначе по перегрузке очереди можно + # выяснить, какие адреса есть в системе. Сбой уже записан в лог почтовой очереди. + pass + return FORGOT_OK + + def reset(self, token_raw: object, password: object, ip: str) -> dict: + self._reserve(("reset_ip", ip)) + token_hash = security.hash_token(token_raw) + now = self._clock() + row = self._store.peek_reset_token(token_hash, now) if token_hash else None + if row is None: + raise ApiError(400, "invalid_token") + problem = security.validate_password(password, row["email"]) + if problem: + raise ApiError(400, problem) # токен не сгорает: можно выбрать другой пароль + pw_hash = security.hash_password(password) # считаем до погашения токена: сбой хеширования не сжигает ссылку + result = self._store.reset_password(token_hash, pw_hash, now) # токен, пароль и сессии: одна транзакция + if result is None: + raise ApiError(400, "invalid_token") + try: + self._mailer.send(templates.password_changed_mail(self._cfg, result["email"])) + except MailQueueFull: + pass # уведомление не критично: пароль уже изменён + return RESET_OK + + # --- прогресс --- + def get_state(self, user_id: int) -> dict: + row = self._store.get_state(user_id) + if row is None: + return {"version": 0, "data": clean_state({})} + return {"version": row[0], "data": json.loads(row[1])} + + def put_state(self, user_id: int, version: object, data: object, account_email: str, claimed_email: object = None) -> dict: + """claimed_email: от чьего имени клиент сохраняет данные; несовпадение значит, что вход в другой вкладке сменили.""" + if claimed_email is not None and str(claimed_email).strip().lower() != account_email: + raise ApiError(409, "account_mismatch") + self._reserve(("state_user", str(user_id))) + if isinstance(version, bool) or not isinstance(version, int) or version < 0: + raise ApiError(400, "invalid_state") + try: + cleaned = clean_state(data) + except StateError: + raise ApiError(400, "invalid_state") from None + payload = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":")) + if len(payload.encode("utf-8")) > MAX_STATE_BYTES: + raise ApiError(400, "invalid_state") + new_version = self._store.put_state(user_id, version, payload, self._clock()) + if new_version is None: + raise ApiError(409, "version_conflict", extra=self.get_state(user_id)) + return {"version": new_version} diff --git a/server/splitapi/statevalid.py b/server/splitapi/statevalid.py new file mode 100644 index 0000000..f708130 --- /dev/null +++ b/server/splitapi/statevalid.py @@ -0,0 +1,109 @@ +"""Проверка и очистка состояния прогресса, которое клиент хранит на сервере. + +Сервер не доверяет присланному JSON: оставляем только известные поля, приводим числа к допустимым +диапазонам и ограничиваем размеры. Так испорченные данные не попадут ни на другие устройства, ни в интерфейс. +""" +from __future__ import annotations + +import math +import re + +MAX_HISTORY = 600 +MAX_PROGRESS = 200 +MAX_KEYS = 200 +_ID_MAX = 40 +_ID_RE = re.compile(r"[^A-Za-z0-9_.-]") +_LANGS = ("ru", "en") +_KINDS = ("lesson", "practice", "weak") + + +class StateError(ValueError): + """Состояние имеет неверную структуру.""" + + +def _num(value: object, low: float, high: float) -> int | float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return 0 + if isinstance(value, float) and not math.isfinite(value): + return 0 + clamped = min(max(value, low), high) + return clamped + + +def _ident(value: object) -> str: + return _ID_RE.sub("", value)[:_ID_MAX] if isinstance(value, str) else "" + + +def _is_key_char(char: object) -> bool: + # одиночные суррогаты не кодируются в UTF-8 и ломали бы сохранение + return isinstance(char, str) and len(char) == 1 and not 0xD800 <= ord(char) <= 0xDFFF + + +def _choice(value: object, allowed: tuple[str, ...]) -> str: + return value if isinstance(value, str) and value in allowed else "" + + +def _history_row(row: dict) -> dict: + return { + "t": _num(row.get("t"), 0, 4_000_000_000_000), + "id": _ident(row.get("id")), + "k": _choice(row.get("k"), _KINDS), + "lang": _choice(row.get("lang"), _LANGS), + "cpm": _num(row.get("cpm"), 0, 3000), + "acc": _num(row.get("acc"), 0, 100), + "err": _num(row.get("err"), 0, 100_000), + "len": _num(row.get("len"), 0, 100_000), + "sec": _num(row.get("sec"), 0, 1_000_000), + } + + +def _progress_row(row: dict) -> dict: + return { + "best": _num(row.get("best"), 0, 3000), + "acc": _num(row.get("acc"), 0, 100), + "stars": int(_num(row.get("stars"), 0, 3)), + "tries": int(_num(row.get("tries"), 0, 100_000)), + } + + +def _key_row(row: dict) -> dict: + return {name: _num(row.get(name), 0, 1_000_000_000) for name in ("n", "err", "ms", "ln")} + + +def _section(data: dict, name: str, kind: type) -> object: + value = data.get(name) + if value is None: + return kind() + if not isinstance(value, kind): + raise StateError(f"поле {name} имеет неверный тип") + return value + + +def clean_state(data: object) -> dict: + """Возвращает очищенную копию состояния или поднимает StateError.""" + if not isinstance(data, dict): + raise StateError("ожидался объект") + progress_in = _section(data, "progress", dict) + history_in = _section(data, "history", list) + keys_in = _section(data, "keys", dict) + + progress = {} + for key, row in sorted(progress_in.items()): # порядок как у клиента: обрезка одинакова на обеих сторонах + ident = _ident(key) + if ident and isinstance(row, dict) and len(progress) < MAX_PROGRESS: + progress[ident] = _progress_row(row) + + history = [_history_row(row) for row in history_in if isinstance(row, dict)][-MAX_HISTORY:] + + keys = {} + for lang in _LANGS: + per_lang = keys_in.get(lang, {}) + if not isinstance(per_lang, dict): + raise StateError("поле keys имеет неверный тип") + cleaned = {} + for char, row in per_lang.items(): + if _is_key_char(char) and isinstance(row, dict) and len(cleaned) < MAX_KEYS: + cleaned[char] = _key_row(row) + keys[lang] = cleaned + + return {"progress": progress, "history": history, "keys": keys} diff --git a/server/splitapi/store.py b/server/splitapi/store.py new file mode 100644 index 0000000..af48318 --- /dev/null +++ b/server/splitapi/store.py @@ -0,0 +1,245 @@ +"""Хранилище SQLite. Все запросы параметризованы; одноразовые токены гасятся атомарно.""" +from __future__ import annotations + +import os +import sqlite3 +from contextlib import contextmanager +from typing import Iterator + +SCHEMA_VERSION = 2 # 2: регистрация больше не хранит пароль (его задают после подтверждения почты) + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + pw_hash TEXT NOT NULL, + created_at INTEGER NOT NULL +); +-- Ожидание подтверждения почты: пользователь появляется только после перехода по ссылке и выбора пароля. +CREATE TABLE IF NOT EXISTS registrations ( + email TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS reset_tokens ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS registrations_expires ON registrations(expires_at); +CREATE INDEX IF NOT EXISTS reset_tokens_expires ON reset_tokens(expires_at); +CREATE INDEX IF NOT EXISTS sessions_expires ON sessions(expires_at); +CREATE TABLE IF NOT EXISTS user_state ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + data TEXT NOT NULL, + version INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +""" + +_COUNTABLE = frozenset({"users", "registrations", "reset_tokens", "sessions", "user_state"}) + + +class Store: + def __init__(self, path: str) -> None: + self._path = path + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + self._init_schema() + try: + os.chmod(path, 0o600) + except OSError: + pass # Windows и некоторые файловые системы не поддерживают права + + @contextmanager + def _tx(self, immediate: bool = False) -> Iterator[sqlite3.Connection]: + conn = sqlite3.connect(self._path, timeout=5, isolation_level=None) + conn.row_factory = sqlite3.Row + try: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("BEGIN IMMEDIATE" if immediate else "BEGIN") + try: + yield conn + except BaseException: + conn.execute("ROLLBACK") + raise + conn.execute("COMMIT") + finally: + conn.close() + + def _init_schema(self) -> None: + conn = sqlite3.connect(self._path, timeout=5, isolation_level=None) + try: + conn.execute("PRAGMA journal_mode = WAL") + version = conn.execute("PRAGMA user_version").fetchone()[0] + if version < 2: + # Ожидающие регистрации одноразовы и недолговечны: старые (с паролем) просто отбрасываем + conn.execute("DROP TABLE IF EXISTS registrations") + conn.executescript(_SCHEMA) + conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + finally: + conn.close() + + # --- пользователи --- + def create_user(self, email: str, pw_hash: str, now: float) -> int | None: + try: + with self._tx(immediate=True) as conn: + return conn.execute( + "INSERT INTO users (email, pw_hash, created_at) VALUES (?, ?, ?)", (email, pw_hash, int(now)) + ).lastrowid + except sqlite3.IntegrityError: + return None + + def user_by_email(self, email: str) -> sqlite3.Row | None: + with self._tx() as conn: + return conn.execute("SELECT id, email, pw_hash FROM users WHERE email = ?", (email,)).fetchone() + + def set_password(self, user_id: int, pw_hash: str, expect_pw_hash: str | None = None) -> bool: + """С expect_pw_hash меняет пароль, только если он всё ещё прежний (перехеширование не затирает сброс пароля).""" + with self._tx(immediate=True) as conn: + if expect_pw_hash is None: + cursor = conn.execute("UPDATE users SET pw_hash = ? WHERE id = ?", (pw_hash, user_id)) + else: + cursor = conn.execute("UPDATE users SET pw_hash = ? WHERE id = ? AND pw_hash = ?", (pw_hash, user_id, expect_pw_hash)) + return cursor.rowcount > 0 + + def delete_user(self, user_id: int) -> None: + """Удаляет пользователя вместе с сессиями, токенами и прогрессом (каскад внешних ключей).""" + with self._tx(immediate=True) as conn: + conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) + + # --- регистрация --- + def save_registration(self, email: str, token_hash: str, expires_at: float, now: float) -> None: + with self._tx(immediate=True) as conn: + conn.execute( + "INSERT OR REPLACE INTO registrations (email, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)", + (email, token_hash, int(expires_at), int(now)), + ) + + def peek_registration(self, token_hash: str, now: float) -> str | None: + with self._tx() as conn: + row = conn.execute( + "SELECT email FROM registrations WHERE token_hash = ? AND expires_at > ?", (token_hash, int(now)) + ).fetchone() + return row["email"] if row else None + + def activate_registration(self, token_hash: str, pw_hash: str, now: float) -> bool: + """Гасит ссылку и создаёт пользователя одной транзакцией. False: ссылка недействительна или адрес уже занят.""" + with self._tx(immediate=True) as conn: + row = conn.execute( + "DELETE FROM registrations WHERE token_hash = ? AND expires_at > ? RETURNING email", (token_hash, int(now)) + ).fetchone() + if row is None: + return False + try: + conn.execute( + "INSERT INTO users (email, pw_hash, created_at) VALUES (?, ?, ?)", (row["email"], pw_hash, int(now)) + ) + except sqlite3.IntegrityError: + return False # неудавшийся INSERT не отменяет транзакцию: ссылка всё равно погашена + return True + + # --- сброс пароля --- + def save_reset_token(self, user_id: int, token_hash: str, expires_at: float) -> None: + with self._tx(immediate=True) as conn: + conn.execute("DELETE FROM reset_tokens WHERE user_id = ?", (user_id,)) + conn.execute( + "INSERT INTO reset_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)", + (token_hash, user_id, int(expires_at)), + ) + + def peek_reset_token(self, token_hash: str, now: float) -> sqlite3.Row | None: + with self._tx() as conn: + return conn.execute( + "SELECT u.id AS id, u.email AS email FROM reset_tokens t JOIN users u ON u.id = t.user_id " + "WHERE t.token_hash = ? AND t.expires_at > ?", + (token_hash, int(now)), + ).fetchone() + + def reset_password(self, token_hash: str, pw_hash: str, now: float) -> sqlite3.Row | None: + """Гасит токен, меняет пароль и завершает все сессии одной транзакцией. None: токен недействителен.""" + with self._tx(immediate=True) as conn: + row = conn.execute( + "DELETE FROM reset_tokens WHERE token_hash = ? AND expires_at > ? RETURNING user_id", (token_hash, int(now)) + ).fetchone() + if row is None: + return None + conn.execute("UPDATE users SET pw_hash = ? WHERE id = ?", (pw_hash, row["user_id"])) + conn.execute("DELETE FROM sessions WHERE user_id = ?", (row["user_id"],)) + return conn.execute("SELECT id, email FROM users WHERE id = ?", (row["user_id"],)).fetchone() + + # --- сессии --- + def create_session(self, user_id: int, token_hash: str, expires_at: float, now: float, keep: int = 10, + expect_pw_hash: str | None = None) -> bool: + """expect_pw_hash защищает от гонки: если пароль сменили после проверки, сессия не создаётся.""" + with self._tx(immediate=True) as conn: + if expect_pw_hash is not None: + current = conn.execute("SELECT pw_hash FROM users WHERE id = ?", (user_id,)).fetchone() + if current is None or current["pw_hash"] != expect_pw_hash: + return False + conn.execute( + "INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)", + (token_hash, user_id, int(expires_at), int(now)), + ) + conn.execute( + "DELETE FROM sessions WHERE user_id = ? AND token_hash NOT IN " + "(SELECT token_hash FROM sessions WHERE user_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?)", + (user_id, user_id, keep), + ) + return True + + def session_user(self, token_hash: str, now: float) -> sqlite3.Row | None: + with self._tx() as conn: + return conn.execute( + "SELECT u.id AS id, u.email AS email FROM sessions s JOIN users u ON u.id = s.user_id " + "WHERE s.token_hash = ? AND s.expires_at > ?", + (token_hash, int(now)), + ).fetchone() + + def delete_session(self, token_hash: str) -> None: + with self._tx(immediate=True) as conn: + conn.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,)) + + def delete_user_sessions(self, user_id: int) -> None: + with self._tx(immediate=True) as conn: + conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,)) + + # --- состояние прогресса --- + def get_state(self, user_id: int) -> tuple[int, str] | None: + with self._tx() as conn: + row = conn.execute("SELECT version, data FROM user_state WHERE user_id = ?", (user_id,)).fetchone() + return (row["version"], row["data"]) if row else None + + def put_state(self, user_id: int, expected_version: int, data: str, now: float) -> int | None: + """Записывает состояние, если версия совпала (оптимистичная блокировка). None = конфликт.""" + with self._tx(immediate=True) as conn: + row = conn.execute("SELECT version FROM user_state WHERE user_id = ?", (user_id,)).fetchone() + current = row["version"] if row else 0 + if current != expected_version: + return None + new_version = current + 1 + conn.execute( + "INSERT INTO user_state (user_id, data, version, updated_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, version = excluded.version, updated_at = excluded.updated_at", + (user_id, data, new_version, int(now)), + ) + return new_version + + # --- обслуживание --- + def purge_expired(self, now: float) -> None: + with self._tx(immediate=True) as conn: + for table in ("registrations", "reset_tokens", "sessions"): + conn.execute(f"DELETE FROM {table} WHERE expires_at <= ?", (int(now),)) # имена таблиц - константы выше + + def count(self, table: str) -> int: + if table not in _COUNTABLE: + raise ValueError("неизвестная таблица") + with self._tx() as conn: + return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] diff --git a/server/tests/__init__.py b/server/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/test_api.py b/server/tests/test_api.py new file mode 100644 index 0000000..1ca0ff0 --- /dev/null +++ b/server/tests/test_api.py @@ -0,0 +1,810 @@ +"""Интеграционные тесты API: реальный HTTP-сервер, SQLite во временной папке, почта в памяти.""" +import http.client +import json +import os +import re +import sqlite3 +import tempfile +import threading +import unittest +from unittest import mock + +from splitapi import security +from splitapi.config import from_env +from splitapi.http_api import is_trusted_proxy_peer, make_server, normalize_ip +from splitapi.mailer import MailQueueFull, MemoryMailer +from splitapi.ratelimit import RateLimiter +from splitapi.service import Service +from splitapi.store import Store + +PASSWORD = "correct horse battery" +OTHER_PASSWORD = "another staple pony" +_ORIGINAL_N = security.SCRYPT_N +WRONG = "definitely wrong pass" + + +def setUpModule(): + security.SCRYPT_N = 2**10 # быстрые хеши в тестах; боевое значение проверено в test_security + + +def tearDownModule(): + security.SCRYPT_N = _ORIGINAL_N + + +class Clock: + def __init__(self): + self.now = 1_800_000_000.0 + + def __call__(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + +class Resp: + def __init__(self, status, headers, body): + self.status, self.headers, self.text = status, headers, body + self.set_cookies = [] + try: + self.json = json.loads(body) if body else None + except ValueError: + self.json = None + + def header(self, name): + return self.headers.get(name) + + +class ApiTestCase(unittest.TestCase): + public_url = "http://127.0.0.1:8080" + env_extra: dict = {} + + def make_mailer(self): + return MemoryMailer() + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.db_path = os.path.join(self.tmp.name, "api.sqlite3") + self.cfg = from_env({"PUBLIC_URL": self.public_url, "DB_PATH": self.db_path, "MAIL_BACKEND": "console", **self.env_extra}) + self.clock = Clock() + self.mail = self.make_mailer() + self.service = Service(self.cfg, Store(self.db_path), self.mail, RateLimiter(clock=self.clock), clock=self.clock) + self.server = make_server(self.cfg, self.service, host="127.0.0.1", port=0) + self.port = self.server.server_address[1] + self.thread = threading.Thread(target=lambda: self.server.serve_forever(poll_interval=0.01), daemon=True) + self.thread.start() + self.addCleanup(self._stop) + self.cookie = None + + def _stop(self): + self.server.shutdown() + self.server.server_close() + + # --- помощники --- + def call(self, method, path, body=None, headers=None, raw=None, csrf=True, cookie="auto"): + hdrs = {} + if method not in ("GET", "HEAD", "OPTIONS") and csrf: + hdrs.update({"Content-Type": "application/json", "X-Requested-With": "split-trainer", "Origin": self.cfg.origin}) + if cookie == "auto": + cookie = self.cookie + if cookie: + hdrs["Cookie"] = f"{self.cfg.cookie_name}={cookie}" + hdrs.update(headers or {}) + payload = raw if raw is not None else (json.dumps(body).encode() if body is not None else None) + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + try: + conn.request(method, path, body=payload, headers=hdrs) + r = conn.getresponse() + resp = Resp(r.status, {k: v for k, v in r.getheaders()}, r.read().decode("utf-8")) + resp.set_cookies = [v for k, v in r.getheaders() if k.lower() == "set-cookie"] + return resp + finally: + conn.close() + + def register(self, email="user@example.com", **extra): + return self.call("POST", "/api/register", {"email": email, **extra}) + + def mails_to(self, email): + return [m for m in self.mail.sent if m.to == email] + + def token_from(self, email, kind, index=-1): + mail = self.mails_to(email)[index] + match = re.search(rf"#{kind}=([A-Za-z0-9_-]+)", mail.body) + self.assertIsNotNone(match, f"в письме нет ссылки {kind}: {mail.body}") + return match.group(1) + + def confirm(self, email="user@example.com", password=PASSWORD): + return self.call("POST", "/api/verify", {"token": self.token_from(email, "verify"), "password": password}) + + def login(self, email="user@example.com", password=PASSWORD, remember=True, headers=None): + r = self.call("POST", "/api/login", {"email": email, "password": password}, cookie=None, headers=headers) + if r.status == 200 and remember: + self.cookie = re.match(rf"{re.escape(self.cfg.cookie_name)}=([^;]+)", r.set_cookies[0]).group(1) + return r + + def whoami(self, cookie="auto"): + r = self.call("GET", "/api/me", cookie=cookie) + self.assertEqual(r.status, 200) # гостю тоже 200: так в консоли браузера нет «ошибок» при каждом заходе + return r.json["email"] + + def make_user(self, email="user@example.com", password=PASSWORD): + self.assertEqual(self.register(email).status, 200) + self.assertEqual(self.confirm(email, password).status, 200) + self.assertEqual(self.login(email, password).status, 200) + + def fail_logins(self, count, email="user@example.com", headers=None): + for _ in range(count): + self.assertEqual(self.login(email, WRONG, remember=False, headers=headers).status, 401) + + +class BasicTests(ApiTestCase): + def test_health(self): + r = self.call("GET", "/api/health") + self.assertEqual((r.status, r.json), (200, {"ok": True})) + + def test_unknown_path_and_wrong_method(self): + self.assertEqual(self.call("GET", "/api/nope").status, 404) + r = self.call("GET", "/api/login") + self.assertEqual(r.status, 405) + self.assertIn("POST", r.header("Allow")) + self.assertEqual(self.call("POST", "/api/health", {}).status, 405) + + def test_head_and_options_are_not_supported(self): + self.assertEqual(self.call("HEAD", "/api/health").status, 405) + self.assertEqual(self.call("OPTIONS", "/api/login").status, 405) + + def test_responses_are_not_cacheable_and_nosniff(self): + r = self.call("GET", "/api/health") + self.assertEqual(r.header("Cache-Control"), "no-store") + self.assertEqual(r.header("X-Content-Type-Options"), "nosniff") + self.assertTrue(r.header("Content-Type").startswith("application/json")) + + +class RegistrationTests(ApiTestCase): + def test_register_sends_confirmation_link_and_creates_no_session(self): + r = self.register() + self.assertEqual((r.status, r.json["ok"]), (200, True)) + self.assertEqual(len(self.mails_to("user@example.com")), 1) + self.assertIn("http://127.0.0.1:8080/#verify=", self.mails_to("user@example.com")[0].body) + self.assertEqual(r.set_cookies, []) + + def test_registration_never_sets_a_password(self): + """Пароль выбирает тот, у кого есть доступ к ящику, поэтому подменить его чужой регистрацией нельзя.""" + self.register(password="attackers-password-1") + self.assertEqual(self.login(password="attackers-password-1", remember=False).status, 401) + self.assertEqual(self.confirm(password=OTHER_PASSWORD).status, 200) + self.assertEqual(self.login(password="attackers-password-1", remember=False).status, 401) + self.assertEqual(self.login(password=OTHER_PASSWORD).status, 200) + + def test_cannot_login_before_confirmation(self): + self.register() + r = self.login() + self.assertEqual((r.status, r.json["error"]), (401, "invalid_credentials")) + + def test_confirmation_creates_account_with_chosen_password_without_logging_in(self): + self.register() + r = self.confirm() + self.assertEqual(r.status, 200) + self.assertEqual(r.set_cookies, []) + self.assertEqual(self.login().status, 200) + + def test_confirmation_token_is_single_use(self): + self.register() + token = self.token_from("user@example.com", "verify") + self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": PASSWORD}).status, 200) + r = self.call("POST", "/api/verify", {"token": token, "password": OTHER_PASSWORD}) + self.assertEqual((r.status, r.json["error"]), (400, "invalid_token")) + self.assertEqual(self.login(password=OTHER_PASSWORD, remember=False).status, 401) + + def test_confirmation_token_expires(self): + self.register() + token = self.token_from("user@example.com", "verify") + self.clock.advance(24 * 3600 + 1) + self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": PASSWORD}).json["error"], "invalid_token") + + def test_garbage_tokens_are_rejected(self): + for token in ["", "short", None, 123, "x" * 500, "../../etc/passwd", {"a": 1}]: + with self.subTest(token=token): + self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": PASSWORD}).status, 400) + + def test_weak_password_at_confirmation_does_not_burn_the_link(self): + self.register() + token = self.token_from("user@example.com", "verify") + for bad, code in [("short", "password_too_short"), ("password", "password_weak"), ("x" * 200, "password_too_long"), + ("user@example.com", "password_weak"), (None, "password_too_short")]: + with self.subTest(bad=bad): + self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": bad}).json["error"], code) + self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": PASSWORD}).status, 200) + + def test_lone_surrogate_password_is_a_clean_400_not_a_500(self): + self.register() + token = self.token_from("user@example.com", "verify") + r = self.call("POST", "/api/verify", raw=b'{"token": "%s", "password": "\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800"}' % token.encode()) + self.assertEqual((r.status, r.json["error"]), (400, "password_invalid")) + self.assertEqual(self.call("POST", "/api/verify", {"token": token, "password": PASSWORD}).status, 200) + + def test_latest_registration_link_wins_and_older_dies(self): + self.register() + old = self.token_from("user@example.com", "verify") + self.clock.advance(1) + self.register() + self.assertEqual(self.call("POST", "/api/verify", {"token": old, "password": PASSWORD}).status, 400) + self.assertEqual(self.confirm().status, 200) + + def test_registering_existing_email_is_indistinguishable_and_sends_notice_only(self): + fresh = self.register("fresh@example.com") + self.make_user("user@example.com") + count_before = len(self.mails_to("user@example.com")) + again = self.register("user@example.com") + self.assertEqual((again.status, again.json), (fresh.status, fresh.json)) + notice = self.mails_to("user@example.com")[count_before:] + self.assertEqual(len(notice), 1) + self.assertNotIn("#verify=", notice[0].body) + self.assertEqual(self.login(remember=False).status, 200) + + def test_account_taken_while_link_was_pending_cannot_be_overwritten(self): + self.register() + stale = self.token_from("user@example.com", "verify") + self.register() + self.assertEqual(self.confirm(password=PASSWORD).status, 200) + self.assertEqual(self.call("POST", "/api/verify", {"token": stale, "password": OTHER_PASSWORD}).status, 400) + self.assertEqual(self.login(password=PASSWORD, remember=False).status, 200) + + def test_invalid_emails_are_rejected_without_sending_mail(self): + for bad in ["not-an-email", "a\n@example.com", "user@example\n.com", "a%b@example.com", ["a@example.com"], None, 5]: + with self.subTest(bad=bad): + self.assertEqual(self.register(bad).json["error"], "invalid_email") + self.clock.advance(1000) # не упираемся в лимит регистраций с одного IP + self.assertEqual(self.mail.sent, []) + + def test_email_is_normalized(self): + self.register(" User@Example.COM ") + self.assertEqual(len(self.mails_to("user@example.com")), 1) + self.confirm("user@example.com") + self.assertEqual(self.login("USER@example.com").status, 200) + + def test_registration_rate_limit_per_ip(self): + statuses = [self.register(f"u{i}@example.com").status for i in range(7)] + self.assertEqual(statuses[:5], [200] * 5) + self.assertEqual(statuses[5:], [429, 429]) + self.assertIsNotNone(self.register("late@example.com").header("Retry-After")) + + def test_per_email_limit_stays_silent(self): + for _ in range(3): + self.assertEqual(self.register("same@example.com").status, 200) + self.clock.advance(1000) # чтобы не упереться в лимит по IP + before = len(self.mails_to("same@example.com")) + r = self.register("same@example.com") + self.assertEqual((r.status, r.json["ok"]), (200, True)) + self.assertEqual(len(self.mails_to("same@example.com")), before) + + def test_expired_registrations_are_purged_periodically_not_on_every_request(self): + self.register("old@example.com") + self.clock.advance(24 * 3600 + 1) + self.register("new1@example.com") # первая чистка + self.assertEqual(Store(self.db_path).count("registrations"), 1) + self.clock.advance(1) + self.register("new2@example.com") + self.assertEqual(Store(self.db_path).count("registrations"), 2) + + +class RegistrationClosedTests(ApiTestCase): + env_extra = {"ALLOW_REGISTRATION": "false"} + + def test_registration_can_be_disabled(self): + r = self.register() + self.assertEqual((r.status, r.json["error"]), (403, "registration_closed")) + self.assertEqual(self.mail.sent, []) + + +class MailQueueFullMailer: + def send(self, mail): + raise MailQueueFull + + +class MailOverloadTests(ApiTestCase): + def make_mailer(self): + return MailQueueFullMailer() + + def test_overloaded_mail_queue_is_reported_instead_of_pretending_the_mail_was_sent(self): + r = self.register() + self.assertEqual((r.status, r.json["error"]), (503, "busy")) + self.assertIsNotNone(r.header("Retry-After")) + + def test_forgot_does_not_reveal_which_addresses_exist_when_the_queue_is_full(self): + """При переполненной очереди известный адрес не должен отвечать иначе, чем неизвестный.""" + Store(self.db_path).create_user("known@example.com", security.hash_password(PASSWORD), self.clock()) + known = self.call("POST", "/api/forgot", {"email": "known@example.com"}) + unknown = self.call("POST", "/api/forgot", {"email": "ghost@example.com"}) + self.assertEqual((known.status, known.json), (unknown.status, unknown.json)) + self.assertEqual(known.status, 200) + + def test_password_change_notice_failing_does_not_undo_the_reset(self): + Store(self.db_path).create_user("known@example.com", security.hash_password(PASSWORD), self.clock()) + user = Store(self.db_path).user_by_email("known@example.com") + Store(self.db_path).save_reset_token(user["id"], security.hash_token("T" * 43), self.clock() + 3600) + r = self.call("POST", "/api/reset", {"token": "T" * 43, "password": OTHER_PASSWORD}) + self.assertEqual(r.status, 200) + self.assertEqual(self.login("known@example.com", OTHER_PASSWORD, remember=False).status, 200) + + +class LoginTests(ApiTestCase): + def test_login_sets_hardened_cookie_and_me_works(self): + self.make_user() + r = self.call("POST", "/api/login", {"email": "user@example.com", "password": PASSWORD}, cookie=None) + cookie = r.set_cookies[0] + for flag in ("HttpOnly", "SameSite=Lax", "Path=/", "Max-Age=2592000"): + self.assertIn(flag, cookie) + self.assertNotIn("Secure", cookie) # PUBLIC_URL по http + self.assertEqual(self.whoami(), "user@example.com") + + def test_wrong_password_and_unknown_user_look_identical(self): + self.make_user() + wrong = self.login(password=WRONG, remember=False) + unknown = self.login(email="ghost@example.com", remember=False) + self.assertEqual((wrong.status, wrong.json), (unknown.status, unknown.json)) + self.assertEqual(wrong.status, 401) + + def test_me_reports_guest_with_null_email(self): + self.assertIsNone(self.whoami(cookie=None)) + self.assertIsNone(self.whoami(cookie="x" * 43)) + + def test_garbage_and_duplicate_cookies_mean_guest_not_an_error(self): + self.make_user() + for header in ["session=;;;====", "\x01\x02", "=", ";", f"{self.cfg.cookie_name}={self.cookie}; {self.cfg.cookie_name}=other"]: + with self.subTest(header=header): + r = self.call("GET", "/api/me", cookie=None, headers={"Cookie": header}) + self.assertEqual((r.status, r.json["email"]), (200, None)) + + def test_logout_invalidates_session_and_clears_cookie(self): + self.make_user() + r = self.call("POST", "/api/logout", {}) + self.assertEqual(r.status, 200) + self.assertIn("Max-Age=0", r.set_cookies[0]) + self.assertIsNone(self.whoami()) + + def test_session_expires(self): + self.make_user() + self.clock.advance(30 * 24 * 3600 + 1) + self.assertIsNone(self.whoami()) + + def test_failed_logins_per_ip_are_limited_and_recover(self): + for i in range(20): + self.assertEqual(self.login(f"x{i}@example.com", WRONG, remember=False).status, 401) + r = self.login("y@example.com", WRONG, remember=False) + self.assertEqual(r.status, 429) + self.assertGreaterEqual(int(r.header("Retry-After")), 1) + self.clock.advance(601) + self.assertEqual(self.login("y@example.com", WRONG, remember=False).status, 401) + + def test_successful_logins_do_not_use_up_the_ip_allowance(self): + """Класс или семья за одним NAT входят много раз подряд: успехи не считаются.""" + self.make_user() + for _ in range(30): + self.assertEqual(self.login(remember=False).status, 200) + + def test_password_guessing_for_one_account_from_one_ip_is_blocked_even_with_the_right_password(self): + self.make_user() + self.fail_logins(5) + self.assertEqual(self.login(remember=False).status, 429) + + def test_successful_logins_do_not_count_toward_the_account_lockout(self): + self.make_user() + self.fail_logins(4) + for _ in range(3): + self.assertEqual(self.login(remember=False).status, 200) + self.fail_logins(1) + self.assertEqual(self.login(remember=False).status, 429) + + def test_lockout_expires(self): + self.make_user() + self.fail_logins(5) + self.clock.advance(601) + self.assertEqual(self.login(remember=False).status, 200) + + def test_busy_hasher_gives_503_with_retry_after(self): + self.make_user() + with mock.patch.object(security, "_slots", threading.BoundedSemaphore(0)), mock.patch.object(security, "SLOT_WAIT", 0.05): + r = self.login(remember=False) + self.assertEqual((r.status, r.json["error"]), (503, "busy")) + self.assertIsNotNone(r.header("Retry-After")) + + def test_lone_surrogate_password_at_login_is_a_clean_401(self): + self.make_user() + r = self.call("POST", "/api/login", raw=b'{"email": "user@example.com", "password": "\\ud800\\ud800\\ud800"}', cookie=None) + self.assertEqual((r.status, r.json["error"]), (401, "invalid_credentials")) + + def test_rehash_never_overwrites_a_password_reset_that_happened_meanwhile(self): + """Старый (скомпрометированный) пароль не должен вернуться из-за перехеширования, начавшегося до сброса.""" + self.make_user() + store = Store(self.db_path) + uid = store.user_by_email("user@example.com")["id"] + real_hash = security.hash_password + + def racing_hash(password, n=None): + store.set_password(uid, "HASH-AFTER-RESET") # владелец успел сменить пароль, пока считался новый хеш + return real_hash(password, n) + + with mock.patch.object(security, "needs_rehash", return_value=True), mock.patch.object(security, "hash_password", racing_hash): + r = self.login(remember=False) + self.assertEqual((r.status, r.json["error"]), (401, "invalid_credentials")) + self.assertEqual(store.user_by_email("user@example.com")["pw_hash"], "HASH-AFTER-RESET") + self.assertIsNone(self.whoami(cookie=None)) + + def test_password_hash_is_upgraded_on_login(self): + self.make_user() + old_n = security.SCRYPT_N + security.SCRYPT_N = 2**11 + try: + self.assertEqual(self.login(remember=False).status, 200) + stored = Store(self.db_path).user_by_email("user@example.com")["pw_hash"] + self.assertTrue(stored.startswith("scrypt$2048$")) + self.assertEqual(self.login(remember=False).status, 200) # новым хешем тоже входит + finally: + security.SCRYPT_N = old_n + + +class PasswordResetTests(ApiTestCase): + def test_forgot_for_unknown_email_is_indistinguishable_and_sends_nothing(self): + self.make_user() + known = self.call("POST", "/api/forgot", {"email": "user@example.com"}) + unknown = self.call("POST", "/api/forgot", {"email": "ghost@example.com"}) + self.assertEqual((known.status, known.json), (unknown.status, unknown.json)) + self.assertEqual(self.mails_to("ghost@example.com"), []) + self.assertIn("#reset=", self.mails_to("user@example.com")[-1].body) + + def test_forgot_rejects_malformed_email(self): + self.assertEqual(self.call("POST", "/api/forgot", {"email": "nope"}).json["error"], "invalid_email") + + def test_full_reset_flow(self): + self.make_user() + old_cookie = self.cookie + self.call("POST", "/api/forgot", {"email": "user@example.com"}) + token = self.token_from("user@example.com", "reset") + r = self.call("POST", "/api/reset", {"token": token, "password": OTHER_PASSWORD}, cookie=None) + self.assertEqual(r.status, 200) + self.assertEqual(self.login(password=PASSWORD, remember=False).status, 401) + self.assertEqual(self.login(password=OTHER_PASSWORD).status, 200) + self.assertIsNone(self.whoami(cookie=old_cookie)) # старые сессии отозваны + self.assertIn("пароль", self.mails_to("user@example.com")[-1].subject.lower()) + + def test_reset_token_is_single_use(self): + self.make_user() + self.call("POST", "/api/forgot", {"email": "user@example.com"}) + token = self.token_from("user@example.com", "reset") + self.assertEqual(self.call("POST", "/api/reset", {"token": token, "password": OTHER_PASSWORD}).status, 200) + r = self.call("POST", "/api/reset", {"token": token, "password": "third password!"}) + self.assertEqual((r.status, r.json["error"]), (400, "invalid_token")) + + def test_bad_new_password_does_not_burn_the_token(self): + self.make_user() + self.call("POST", "/api/forgot", {"email": "user@example.com"}) + token = self.token_from("user@example.com", "reset") + self.assertEqual(self.call("POST", "/api/reset", {"token": token, "password": "123"}).json["error"], "password_too_short") + surrogate = self.call("POST", "/api/reset", raw=b'{"token": "%s", "password": "\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800\\ud800"}' % token.encode()) + self.assertEqual((surrogate.status, surrogate.json["error"]), (400, "password_invalid")) + self.assertEqual(self.call("POST", "/api/reset", {"token": token, "password": OTHER_PASSWORD}).status, 200) + + def test_reset_token_expires_after_one_hour(self): + self.make_user() + self.call("POST", "/api/forgot", {"email": "user@example.com"}) + token = self.token_from("user@example.com", "reset") + self.clock.advance(3601) + self.assertEqual(self.call("POST", "/api/reset", {"token": token, "password": OTHER_PASSWORD}).json["error"], "invalid_token") + + def test_new_request_invalidates_previous_link(self): + self.make_user() + self.call("POST", "/api/forgot", {"email": "user@example.com"}) + first = self.token_from("user@example.com", "reset") + self.clock.advance(5) + self.call("POST", "/api/forgot", {"email": "user@example.com"}) + self.assertEqual(self.call("POST", "/api/reset", {"token": first, "password": OTHER_PASSWORD}).status, 400) + self.assertEqual(self.call("POST", "/api/reset", {"token": self.token_from("user@example.com", "reset"), "password": OTHER_PASSWORD}).status, 200) + + def test_forgot_rate_limit_per_email_is_silent(self): + self.make_user() + for _ in range(5): + self.assertEqual(self.call("POST", "/api/forgot", {"email": "user@example.com"}).status, 200) + resets = [m for m in self.mails_to("user@example.com") if "#reset=" in m.body] + self.assertEqual(len(resets), 3) + + def test_forgot_rate_limit_per_ip(self): + statuses = [self.call("POST", "/api/forgot", {"email": f"a{i}@example.com"}).status for i in range(7)] + self.assertEqual(statuses[-1], 429) + + def test_reset_cannot_be_used_with_a_verify_token(self): + self.register() + token = self.token_from("user@example.com", "verify") + self.assertEqual(self.call("POST", "/api/reset", {"token": token, "password": OTHER_PASSWORD}).status, 400) + + def test_session_created_by_a_concurrent_login_does_not_survive_the_reset(self): + """Вход, начавшийся со старым паролем, не должен пережить сброс пароля.""" + self.make_user() + stale_hash = Store(self.db_path).user_by_email("user@example.com")["pw_hash"] + self.call("POST", "/api/forgot", {"email": "user@example.com"}) + self.call("POST", "/api/reset", {"token": self.token_from("user@example.com", "reset"), "password": OTHER_PASSWORD}) + user = Store(self.db_path).user_by_email("user@example.com") + self.assertFalse(Store(self.db_path).create_session(user["id"], "late-session-hash", 2**40, 1, expect_pw_hash=stale_hash)) + + +class DeleteAccountTests(ApiTestCase): + def test_requires_login(self): + self.assertEqual(self.call("POST", "/api/delete-account", {"password": PASSWORD}, cookie=None).status, 401) + + def test_wrong_password_keeps_the_account(self): + self.make_user() + r = self.call("POST", "/api/delete-account", {"password": WRONG}) + self.assertEqual((r.status, r.json["error"]), (401, "wrong_password")) + self.assertEqual(self.login(remember=False).status, 200) + + def test_deletes_account_progress_and_sessions(self): + self.make_user() + self.call("PUT", "/api/state", {"version": 0, "data": {"progress": {"ru-A1": {"best": 1, "acc": 1, "stars": 1, "tries": 1}}}}) + r = self.call("POST", "/api/delete-account", {"password": PASSWORD}) + self.assertEqual(r.status, 200) + self.assertIn("Max-Age=0", r.set_cookies[0]) + self.assertIsNone(self.whoami()) + self.assertEqual(self.login(remember=False).status, 401) + store = Store(self.db_path) + self.assertEqual((store.count("users"), store.count("user_state"), store.count("sessions")), (0, 0, 0)) + + def test_password_guessing_here_is_limited_too(self): + self.make_user() + for _ in range(5): + self.assertEqual(self.call("POST", "/api/delete-account", {"password": WRONG}).status, 401) + self.assertEqual(self.call("POST", "/api/delete-account", {"password": PASSWORD}).status, 429) + + def test_email_can_register_again_after_deletion(self): + self.make_user() + self.call("POST", "/api/delete-account", {"password": PASSWORD}) + self.cookie = None + self.make_user(password=OTHER_PASSWORD) + self.assertEqual(self.whoami(), "user@example.com") + + +class RequestHardeningTests(ApiTestCase): + def test_missing_csrf_header_is_rejected(self): + r = self.call("POST", "/api/login", {"email": "a@example.com", "password": "x" * 10}, + headers={"Content-Type": "application/json", "Origin": self.cfg.origin}, csrf=False) + self.assertEqual((r.status, r.json["error"]), (403, "csrf")) + + def test_foreign_origin_is_rejected_and_logged_with_expected_value(self): + with self.assertLogs("splitapi", level="WARNING") as logs: + r = self.call("POST", "/api/login", {"email": "a@example.com", "password": "x" * 10}, headers={"Origin": "https://evil.example"}) + self.assertEqual((r.status, r.json["error"]), (403, "csrf")) + text = "\n".join(logs.output) + self.assertIn("https://evil.example", text) + self.assertIn(self.cfg.origin, text) + + def test_origin_null_is_rejected(self): + r = self.call("POST", "/api/login", {"email": "a@example.com", "password": "x" * 10}, headers={"Origin": "null"}) + self.assertEqual(r.status, 403) + + def test_request_without_origin_but_with_custom_header_is_allowed(self): + hdrs = {"Content-Type": "application/json", "X-Requested-With": "split-trainer"} + r = self.call("POST", "/api/login", {"email": "a@example.com", "password": "x" * 10}, headers=hdrs, csrf=False) + self.assertEqual(r.status, 401) + + def test_wrong_content_type_is_rejected(self): + r = self.call("POST", "/api/login", raw=b'{"email":"a@example.com"}', headers={"Content-Type": "text/plain"}) + self.assertEqual(r.status, 415) + + def test_invalid_json_and_non_object_bodies(self): + self.assertEqual(self.call("POST", "/api/login", raw=b"{not json").json["error"], "invalid_json") + self.assertEqual(self.call("POST", "/api/login", raw=b"[1,2,3]").json["error"], "invalid_json") + self.assertEqual(self.call("POST", "/api/login", raw=b"").status, 400) + self.assertEqual(self.call("POST", "/api/login", raw=b'{"a": NaN}').status, 400) + + def test_oversized_body_is_rejected_before_reading(self): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + conn.putrequest("POST", "/api/login") + for k, v in {"Content-Type": "application/json", "X-Requested-With": "split-trainer", "Content-Length": "5000000"}.items(): + conn.putheader(k, v) + conn.endheaders() + self.assertEqual(conn.getresponse().status, 413) + conn.close() + + def test_errors_do_not_leak_internals(self): + r = self.call("POST", "/api/login", raw=b"\xff\xfe") + self.assertNotIn("Traceback", r.text) + self.assertLess(r.status, 500) + + +class StateTests(ApiTestCase): + GOOD = {"progress": {"ru-A1": {"best": 100, "acc": 95, "stars": 1, "tries": 1}}, "history": [], "keys": {"ru": {}, "en": {}}} + + def test_state_requires_login(self): + self.assertEqual(self.call("GET", "/api/state", cookie=None).status, 401) + self.assertEqual(self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD}, cookie=None).status, 401) + + def test_roundtrip_and_versioning(self): + self.make_user() + first = self.call("GET", "/api/state") + self.assertEqual((first.json["version"], first.json["data"]["progress"]), (0, {})) + saved = self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD}) + self.assertEqual((saved.status, saved.json["version"]), (200, 1)) + got = self.call("GET", "/api/state").json + self.assertEqual((got["version"], got["data"]["progress"]["ru-A1"]["best"]), (1, 100)) + + def test_state_response_names_the_account_so_a_stale_tab_can_notice_a_switch(self): + self.make_user("a@example.com") + self.assertEqual(self.call("GET", "/api/state").json["email"], "a@example.com") + + def test_saving_on_behalf_of_another_account_is_refused(self): + """Вкладка, открытая под аккаунтом A, не должна записать данные в аккаунт B после смены входа в другой вкладке.""" + self.make_user("b@example.com") + r = self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD, "email": "a@example.com"}) + self.assertEqual((r.status, r.json["error"]), (409, "account_mismatch")) + self.assertEqual(self.call("GET", "/api/state").json["data"]["progress"], {}) + ok = self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD, "email": "B@Example.com"}) + self.assertEqual(ok.status, 200) + + def test_saving_without_an_email_still_works_for_older_clients(self): + self.make_user() + self.assertEqual(self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD}).status, 200) + + def test_progress_can_be_cleared_by_saving_an_empty_state(self): + self.make_user() + self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD}) + self.assertEqual(self.call("PUT", "/api/state", {"version": 1, "data": {}}).status, 200) + self.assertEqual(self.call("GET", "/api/state").json["data"]["progress"], {}) + + def test_stale_version_gets_conflict_with_current_state(self): + self.make_user() + self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD}) + r = self.call("PUT", "/api/state", {"version": 0, "data": {"progress": {}, "history": [], "keys": {}}}) + self.assertEqual((r.status, r.json["error"], r.json["version"]), (409, "version_conflict", 1)) + self.assertIn("ru-A1", r.json["data"]["progress"]) + + def test_parallel_writes_with_the_same_version_let_exactly_one_through(self): + self.make_user() + statuses, gate = [], threading.Barrier(2) + + def writer(): + gate.wait() + statuses.append(self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD}).status) + + threads = [threading.Thread(target=writer) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join() + self.assertEqual(sorted(statuses), [200, 409]) + + def test_invalid_payloads(self): + self.make_user() + for body in [{"version": "x", "data": self.GOOD}, {"version": -1, "data": self.GOOD}, {"version": 0}, + {"version": 0, "data": []}, {"version": 0, "data": {"progress": []}}]: + with self.subTest(body=body): + self.assertEqual(self.call("PUT", "/api/state", body).status, 400) + + def test_lone_surrogate_key_does_not_crash_saving(self): + self.make_user() + raw = b'{"version": 0, "data": {"keys": {"ru": {"\\ud800": {"n": 1}}, "en": {}}}}' + self.assertEqual(self.call("PUT", "/api/state", raw=raw).status, 200) + + def test_unknown_fields_are_not_stored(self): + self.make_user() + self.call("PUT", "/api/state", {"version": 0, "data": {**self.GOOD, "evil": ""}}) + self.assertNotIn("evil", self.call("GET", "/api/state").json["data"]) + + def test_state_is_isolated_between_users(self): + self.make_user("a@example.com") + self.call("PUT", "/api/state", {"version": 0, "data": self.GOOD}) + self.cookie = None + self.make_user("b@example.com", OTHER_PASSWORD) + self.assertEqual(self.call("GET", "/api/state").json["data"]["progress"], {}) + + +class SecretsAtRestTests(ApiTestCase): + def test_database_never_contains_passwords_or_raw_tokens(self): + self.register() + verify_token = self.token_from("user@example.com", "verify") + self.confirm() + self.login() + self.call("POST", "/api/forgot", {"email": "user@example.com"}) + reset_token = self.token_from("user@example.com", "reset") + conn = sqlite3.connect(self.db_path) + try: + dump = "\n".join(conn.iterdump()) + finally: + conn.close() + for secret in (PASSWORD, verify_token, reset_token, self.cookie): + self.assertNotIn(secret, dump) + + +class HttpsCookieTests(ApiTestCase): + public_url = "https://trainer.example.com/app" + + def test_cookie_is_secure_and_host_prefixed_when_site_is_https(self): + self.make_user() + r = self.call("POST", "/api/login", {"email": "user@example.com", "password": PASSWORD}, cookie=None) + cookie = r.set_cookies[0] + self.assertTrue(cookie.startswith("__Host-session=")) + for flag in ("Secure", "HttpOnly", "SameSite=Lax", "Path=/"): + self.assertIn(flag, cookie) + self.assertNotIn("Domain", cookie) + + def test_links_use_public_url_with_path_not_the_host_header(self): + self.register("user@example.com") + r = self.call("POST", "/api/forgot", {"email": "user@example.com"}, headers={"Host": "attacker.example"}) + self.assertEqual(r.status, 200) + self.assertTrue(all("attacker.example" not in m.body for m in self.mail.sent)) + self.assertIn("https://trainer.example.com/app/#verify=", self.mails_to("user@example.com")[0].body) + + def test_origin_check_matches_normalized_public_url(self): + self.assertEqual(self.cfg.origin, "https://trainer.example.com") + r = self.call("POST", "/api/login", {"email": "a@example.com", "password": "x" * 10}, headers={"Origin": "https://trainer.example.com"}) + self.assertEqual(r.status, 401) + + +class ClientIpTests(ApiTestCase): + env_extra = {"TRUST_PROXY": "1"} + + def test_trusted_proxy_header_separates_clients(self): + for i in range(20): + self.login(f"x{i}@example.com", WRONG, remember=False, headers={"X-Real-IP": "203.0.113.5"}) + blocked = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "203.0.113.5"}) + other = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "203.0.113.6"}) + self.assertEqual((blocked.status, other.status), (429, 401)) + + def test_attacker_cannot_lock_the_owner_out_from_another_address(self): + self.make_user() + self.fail_logins(5, headers={"X-Real-IP": "203.0.113.5"}) + self.assertEqual(self.login(remember=False, headers={"X-Real-IP": "203.0.113.5"}).status, 429) + self.assertEqual(self.login(remember=False, headers={"X-Real-IP": "198.51.100.7"}).status, 200) + + def test_distributed_guessing_hits_the_account_wide_ceiling(self): + self.make_user() + for n in range(20): # 20 адресов по 5 неудач = 100 неудач на один аккаунт за час + self.fail_logins(5, headers={"X-Real-IP": f"203.0.113.{n + 10}"}) + self.assertEqual(self.login(remember=False, headers={"X-Real-IP": "198.51.100.99"}).status, 429) + + def test_the_account_wide_ceiling_leaves_room_for_the_owner_after_a_slow_attack(self): + """Атака вполсилы (30 неудач за час с разных адресов) не должна запирать владельца.""" + self.make_user() + for n in range(6): + self.fail_logins(5, headers={"X-Real-IP": f"203.0.113.{n + 10}"}) + self.assertEqual(self.login(remember=False, headers={"X-Real-IP": "198.51.100.99"}).status, 200) + + def test_ipv6_clients_of_one_network_share_a_counter(self): + for i in range(20): + self.login(f"x{i}@example.com", WRONG, remember=False, headers={"X-Real-IP": f"2001:db8:1:2::{i + 1:x}"}) + r = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "2001:db8:1:2:ffff:ffff:ffff:fff0"}) + self.assertEqual(r.status, 429) + other = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "2001:db8:1:3::1"}) + self.assertEqual(other.status, 401) + + def test_garbage_proxy_header_falls_back_to_peer(self): + r = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "not-an-ip"}) + self.assertEqual(r.status, 401) + + +class UntrustedProxyTests(ApiTestCase): + def test_header_is_ignored_when_proxy_is_not_trusted(self): + for i in range(20): + self.login(f"x{i}@example.com", WRONG, remember=False, headers={"X-Real-IP": f"203.0.113.{i + 10}"}) + r = self.login("z@example.com", WRONG, remember=False, headers={"X-Real-IP": "198.51.100.1"}) + self.assertEqual(r.status, 429) + + +class IpHelpersTests(unittest.TestCase): + def test_only_private_and_loopback_peers_may_set_forwarded_headers(self): + for ok in ["127.0.0.1", "::1", "10.1.2.3", "172.17.0.1", "192.168.65.1", "fd00::1", "::ffff:10.0.0.5"]: + with self.subTest(ip=ok): + self.assertTrue(is_trusted_proxy_peer(ok)) + for bad in ["8.8.8.8", "203.0.113.5", "2001:db8::1", "garbage", "", "::ffff:8.8.8.8"]: + with self.subTest(ip=bad): + self.assertFalse(is_trusted_proxy_peer(bad)) + + def test_ipv6_is_reduced_to_its_64_bit_network(self): + self.assertEqual(normalize_ip("2001:db8:1:2::1"), normalize_ip("2001:db8:1:2:ffff::9")) + self.assertNotEqual(normalize_ip("2001:db8:1:2::1"), normalize_ip("2001:db8:1:3::1")) + self.assertEqual(normalize_ip("203.0.113.5"), "203.0.113.5") + self.assertEqual(normalize_ip("::ffff:203.0.113.5"), "203.0.113.5") + self.assertIsNone(normalize_ip("nope")) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_config.py b/server/tests/test_config.py new file mode 100644 index 0000000..ee66e66 --- /dev/null +++ b/server/tests/test_config.py @@ -0,0 +1,120 @@ +"""Тесты чтения настроек из переменных окружения.""" +import os +import tempfile +import unittest + +from splitapi.config import Config, ConfigError, from_env + +BASE = {"PUBLIC_URL": "https://trainer.example.com"} +DEV_MAIL = {**BASE, "MAIL_BACKEND": "console"} # явное согласие на режим «письма в лог» + + +class ConfigTests(unittest.TestCase): + def test_minimal_local_config_has_safe_defaults(self): + cfg = from_env({"PUBLIC_URL": "http://127.0.0.1:8080"}) + self.assertEqual(cfg.bind, "127.0.0.1") + self.assertEqual(cfg.port, 8000) + self.assertEqual(cfg.mail_backend, "console") + self.assertTrue(cfg.allow_registration) + self.assertFalse(cfg.trust_proxy) + + def test_public_url_is_required_and_validated(self): + for env in [{}, {"PUBLIC_URL": ""}, {"PUBLIC_URL": "ftp://x.org"}, {"PUBLIC_URL": "not a url"}, + {"PUBLIC_URL": "https://x.org/?q=1"}, {"PUBLIC_URL": "https://x.org/#frag"}, {"PUBLIC_URL": "https://"}, + {"PUBLIC_URL": "https://user:pw@x.org"}]: + with self.subTest(env=env), self.assertRaises(ConfigError): + from_env({**env, "MAIL_BACKEND": "console"}) + + def test_derived_urls(self): + cfg = from_env({**DEV_MAIL, "PUBLIC_URL": "https://example.com/trainer"}) + self.assertEqual(cfg.origin, "https://example.com") + self.assertEqual(cfg.base_url, "https://example.com/trainer/") + self.assertTrue(cfg.secure_cookie) + + def test_http_url_gives_non_secure_cookie_and_keeps_port_in_origin(self): + cfg = from_env({"PUBLIC_URL": "http://127.0.0.1:8080/"}) + self.assertEqual(cfg.origin, "http://127.0.0.1:8080") + self.assertEqual(cfg.base_url, "http://127.0.0.1:8080/") + self.assertFalse(cfg.secure_cookie) + + def test_public_url_is_normalized_like_a_browser_origin(self): + cfg = from_env({**DEV_MAIL, "PUBLIC_URL": "https://Example.COM:443/app/"}) + self.assertEqual((cfg.public_url, cfg.origin), ("https://example.com/app", "https://example.com")) + self.assertEqual(from_env({**DEV_MAIL, "PUBLIC_URL": "http://example.com:80"}).origin, "http://example.com") + self.assertEqual(from_env({**DEV_MAIL, "PUBLIC_URL": "https://example.com:8443"}).origin, "https://example.com:8443") + + def test_international_domain_is_stored_as_punycode_like_browsers_send_it_in_origin(self): + cfg = from_env({**DEV_MAIL, "PUBLIC_URL": "https://тренер.рф/app"}) + self.assertEqual(cfg.origin, "https://xn--e1aaxkdh.xn--p1ai") + self.assertEqual(cfg.public_url, "https://xn--e1aaxkdh.xn--p1ai/app") + + def test_cookie_name_uses_host_prefix_only_over_https(self): + self.assertEqual(from_env(DEV_MAIL).cookie_name, "__Host-session") + self.assertEqual(from_env({"PUBLIC_URL": "http://127.0.0.1:8080"}).cookie_name, "session") + + def test_smtp_host_switches_backend_to_smtp(self): + cfg = from_env({**BASE, "SMTP_HOST": "smtp.example.com", "SMTP_USER": "u", "SMTP_PASSWORD": "p"}) + self.assertEqual(cfg.mail_backend, "smtp") + self.assertEqual((cfg.smtp_host, cfg.smtp_port, cfg.smtp_security), ("smtp.example.com", 587, "starttls")) + + def test_console_mailer_is_not_the_silent_default_on_a_public_site(self): + with self.assertRaises(ConfigError) as ctx: + from_env(BASE) + self.assertIn("SMTP", str(ctx.exception)) + for local in ("http://localhost:8080", "http://127.0.0.1", "http://[::1]:8080"): + with self.subTest(local=local): + self.assertEqual(from_env({"PUBLIC_URL": local}).mail_backend, "console") + + def test_explicit_backend_wins_and_is_validated(self): + self.assertEqual(from_env({**BASE, "SMTP_HOST": "h", "MAIL_BACKEND": "console"}).mail_backend, "console") + with self.assertRaises(ConfigError): + from_env({**BASE, "MAIL_BACKEND": "carrier-pigeon"}) + with self.assertRaises(ConfigError): + from_env({**BASE, "MAIL_BACKEND": "smtp"}) # без SMTP_HOST + + def test_smtp_security_validated(self): + with self.assertRaises(ConfigError): + from_env({**BASE, "SMTP_HOST": "h", "SMTP_SECURITY": "magic"}) + self.assertEqual(from_env({**BASE, "SMTP_HOST": "h", "SMTP_SECURITY": "ssl", "SMTP_PORT": "465"}).smtp_port, 465) + + def test_password_from_file_for_docker_secrets(self): + with tempfile.NamedTemporaryFile("w", delete=False, suffix=".txt") as f: + f.write("file-secret\n") + try: + cfg = from_env({**BASE, "SMTP_HOST": "h", "SMTP_PASSWORD_FILE": f.name}) + self.assertEqual(cfg.smtp_password, "file-secret") + finally: + os.unlink(f.name) + + def test_missing_password_file_is_an_error(self): + with self.assertRaises(ConfigError): + from_env({**BASE, "SMTP_HOST": "h", "SMTP_PASSWORD_FILE": "/nonexistent/secret"}) + + def test_booleans_and_numbers(self): + cfg = from_env({**DEV_MAIL, "ALLOW_REGISTRATION": "false", "TRUST_PROXY": "1", "PORT": "9000", "SESSION_DAYS": "7"}) + self.assertFalse(cfg.allow_registration) + self.assertTrue(cfg.trust_proxy) + self.assertEqual((cfg.port, cfg.session_days), (9000, 7)) + + def test_invalid_values_rejected(self): + for env in [{"PORT": "0"}, {"PORT": "99999"}, {"PORT": "abc"}, {"SESSION_DAYS": "0"}, {"ALLOW_REGISTRATION": "maybe"}]: + with self.subTest(env=env), self.assertRaises(ConfigError): + from_env({**DEV_MAIL, **env}) + + def test_empty_values_fall_back_to_defaults(self): + """docker compose передаёт незаданные переменные пустыми строками.""" + cfg = from_env({**DEV_MAIL, "PORT": "", "SESSION_DAYS": "", "SMTP_PORT": "", "SMTP_SECURITY": "", "MAIL_FROM": ""}) + self.assertEqual((cfg.port, cfg.session_days, cfg.smtp_port, cfg.smtp_security), (8000, 30, 587, "starttls")) + + def test_default_sender_uses_site_host(self): + self.assertIn("noreply@trainer.example.com", from_env(DEV_MAIL).mail_from) + + def test_config_is_immutable(self): + cfg = from_env(DEV_MAIL) + with self.assertRaises(Exception): + cfg.port = 1 # type: ignore[misc] + self.assertIsInstance(cfg, Config) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_mailer.py b/server/tests/test_mailer.py new file mode 100644 index 0000000..2f09f14 --- /dev/null +++ b/server/tests/test_mailer.py @@ -0,0 +1,206 @@ +"""Тесты отправки писем и шаблонов.""" +import dataclasses +import logging +import threading +import unittest +from unittest import mock + +from splitapi import mailer +from splitapi.config import from_env + +CFG = from_env({"PUBLIC_URL": "https://example.com/trainer", "SMTP_HOST": "smtp.example.com", "SMTP_USER": "user", + "SMTP_PASSWORD": "pw", "MAIL_FROM": "Тренер "}) + + +class TemplateTests(unittest.TestCase): + def test_verify_mail_has_fragment_link_not_query(self): + mail = mailer.verify_mail(CFG, "u@example.com", "TOKEN123") + self.assertEqual(mail.to, "u@example.com") + self.assertIn("https://example.com/trainer/#verify=TOKEN123", mail.body) + self.assertNotIn("?verify", mail.body) + + def test_reset_mail_has_fragment_link(self): + mail = mailer.reset_mail(CFG, "u@example.com", "TOK") + self.assertIn("https://example.com/trainer/#reset=TOK", mail.body) + self.assertIn("1 час", mail.body) + + def test_validity_period_comes_from_the_config_not_from_a_constant(self): + longer = dataclasses.replace(CFG, reset_ttl=2 * 3600, verify_ttl=3 * 24 * 3600) + shorter = dataclasses.replace(CFG, reset_ttl=30 * 60) + self.assertIn("2 часа", mailer.reset_mail(longer, "u@example.com", "T").body) + self.assertIn("3 дня", mailer.verify_mail(longer, "u@example.com", "T").body) + self.assertIn("30 минут", mailer.reset_mail(shorter, "u@example.com", "T").body) + + def test_human_duration_uses_correct_russian_plurals(self): + cases = {3600: "1 час", 7200: "2 часа", 5 * 3600: "5 часов", 21 * 3600: "21 час", 24 * 3600: "24 часа", + 86400 * 3: "3 дня", 86400 * 7: "7 дней", 60: "1 минута", 120: "2 минуты", 15 * 60: "15 минут"} + for seconds, expected in cases.items(): + with self.subTest(seconds=seconds): + self.assertEqual(mailer.human_duration(seconds), expected) + + def test_notice_mails_have_no_tokens(self): + for mail in (mailer.already_registered_mail(CFG, "u@example.com"), mailer.password_changed_mail(CFG, "u@example.com")): + self.assertNotIn("#verify=", mail.body) + self.assertNotIn("#reset=", mail.body) + + def test_messages_are_russian_and_have_subject(self): + for mail in (mailer.verify_mail(CFG, "u@example.com", "T"), mailer.reset_mail(CFG, "u@example.com", "T")): + self.assertTrue(mail.subject) + self.assertRegex(mail.body, r"[А-Яа-я]") + + +class BuildMessageTests(unittest.TestCase): + def test_headers_and_utf8_body(self): + msg = mailer.build_message(mailer.Mail("u@example.com", "Тема письма", "Текст на русском"), CFG.mail_from) + self.assertEqual(msg["To"], "u@example.com") + self.assertIn("noreply@example.com", msg["From"]) + self.assertEqual(msg.get_content().strip(), "Текст на русском") + + def test_header_injection_is_impossible(self): + for bad_to, bad_subject in [("a@example.com\r\nBcc: evil@x.org", "ok"), ("a@example.com", "x\r\nBcc: evil@x.org")]: + with self.subTest(to=bad_to, subject=bad_subject), self.assertRaises(ValueError): + mailer.build_message(mailer.Mail(bad_to, bad_subject, "body"), CFG.mail_from) + + +class MemoryMailerTests(unittest.TestCase): + def test_collects_messages(self): + m = mailer.MemoryMailer() + m.send(mailer.Mail("a@example.com", "s", "b")) + self.assertEqual([x.to for x in m.sent], ["a@example.com"]) + + +class ConsoleMailerTests(unittest.TestCase): + def test_prints_message_to_log(self): + with self.assertLogs("splitapi.mail", level=logging.WARNING) as logs: + mailer.ConsoleMailer().send(mailer.Mail("a@example.com", "Тема", "Ссылка: https://x/#verify=ABC")) + self.assertIn("#verify=ABC", "\n".join(logs.output)) + + +class SmtpMailerTests(unittest.TestCase): + def _send(self, security: str, port: int): + cfg = from_env({"PUBLIC_URL": "https://example.com", "SMTP_HOST": "smtp.example.com", "SMTP_PORT": str(port), + "SMTP_SECURITY": security, "SMTP_USER": "user", "SMTP_PASSWORD": "pw"}) + target = "smtplib.SMTP_SSL" if security == "ssl" else "smtplib.SMTP" + with mock.patch(target) as smtp_cls: + server = smtp_cls.return_value.__enter__.return_value + mailer.SmtpMailer(cfg).send(mailer.Mail("u@example.com", "Тема", "Тело")) + return smtp_cls, server + + def test_starttls_logs_in_and_sends(self): + smtp_cls, server = self._send("starttls", 587) + self.assertEqual(smtp_cls.call_args.args[:2], ("smtp.example.com", 587)) + server.starttls.assert_called_once() + server.login.assert_called_once_with("user", "pw") + server.send_message.assert_called_once() + + def test_ssl_does_not_call_starttls(self): + smtp_cls, server = self._send("ssl", 465) + server.starttls.assert_not_called() + server.login.assert_called_once() + server.send_message.assert_called_once() + + def test_none_security_without_credentials_skips_login(self): + cfg = from_env({"PUBLIC_URL": "https://example.com", "SMTP_HOST": "localhost", "SMTP_SECURITY": "none", "SMTP_PORT": "25"}) + with mock.patch("smtplib.SMTP") as smtp_cls: + server = smtp_cls.return_value.__enter__.return_value + mailer.SmtpMailer(cfg).send(mailer.Mail("u@example.com", "s", "b")) + server.starttls.assert_not_called() + server.login.assert_not_called() + server.send_message.assert_called_once() + + def test_starttls_uses_verified_tls_context(self): + _, server = self._send("starttls", 587) + context = server.starttls.call_args.kwargs.get("context") + self.assertIsNotNone(context) + self.assertEqual(context.verify_mode.name, "CERT_REQUIRED") + + +class AsyncMailerTests(unittest.TestCase): + def test_delivers_in_background(self): + inner = mailer.MemoryMailer() + am = mailer.AsyncMailer(inner) + try: + am.send(mailer.Mail("a@example.com", "s", "b")) + am.flush(timeout=5) + self.assertEqual(len(inner.sent), 1) + finally: + am.close() + + def test_failure_is_logged_and_worker_survives(self): + class Flaky: + def __init__(self): + self.calls = 0 + self.done = threading.Event() + + def send(self, mail): + self.calls += 1 + if self.calls == 1: + raise OSError("smtp down") + self.done.set() + + inner = Flaky() + am = mailer.AsyncMailer(inner) + try: + with self.assertLogs("splitapi.mail", level=logging.ERROR) as logs: + am.send(mailer.Mail("a@example.com", "s", "b")) + am.flush(timeout=5) + self.assertIn("OSError", "\n".join(logs.output)) + am.send(mailer.Mail("b@example.com", "s", "b")) + self.assertTrue(inner.done.wait(5)) + finally: + am.close() + + def test_error_text_with_an_address_is_not_written_to_the_log(self): + class Refusing: + def send(self, mail): + raise OSError("recipient refused: private.person@secret-domain.example") + + am = mailer.AsyncMailer(Refusing()) + try: + with self.assertLogs("splitapi.mail", level=logging.ERROR) as logs: + am.send(mailer.Mail("a@example.com", "s", "b")) + am.flush(timeout=5) + text = "\n".join(logs.output) + self.assertNotIn("private.person", text) + self.assertNotIn("secret-domain", text) + finally: + am.close() + + def test_full_queue_raises_instead_of_silently_dropping(self): + gate = threading.Event() + delivered = [] + + class Slow: + def send(self, mail): + gate.wait(5) + delivered.append(mail) + + am = mailer.AsyncMailer(Slow(), maxsize=2) + rejected = 0 + try: + with self.assertLogs("splitapi.mail", level=logging.ERROR): + for _ in range(10): + try: + am.send(mailer.Mail("a@example.com", "s", "b")) + except mailer.MailQueueFull: + rejected += 1 + self.assertGreaterEqual(rejected, 7) # из 10 принято не больше 3: одно в работе, два в очереди + finally: + gate.set() + am.flush(timeout=5) + am.close() + self.assertEqual(len(delivered) + rejected, 10) # каждое письмо либо доставлено, либо явно отклонено + + +class MakeMailerTests(unittest.TestCase): + def test_selects_backend(self): + self.assertIsInstance(mailer.make_mailer(from_env({"PUBLIC_URL": "https://x.org", "MAIL_BACKEND": "console"})), mailer.ConsoleMailer) + m = mailer.make_mailer(CFG) + try: + self.assertIsInstance(m, mailer.AsyncMailer) + finally: + m.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_main.py b/server/tests/test_main.py new file mode 100644 index 0000000..2bd8974 --- /dev/null +++ b/server/tests/test_main.py @@ -0,0 +1,115 @@ +"""Тесты точки входа: коды возврата при неверных настройках и healthcheck контейнера.""" +import os +import socket +import subprocess +import sys +import tempfile +import threading +import unittest + +from splitapi.config import from_env +from splitapi.http_api import make_server +from splitapi.mailer import MemoryMailer +from splitapi.ratelimit import RateLimiter +from splitapi.service import Service +from splitapi.store import Store + +SERVER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def run_module(env_overrides, *args): + env = {k: v for k, v in os.environ.items() if not k.startswith(("PUBLIC_URL", "SMTP_", "MAIL_", "PORT", "DB_PATH", "TRUST_PROXY"))} + env.update(env_overrides) + return subprocess.run([sys.executable, "-m", "splitapi", *args], cwd=SERVER_DIR, env=env, capture_output=True, text=True, timeout=60) + + +def free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class StartupTests(unittest.TestCase): + def test_missing_public_url_exits_with_code_2_and_explains(self): + result = run_module({}) + self.assertEqual(result.returncode, 2) + self.assertIn("PUBLIC_URL", result.stderr) + + def test_public_site_without_smtp_refuses_to_start(self): + result = run_module({"PUBLIC_URL": "https://trainer.example.com"}) + self.assertEqual(result.returncode, 2) + self.assertIn("SMTP", result.stderr) + + def test_explicit_console_mode_passes_configuration_checks_and_serves(self): + with tempfile.TemporaryDirectory() as tmp: + port = free_port() + env = {**{k: v for k, v in os.environ.items()}, "PUBLIC_URL": "https://trainer.example.com", "MAIL_BACKEND": "console", + "DB_PATH": os.path.join(tmp, "m.sqlite3"), "PORT": str(port)} + proc = subprocess.Popen([sys.executable, "-m", "splitapi"], cwd=SERVER_DIR, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + import time + deadline, ok = time.monotonic() + 20, False + while time.monotonic() < deadline and not ok: + ok = run_module({"PORT": str(port)}, "--healthcheck").returncode == 0 + time.sleep(0.2) + self.assertTrue(ok, "процесс не начал отвечать на healthcheck") + finally: + proc.terminate() + try: + _, err = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + _, err = proc.communicate() + self.assertIn("SMTP не настроен", err) # предупреждение о режиме разработки видно в логе + + +class SmtpWarningTests(unittest.TestCase): + def _start_and_collect_log(self, extra_env): + with tempfile.TemporaryDirectory() as tmp: + port = free_port() + env = {**os.environ, "PUBLIC_URL": "https://trainer.example.com", "DB_PATH": os.path.join(tmp, "w.sqlite3"), "PORT": str(port), **extra_env} + proc = subprocess.Popen([sys.executable, "-m", "splitapi"], cwd=SERVER_DIR, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + import time + deadline, ok = time.monotonic() + 20, False + while time.monotonic() < deadline and not ok: + ok = run_module({"PORT": str(port)}, "--healthcheck").returncode == 0 + time.sleep(0.2) + self.assertTrue(ok) + finally: + proc.terminate() + try: + _, err = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + _, err = proc.communicate() + return err + + def test_unencrypted_smtp_to_a_remote_host_is_called_out(self): + err = self._start_and_collect_log({"SMTP_HOST": "mail.example.com", "SMTP_SECURITY": "none"}) + self.assertIn("без шифрования", err) + + def test_encrypted_smtp_gives_no_such_warning(self): + err = self._start_and_collect_log({"SMTP_HOST": "mail.example.com", "SMTP_SECURITY": "starttls"}) + self.assertNotIn("без шифрования", err) + + +class HealthcheckTests(unittest.TestCase): + def test_fails_when_nothing_is_listening(self): + self.assertEqual(run_module({"PORT": str(free_port())}, "--healthcheck").returncode, 1) + + def test_succeeds_against_a_running_server(self): + with tempfile.TemporaryDirectory() as tmp: + cfg = from_env({"PUBLIC_URL": "http://127.0.0.1:8080", "DB_PATH": os.path.join(tmp, "h.sqlite3")}) + server = make_server(cfg, Service(cfg, Store(cfg.db_path), MemoryMailer(), RateLimiter()), host="127.0.0.1", port=0) + thread = threading.Thread(target=lambda: server.serve_forever(poll_interval=0.01), daemon=True) + thread.start() + try: + self.assertEqual(run_module({"PORT": str(server.server_address[1])}, "--healthcheck").returncode, 0) + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_ratelimit.py b/server/tests/test_ratelimit.py new file mode 100644 index 0000000..7961b69 --- /dev/null +++ b/server/tests/test_ratelimit.py @@ -0,0 +1,117 @@ +"""Тесты ограничителя частоты запросов.""" +import unittest + +from splitapi.ratelimit import RateLimiter + + +class FakeClock: + def __init__(self): + self.t = 1000.0 + + def __call__(self): + return self.t + + +class RateLimiterTests(unittest.TestCase): + def setUp(self): + self.clock = FakeClock() + self.limiter = RateLimiter(clock=self.clock) + + def test_allows_up_to_limit_then_blocks(self): + for _ in range(3): + self.assertTrue(self.limiter.check("k", 3, 60)[0]) + allowed, retry = self.limiter.check("k", 3, 60) + self.assertFalse(allowed) + self.assertGreaterEqual(retry, 1) + + def test_window_slides(self): + for _ in range(3): + self.limiter.check("k", 3, 60) + self.clock.t += 61 + self.assertTrue(self.limiter.check("k", 3, 60)[0]) + + def test_retry_after_matches_oldest_hit(self): + self.limiter.check("k", 2, 60) + self.clock.t += 20 + self.limiter.check("k", 2, 60) + allowed, retry = self.limiter.check("k", 2, 60) + self.assertFalse(allowed) + self.assertEqual(retry, 40) + + def test_keys_are_independent(self): + self.assertTrue(self.limiter.check("a", 1, 60)[0]) + self.assertFalse(self.limiter.check("a", 1, 60)[0]) + self.assertTrue(self.limiter.check("b", 1, 60)[0]) + + def test_blocked_attempts_do_not_extend_the_block(self): + self.limiter.check("k", 1, 60) + for _ in range(50): + self.limiter.check("k", 1, 60) + self.clock.t += 61 + self.assertTrue(self.limiter.check("k", 1, 60)[0]) + + def test_is_blocked_does_not_count_attempts(self): + for _ in range(10): + self.assertFalse(self.limiter.is_blocked("k", 2, 60)[0]) + + def test_recorded_failures_lead_to_block_and_expire(self): + self.limiter.record("k", 60) + self.limiter.record("k", 60) + blocked, retry = self.limiter.is_blocked("k", 2, 60) + self.assertTrue(blocked) + self.assertGreaterEqual(retry, 1) + self.clock.t += 61 + self.assertFalse(self.limiter.is_blocked("k", 2, 60)[0]) + + def test_forgive_gives_back_the_latest_attempt(self): + self.limiter.check("k", 2, 60) + self.limiter.check("k", 2, 60) + self.assertFalse(self.limiter.check("k", 2, 60)[0]) + self.limiter.forgive("k") + self.assertTrue(self.limiter.check("k", 2, 60)[0]) + + def test_forgive_unknown_key_is_harmless(self): + self.limiter.forgive("never-seen") + self.assertTrue(self.limiter.check("never-seen", 1, 60)[0]) + + def test_successful_attempts_can_be_forgiven_indefinitely(self): + for _ in range(50): + self.assertTrue(self.limiter.check("ok", 3, 60)[0]) + self.limiter.forgive("ok") + + def test_expired_entries_are_dropped_before_live_ones(self): + limiter = RateLimiter(clock=self.clock, max_keys=3) + limiter.check("old1", 1, 60) + limiter.check("old2", 1, 60) + self.clock.t += 100 + limiter.check("new1", 1, 60) + limiter.check("new2", 1, 60) # тут превышение: просроченные уходят первыми + self.assertEqual(limiter.size(), 2) + self.assertFalse(limiter.check("new1", 1, 60)[0]) # живой счётчик не потерян + + def test_concurrent_checks_never_exceed_the_limit(self): + import threading + allowed = [] + gate = threading.Barrier(16) + + def worker(): + gate.wait() + allowed.append(self.limiter.check("race", 5, 60)[0]) + + threads = [threading.Thread(target=worker) for _ in range(16)] + for t in threads: + t.start() + for t in threads: + t.join() + self.assertEqual(sum(allowed), 5) + + def test_memory_is_bounded(self): + limiter = RateLimiter(clock=self.clock, max_keys=100) + for i in range(500): + limiter.check(f"k{i}", 5, 60) + self.clock.t += 0.01 + self.assertLessEqual(limiter.size(), 100) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_security.py b/server/tests/test_security.py new file mode 100644 index 0000000..ad18dcd --- /dev/null +++ b/server/tests/test_security.py @@ -0,0 +1,149 @@ +"""Тесты примитивов безопасности: почта, пароли, токены.""" +import base64 +import threading +import unittest +from unittest import mock + +from splitapi import security + + +class NormalizeEmailTests(unittest.TestCase): + def test_lowercases_and_strips(self): + self.assertEqual(security.normalize_email(" User@Example.COM "), "user@example.com") + + def test_accepts_plus_and_dots(self): + self.assertEqual(security.normalize_email("a.b+tag@mail.example.org"), "a.b+tag@mail.example.org") + + def test_idn_domain_is_converted_to_punycode(self): + self.assertEqual(security.normalize_email("ivan@почта.рф"), "ivan@xn--80a1acny.xn--p1ai") + + def test_rejects_garbage(self): + for bad in ["", "plain", "a@b", "@example.com", "a@@example.com", "a b@example.com", + "a@exa mple.com", "a@example..com", "a@-example.com", "a@example.c", None, 42]: + with self.subTest(bad=bad): + self.assertIsNone(security.normalize_email(bad)) + + def test_rejects_newline_inside_address(self): + """re.match с $ пропускал перевод строки перед концом строки: нужен fullmatch.""" + for bad in ["a\n@example.com", "user@example\n.com", "a@example.com\n\n", "a@exam\nple.com"]: + with self.subTest(bad=bad): + result = security.normalize_email(bad) + self.assertTrue(result is None or "\n" not in result) + self.assertIsNone(security.normalize_email("a\n@example.com")) + self.assertIsNone(security.normalize_email("user@example\n.com")) + + def test_rejects_characters_dangerous_for_legacy_mail_systems(self): + for bad in ["user%evil.com@example.com", "a|cmd@example.com", "a/b@example.com", "a!b@example.com", "a#b@example.com", + "a$b@example.com", "a&b@example.com", "a*b@example.com", "a=b@example.com", "a?b@example.com", "a`b@example.com"]: + with self.subTest(bad=bad): + self.assertIsNone(security.normalize_email(bad)) + + def test_keeps_apostrophe_in_real_names(self): + self.assertEqual(security.normalize_email("O'Brien@Example.com"), "o'brien@example.com") + + def test_rejects_header_injection(self): + for bad in ["a@example.com\r\nBcc: evil@x.org", "a@example.com\nX: y", "a\x00@example.com"]: + with self.subTest(bad=bad): + self.assertIsNone(security.normalize_email(bad)) + + def test_rejects_too_long(self): + self.assertIsNone(security.normalize_email("a" * 65 + "@example.com")) + self.assertIsNone(security.normalize_email("a@" + "b" * 250 + ".com")) + + +class PasswordPolicyTests(unittest.TestCase): + def test_accepts_reasonable_password(self): + self.assertIsNone(security.validate_password("correct horse battery", "u@example.com")) + + def test_too_short(self): + self.assertEqual(security.validate_password("short1", "u@example.com"), "password_too_short") + + def test_too_long_is_rejected_to_bound_hashing_cost(self): + self.assertEqual(security.validate_password("x" * 129, "u@example.com"), "password_too_long") + + def test_not_a_string(self): + self.assertEqual(security.validate_password(None, "u@example.com"), "password_too_short") + + def test_equal_to_email_or_local_part(self): + self.assertEqual(security.validate_password("someone@example.com", "someone@example.com"), "password_weak") + self.assertEqual(security.validate_password("someoneelse1", "someoneelse1@example.com"), "password_weak") + + def test_common_passwords_rejected(self): + for pw in ["password", "12345678", "qwertyuiop", "Password1"]: + with self.subTest(pw=pw): + self.assertEqual(security.validate_password(pw, "u@example.com"), "password_weak") + + def test_unicode_password_length_counts_characters(self): + self.assertIsNone(security.validate_password("пароль-на-русском", "u@example.com")) + + def test_lone_surrogates_are_rejected_instead_of_crashing_the_hasher(self): + """json.loads пропускает "\\ud800": такая строка не кодируется в UTF-8 и раньше давала 500.""" + for bad in ["\ud800" * 8, "valid-prefix-\udfff", "ok-password-" + "\udc00"]: + with self.subTest(bad=bad): + self.assertEqual(security.validate_password(bad, "u@example.com"), "password_invalid") + + +class PasswordHashTests(unittest.TestCase): + def test_roundtrip(self): + stored = security.hash_password("s3cret-password") + self.assertTrue(security.verify_password(stored, "s3cret-password")) + self.assertFalse(security.verify_password(stored, "S3cret-password")) + + def test_hash_has_parameters_and_is_salted(self): + a, b = security.hash_password("same-password"), security.hash_password("same-password") + self.assertNotEqual(a, b) + self.assertTrue(a.startswith("scrypt$")) + self.assertNotIn("same-password", a) + + def test_unicode_normalization_is_consistent(self): + stored = security.hash_password("éclair-1234") # e + combining acute + self.assertTrue(security.verify_password(stored, "éclair-1234")) # precomposed é + + def test_malformed_stored_hash_never_verifies(self): + for bad in ["", "garbage", "scrypt$1$2", "scrypt$x$8$1$AAAA$BBBB", "md5$1$2$3$4$5"]: + with self.subTest(bad=bad): + self.assertFalse(security.verify_password(bad, "whatever-password")) + + def test_needs_rehash_detects_weaker_parameters(self): + weak = security.hash_password("pw-for-rehash", n=2 ** 12) + self.assertTrue(security.needs_rehash(weak)) + self.assertFalse(security.needs_rehash(security.hash_password("pw-for-rehash"))) + + def test_dummy_verify_takes_same_path_and_returns_false(self): + self.assertFalse(security.verify_dummy("any-password-here")) + + def test_waiting_for_a_hash_slot_times_out_instead_of_hanging(self): + stored = "$".join(["scrypt", "1024", "8", "1", base64.b64encode(b"s" * 16).decode(), base64.b64encode(b"d" * 32).decode()]) + with mock.patch.object(security, "_slots", threading.BoundedSemaphore(0)), mock.patch.object(security, "SLOT_WAIT", 0.05): + with self.assertRaises(security.HashBusy): + security.hash_password("some-password-1") + with self.assertRaises(security.HashBusy): + security.verify_password(stored, "some-password-1") + + def test_hash_slot_is_released_after_use(self): + for _ in range(8): # больше, чем слотов: утечка слота зависла бы + security.hash_password("another-password", n=2**10) + + +class TokenTests(unittest.TestCase): + def test_tokens_are_random_and_long(self): + raw1, h1 = security.new_token() + raw2, h2 = security.new_token() + self.assertNotEqual(raw1, raw2) + self.assertGreaterEqual(len(raw1), 43) + self.assertNotEqual(h1, h2) + + def test_hash_is_sha256_hex_and_deterministic(self): + raw, digest = security.new_token() + self.assertEqual(security.hash_token(raw), digest) + self.assertRegex(digest, r"^[0-9a-f]{64}$") + self.assertNotIn(raw, digest) + + def test_hash_token_rejects_non_strings(self): + self.assertIsNone(security.hash_token(None)) + self.assertIsNone(security.hash_token(123)) + self.assertIsNone(security.hash_token("x" * 300)) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_statevalid.py b/server/tests/test_statevalid.py new file mode 100644 index 0000000..7d84d85 --- /dev/null +++ b/server/tests/test_statevalid.py @@ -0,0 +1,94 @@ +"""Тесты проверки состояния прогресса, которое клиент сохраняет на сервере.""" +import json +import unittest + +from splitapi.statevalid import StateError, clean_state + +GOOD = { + "progress": {"ru-A1": {"best": 120.5, "acc": 97.2, "stars": 2, "tries": 3}}, + "history": [{"t": 1760000000000, "id": "ru-A1", "k": "lesson", "lang": "ru", "cpm": 120.5, "acc": 97.2, "err": 2, "len": 60, "sec": 30.1}], + "keys": {"ru": {"ф": {"n": 10, "err": 1, "ms": 2500, "ln": 9}}, "en": {}}, +} + + +class CleanStateTests(unittest.TestCase): + def test_valid_state_roundtrips(self): + self.assertEqual(clean_state(GOOD), GOOD) + + def test_empty_state_is_ok(self): + self.assertEqual(clean_state({}), {"progress": {}, "history": [], "keys": {"ru": {}, "en": {}}}) + + def test_unknown_top_level_fields_are_dropped(self): + out = clean_state({**GOOD, "settings": {"x": 1}, "evil": "