"""Application configuration via pydantic-settings.""" from functools import lru_cache from pathlib import Path from cryptography.hazmat.primitives.asymmetric.rsa import ( RSAPrivateKey, RSAPublicKey, generate_private_key, ) from cryptography.hazmat.primitives.serialization import ( Encoding, NoEncryption, PrivateFormat, PublicFormat, load_pem_private_key, load_pem_public_key, ) from pydantic_settings import BaseSettings, SettingsConfigDict def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[bytes, bytes]: """Load existing RSA keys or generate a new pair. Keys are stored as PEM files in *key_dir*. The directory is created if it does not exist. Files are named ``private.pem`` and ``public.pem`` and are **not** committed to git. """ private_path = key_dir / "private.pem" public_path = key_dir / "public.pem" if private_path.exists() and public_path.exists(): private_pem = private_path.read_bytes() public_pem = public_path.read_bytes() else: # Generate a new 2048-bit RSA key pair private_key = generate_private_key(public_exponent=65537, key_size=2048) private_pem = private_key.private_bytes( Encoding.PEM, PrivateFormat.PKCS8, NoEncryption(), ) public_pem = private_key.public_key().public_bytes( Encoding.PEM, PublicFormat.SubjectPublicKeyInfo, ) key_dir.mkdir(parents=True, exist_ok=True) private_path.write_bytes(private_pem) public_path.write_bytes(public_pem) return private_pem, public_pem class Settings(BaseSettings): # App APP_NAME: str = "AGP Backend" APP_VERSION: str = "0.1.0" DEBUG: bool = True # Database (async) DATABASE_URL: str = "postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db" # Redis REDIS_URL: str = "redis://localhost:6379/0" # JWT / Auth ALGORITHM: str = "RS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 REFRESH_TOKEN_EXPIRE_DAYS: int = 30 # RS256 key storage. Defaults to a directory outside the repo so the # generated keypair is never accidentally committed. RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys") # CORS CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000" # File uploads UPLOAD_DIR: str = "uploads" MAX_UPLOAD_SIZE_MB: int = 100 GLTF_MAX_FILE_SIZE_MB: int = 200 # ── Game-server orchestration ────────────────────────────────────────── # The join flow (POST /api/worlds/{id}/join) ensures a headless Godot # dedicated-server process exists for a world and returns its address. # Disabled by default so nothing tries to spawn Godot in environments # that don't have it (plain pytest, CI without the engine); the # checkpoint/playtest launcher turns it on. GAME_SERVERS_ENABLED: bool = False # Path to the Godot 4 binary. "godot" resolves via PATH. GODOT_BIN: str = str(Path.home() / "godot" / "godot") # Godot project directory (the game repo root -- backend/ is a subdir). GAME_PROJECT_DIR: str = str(Path(__file__).resolve().parents[2]) # The host clients should connect to for ENet. Defaults to the LAN IP the # launcher bakes into the exe; overridable for local testing. GAME_SERVER_HOST: str = "127.0.0.1" # Port range for per-world dedicated servers. GAME_SERVER_PORT_MIN: int = 27000 GAME_SERVER_PORT_MAX: int = 27099 # Kill a world's server this long after its last join with no active # players reported. GAME_SERVER_IDLE_TIMEOUT_SECONDS: int = 300 model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=True, ) @property def cors_origins_list(self) -> list[str]: return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] @property def rsa_key_dir(self) -> Path: return Path(self.RSA_KEY_DIR) @property def private_pem(self) -> bytes: """Return the private key PEM bytes (loads/generates on first call).""" private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir) return private_pem @property def public_pem(self) -> bytes: """Return the public key PEM bytes (loads/generates on first call).""" _, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir) return public_pem @property def private_key(self) -> RSAPrivateKey: """Return the deserialized RSA private key object.""" return load_pem_private_key(self.private_pem, password=None) @property def public_key(self) -> RSAPublicKey: """Return the deserialized RSA public key object.""" return load_pem_public_key(self.public_pem) @lru_cache() def get_settings() -> Settings: """Return cached settings instance.""" return Settings() # Convenience singleton settings = get_settings()