"""Application configuration via Pydantic Settings.""" from __future__ import annotations from functools import lru_cache from typing import Literal from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """Application settings loaded from environment variables.""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) # Environment environment: Literal["development", "production", "testing"] = "development" log_level: str = "INFO" # Database — separate connections for auth, API, worker, and migrations database_url: str = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test" auth_database_url: str = "" # Falls back to database_url if empty worker_database_url: str = "" # Falls back to database_url if empty migration_database_url: str = "" # Falls back to database_url if empty db_pool_size: int = 20 db_max_overflow: int = 30 db_echo: bool = False # Redis redis_url: str = "redis://localhost:6379/0" session_ttl_seconds: int = 28800 # 8 hours # Auth bcrypt_rounds: int = 12 session_cookie_name: str = "leocrm_session" session_cookie_secure: bool = True # Secure by default — set to False only for local HTTP development session_cookie_samesite: str = "lax" # Lax allows WebSocket cookies while preventing CSRF on top-level navigations session_cookie_httponly: bool = True password_reset_expiry_hours: int = 1 # Storage storage_path: str = "/data/storage" # SMTP smtp_host: str = "localhost" smtp_port: int = 587 smtp_username: str | None = None smtp_password: str | None = None smtp_from_email: str = "noreply@leocrm.local" smtp_use_tls: bool = True # Secret Key (for signing, sessions, etc.) secret_key: str = "change-me-in-production-use-a-secure-random-string" # CORS cors_origins: str = "http://localhost:5173,http://localhost:3000" # Frontend URL for email links (password reset, invitations, etc.) frontend_url: str = "http://localhost:5173" # Trusted proxy CIDRs (comma-separated) — only these proxies can set X-Forwarded-For trusted_proxy_cidrs: str = "" # Rate Limiting rate_limit_login_max: int = 5 rate_limit_login_window: int = 900 # 15 min rate_limit_reset_max: int = 3 rate_limit_reset_window: int = 3600 # 1 hour rate_limit_reset_confirm_max: int = 5 rate_limit_reset_confirm_window: int = 3600 # 1 hour rate_limit_general_max: int = 300 rate_limit_general_window: int = 60 # 1 min @property def cors_origin_list(self) -> list[str]: """Parse comma-separated CORS origins into a list.""" return [o.strip() for o in self.cors_origins.split(",") if o.strip()] @lru_cache def get_settings() -> Settings: """Get cached settings instance.""" s = Settings() # Safety checks — always validate critical settings _DEFAULT_KEY = "change-me-in-production-use-a-secure-random-string" if s.secret_key == _DEFAULT_KEY: raise RuntimeError("SECRET_KEY must be changed from default value") if len(s.secret_key) < 32: raise RuntimeError("SECRET_KEY must be at least 32 characters long") # Production-only checks if s.environment == "production": if not s.session_cookie_secure: raise RuntimeError("SESSION_COOKIE_SECURE must be True in production") if s.storage_path == "/tmp": raise RuntimeError("STORAGE_PATH must not be /tmp in production") return s # Module-level singleton for backward-compatible imports settings = get_settings()