"""Application configuration via pydantic-settings.""" import os 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, load_pem_private_key, load_pem_public_key, ) from pydantic_settings import BaseSettings, SettingsConfigDict def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[RSAPrivateKey, RSAPem]: """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 (see .gitignore). """ 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, __import__("cryptography.hazmat.primitives.serialization").hazmat.primitives.serialization.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 RSAPem: """Thin wrapper around raw PEM bytes.""" def __init__(self, data: bytes): self._data = data @property def private_key(self) -> RSAPrivateKey: return load_pem_private_key(self._data, password=None) @property def public_key(self) -> RSAPublicKey: return load_pem_public_key(self._data) 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 SECRET_KEY: str = "change-me-to-a-secure-random-string" ALGORITHM: str = "RS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 REFRESH_TOKEN_EXPIRE_DAYS: int = 30 # RS256 key storage 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 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) -> RSAPem: """Return the private PEM key (loads/generates on first call).""" private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir) return RSAPem(private_pem) @property def public_pem(self) -> RSAPem: """Return the public PEM key (loads/generates on first call).""" _, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir) return RSAPem(public_pem) @lru_cache() def get_settings() -> Settings: """Return cached settings instance.""" return Settings() # Convenience singleton settings = get_settings()