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.
167 lines
6.9 KiB
Python
167 lines
6.9 KiB
Python
"""Настройки сервиса из переменных окружения."""
|
||
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),
|
||
)
|