"""Конфигурация приложения из переменных окружения (12-factor).""" from __future__ import annotations from functools import lru_cache from pathlib import Path from pydantic import field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково # и на деве (uvicorn из backend/), и где бы ни была рабочая папка. В Docker файла нет # (исключён из образа) — там настройки приходят переменными от docker compose. _ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env") # Небезопасные значения по умолчанию (годятся только для dev на localhost). Опубликованное # приложение с ними не стартует — см. валидатор _forbid_default_secrets_when_published. _DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production" _DEFAULT_ADMIN_PASSWORD = "change-me-admin-password" _MIN_SECRET_KEY_LENGTH = 32 # Допустимые окружения. Отдельного test-контура нет: всё проверяется в development. _APP_ENVS = ("development", "production") class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=_ROOT_ENV, env_file_encoding="utf-8", extra="ignore", case_sensitive=False, ) # ── Главный переключатель окружения: development | production ───────────── # development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник. # production — Docker на Pi; контейнер форсит это значение, игнорируя .env. app_env: str = "development" log_level: str = "INFO" # Публикация локального dev-окружения наружу через VPS-туннель. # Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель # на forbidden-stars.ru. Приложению значение говорит, опубликовано ли оно (is_published): # от этого зависят Secure-cookie и проверка секретов при старте. local_public: str = "local" # Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда # в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени # на фронте — оно приходит туда в GET /api/auth/config (#68). app_tz_offset_hours: int = 3 # ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ───── # dev — в папке данных дева; prod — на постоянном томе контейнера. dev_database_url: str = "sqlite:///./data/dev/forbidden_stars.db" prod_database_url: str = "sqlite:////data/forbidden_stars.db" # Каталог загрузок (зарезервировано под вложения), тоже раздельно. dev_upload_dir: str = "./data/dev/uploads" prod_upload_dir: str = "/data/uploads" # Каталог ачивок (папка на ачивку: meta.json, condition.py, icon.*), раздельно. dev_achievements_dir: str = "./data/dev/achievements" prod_achievements_dir: str = "/data/achievements" # JWT / cookie secret_key: str = _DEFAULT_SECRET_KEY jwt_algorithm: str = "HS256" jwt_user_ttl_minutes: int = 60 * 24 * 7 jwt_admin_ttl_minutes: int = 60 * 8 # cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже). cookie_domain: str | None = None # Аутентификация. Методы входа определяются окружением (dev: telegram+stub, # prod: только telegram) — отдельного переключателя провайдера нет. telegram_bot_token: str | None = None telegram_bot_username: str | None = None public_base_url: str | None = None # Бутстрап администратора admin_bootstrap_enabled: bool = True admin_username: str = "admin" admin_password: str = _DEFAULT_ADMIN_PASSWORD admin_nickname: str = "Администратор" # CORS (для раздельного dev-режима фронта). Строка из env, через запятую — # храним как str и режем в свойстве, чтобы pydantic-settings не пытался # распарсить значение как JSON (иначе "http://..." ломает разбор .env). cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173" @property def cors_origins_list(self) -> list[str]: return [o.strip() for o in self.cors_origins.split(",") if o.strip()] @property def is_development(self) -> bool: """Нативная разработка. Только это окружение включает dev-вход (stub), стартовый bootstrap в lifespan и синхронизацию админа из .env.""" return self.app_env.lower() == "development" @property def is_production(self) -> bool: return self.app_env.lower() == "production" @property def database_url(self) -> str: """БД: dev — отдельный файл дева; prod — том контейнера (/data).""" return self.dev_database_url if self.is_development else self.prod_database_url @property def upload_dir(self) -> str: return self.dev_upload_dir if self.is_development else self.prod_upload_dir @property def achievements_dir(self) -> str: return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir @property def is_published(self) -> bool: """Приложение доступно снаружи по домену: production или dev, выставленный через VPS-туннель. Не опубликован только нативный dev на localhost (development + local_public=local).""" return not (self.is_development and self.local_public.lower() == "local") @property def cookie_secure(self) -> bool: """Secure-cookie нужен везде, где снаружи HTTPS (домен), — у опубликованного приложения. На localhost по HTTP браузер Secure-cookie не вернул бы.""" return self.is_published @property def cookie_domain_value(self) -> str | None: return self.cookie_domain or None @field_validator("app_env") @classmethod def _known_app_env(cls, value: str) -> str: """Неизвестное окружение — ошибка старта, а не молчаливое «почти прод»: любое значение, кроме development, выбирает прод-пути к данным и выключает dev-вход.""" if value.lower() not in _APP_ENVS: raise ValueError( f"APP_ENV={value!r} не поддерживается — допустимо: {', '.join(_APP_ENVS)}" ) return value @field_validator("app_tz_offset_hours") @classmethod def _known_tz_offset(cls, value: int) -> int: """Реальные пояса — от −12 до +14: опечатка в .env — ошибка старта, а не время, сдвинутое на сутки.""" if not -12 <= value <= 14: raise ValueError(f"APP_TZ_OFFSET_HOURS={value} вне диапазона −12..14") return value @model_validator(mode="after") def _forbid_default_secrets_when_published(self) -> "Settings": """Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми секретами (#59, #69) — и прод, и dev, выставленный на домен (LOCAL_PUBLIC=vps). Иначе снаружи оказались бы общеизвестный ключ подписи JWT (подделка любого токена, включая админский) и известный пароль администратора. На localhost дефолты — норма.""" if not self.is_published: return self problems: list[str] = [] if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH: problems.append( f"SECRET_KEY не задан, дефолтный или короче {_MIN_SECRET_KEY_LENGTH} символов" ) if self.admin_bootstrap_enabled: password = (self.admin_password or "").strip() if not password or password == _DEFAULT_ADMIN_PASSWORD: problems.append("ADMIN_PASSWORD не задан или дефолтный") if problems: where = ( "production" if self.is_production else f"dev, опубликованного наружу (LOCAL_PUBLIC={self.local_public})" ) raise ValueError( f"Небезопасная конфигурация {where} — задайте секреты в .env: " + "; ".join(problems) ) return self @lru_cache def get_settings() -> Settings: return Settings() settings = get_settings()