APP_TZ_OFFSET_HOURS влиял только на «дату игры», а фронт показывал время в захардкоженных +3 — при другом значении дата и время партии противоречили друг другу. Теперь /api/auth/config отдаёт tz_offset_hours, App.tsx выставляет его в format.ts до первой отрисовки страниц, и в нём же показывается всё время и вводятся даты объявлений (запасное значение +3, если конфиг недоступен). Пояс устройства не учитывается — решение владельца. Валидатор ограничивает смещение −12..14. #68 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
189 lines
9.8 KiB
Python
189 lines
9.8 KiB
Python
"""Конфигурация приложения из переменных окружения (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()
|