Files
ForbiddenStarsApp/backend/app/core/config.py
T
NotBigGhostandClaude Opus 4.8 0799aee684 Конфиг: fail-fast на дефолтных секретах в production
model_validator в Settings: при APP_ENV=production приложение не стартует, если
SECRET_KEY дефолтный/короче 32 символов или (при включённом бутстрапе) ADMIN_PASSWORD
дефолтный/пустой. dev/test не затронуты — там дефолты остаются нормой. #59

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
2026-09-13 17:18:08 +03:00

157 lines
7.9 KiB
Python

"""Конфигурация приложения из переменных окружения (12-factor)."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import 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). В production приложение
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59).
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
_MIN_SECRET_KEY_LENGTH = 32
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=_ROOT_ENV,
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# ── Главный переключатель окружения: development | test | production ───────
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
# test — прод-клон в Docker локально (порт 8080), ведёт себя как прод.
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
app_env: str = "development"
log_level: str = "INFO"
# Публикация локального окружения (dev/test) наружу через VPS-туннель.
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
local_public: str = "local"
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
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_test(self) -> bool:
return self.app_env.lower() == "test"
@property
def is_production(self) -> bool:
return self.app_env.lower() == "production"
@property
def database_url(self) -> str:
"""БД: dev — отдельный файл дева; test и 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 cookie_secure(self) -> bool:
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
нативный dev на localhost по HTTP (development + local_public=local)."""
return not (self.is_development and self.local_public.lower() == "local")
@property
def cookie_domain_value(self) -> str | None:
return self.cookie_domain or None
@model_validator(mode="after")
def _forbid_default_secrets_in_prod(self) -> "Settings":
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
известным паролем администратора. В dev/test проверка не мешает — там дефолты норма."""
if self.app_env.lower() != "production":
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:
raise ValueError(
"Небезопасная конфигурация production — задайте секреты в .env: "
+ "; ".join(problems)
)
return self
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()