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