Files
split-trainer/server/splitapi/config.py
T
darko fcafe1ae15 feat: аккаунты по почте с подтверждением, сбросом пароля и синхронизацией прогресса
API аккаунтов на чистой стандартной библиотеке Python (server/splitapi):
- регистрация по почте: пароль задаётся по ссылке из письма, аккаунт создаётся только после подтверждения
- вход, выход, сброс пароля по почте (токены одноразовые, в базе только SHA-256), удаление аккаунта
- пароли scrypt, лимиты попыток по паре «адрес + IP», IP и аккаунту, защита от CSRF
- хранение прогресса пользователя с оптимистичной блокировкой версии
- SMTP через переменные окружения; без SMTP на публичном адресе API не стартует

Клиент: public/auth.js (вход, регистрация, сброс, синхронизация), public/sync.js
(чистое слияние прогресса). Без API сайт работает как раньше.

Развёртывание: Dockerfile.api, сервис splitapi в docker-compose, проксирование /api/
в nginx с ограничением частоты, deploy/install-api-debian.sh (systemd), .env.example.

Тесты: 216 для бэкенда (unittest, покрытие около 97%), 16 для клиентского слияния.
Документация: раздел «Аккаунты и почта» в docs/DEPLOY.md, раздел 9 в docs/ИНСТРУКЦИЯ.md.
2026-10-16 11:25:09 +03:00

167 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Настройки сервиса из переменных окружения."""
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"Сплит-тренер <noreply@{hostname}>",
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),
)