from __future__ import annotations import re from functools import lru_cache from pathlib import Path from urllib.parse import urlparse from pydantic import Field, SecretStr, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict DEFAULT_TEMPLATE_DIR = Path(__file__).resolve().parents[1] / "templates" / "categories" class Settings(BaseSettings): """Runtime configuration loaded from environment variables.""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) app_name: str = "MediaRouter" app_version: str = "1.0.0" app_environment: str = "development" host: str = "0.0.0.0" port: int = 7860 cors_allowed_origins: str = "" temp_dir: Path = Path("./temp") output_dir: Path = Path("./outputs") template_dir: Path = DEFAULT_TEMPLATE_DIR max_upload_size: int = Field(default=1_073_741_824, ge=1_048_576) max_duration_seconds: float = Field(default=21_600.0, gt=0) max_resolution_pixels: int = Field(default=33_177_600, ge=1) whisper_model: str = "small" cleanup_minutes: int = Field(default=60, ge=1) cleanup_interval_seconds: int = Field(default=60, ge=5) max_workers: int = Field(default=2, ge=1, le=32) log_level: str = "INFO" download_timeout_seconds: float = Field(default=300.0, gt=0) allow_private_urls: bool = False base_url: str = "" ffmpeg_binary: str = "ffmpeg" ffprobe_binary: str = "ffprobe" auth_enabled: bool = True database_url: str = "sqlite+aiosqlite:///./data/mediarouter.db" # Security/tenant schema creation is automatic only for SQLite local # development. PostgreSQL deployments must apply SQL migrations explicitly. security_auto_migrate: bool = False security_database_role: str = "" security_enforce_rls: bool = True auth_role_scopes: dict[str, list[str]] = Field(default_factory=dict) auth_bootstrap_key_hash: str = "" auth_bootstrap_key_prefix: str = "" auth_bootstrap_key_name: str = "Bootstrap Administrator" auth_bootstrap_environment: str = "live" auth_last_used_update_seconds: int = Field(default=60, ge=0, le=3600) auth_default_requests_per_minute: int = Field(default=100, ge=1, le=1_000_000) auth_default_concurrent_jobs: int = Field(default=10, ge=1, le=10_000) auth_default_uploads_per_hour: int = Field(default=20, ge=1, le=1_000_000) auth_default_processing_bytes_per_day: int = Field(default=107_374_182_400, ge=1_048_576) auth_trust_proxy_headers: bool = True mcp_stdio_api_key: SecretStr | None = None # Social Automation foundation. The existing database remains the default # local store; production Supabase/Postgres deployments should set a # dedicated async SQLAlchemy URL and apply the SQL migration out-of-band. social_enabled: bool = True social_database_url: str = "" # API requests use SOCIAL_DATABASE_URL with a non-BYPASSRLS role. Workers # use a separate, backend-only connection with the trusted role below. social_worker_database_url: str = "" social_tenant_database_role: str = "" social_worker_database_role: str = "" social_enforce_rls: bool = True social_auto_migrate: bool = False social_worker_enabled: bool = True social_scheduler_interval_seconds: int = Field(default=30, ge=5, le=3600) social_job_stale_after_seconds: int = Field(default=900, ge=60, le=86_400) social_publish_retry_limit: int = Field(default=5, ge=0, le=20) social_oauth_requests_per_hour: int = Field(default=30, ge=1, le=100_000) social_publish_requests_per_minute: int = Field(default=30, ge=1, le=100_000) social_schedule_requests_per_minute: int = Field(default=60, ge=1, le=100_000) social_analytics_requests_per_minute: int = Field(default=120, ge=1, le=100_000) social_oauth_encryption_key: SecretStr | None = None supabase_url: str = "" supabase_service_role_key: SecretStr | None = None supabase_vault_enabled: bool = False google_client_id: str = "" google_client_secret: SecretStr | None = None youtube_upload_chunk_bytes: int = Field(default=8 * 1024 * 1024, ge=256 * 1024) youtube_max_concurrent_uploads: int = Field(default=2, ge=1, le=32) youtube_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600) youtube_processing_poll_seconds: int = Field(default=30, ge=5, le=3600) meta_client_id: str = "" meta_client_secret: SecretStr | None = None # META_APP_* is the public configuration contract. META_CLIENT_* remains # supported for deployments created during the social foundation phase. meta_app_id: str = "" meta_app_secret: SecretStr | None = None meta_graph_api_version: str = "v25.0" tiktok_client_key: str = "" tiktok_client_secret: SecretStr | None = None # Exact, backend-owned OAuth callback registered in TikTok Login Kit. # This is intentionally separate from the secret and is never a frontend # configuration value. tiktok_redirect_uri: str = "" # Direct Post requires TikTok Content Posting approval and an audited app. # Keep it fail-closed until an operator has confirmed that access. tiktok_direct_post_enabled: bool = False tiktok_upload_chunk_bytes: int = Field(default=10_000_000, ge=5_000_000, le=64_000_000) tiktok_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600) tiktok_processing_poll_seconds: int = Field(default=30, ge=5, le=3600) linkedin_client_id: str = "" linkedin_client_secret: SecretStr | None = None # Exact backend callback registered in the LinkedIn Developer Portal. linkedin_redirect_uri: str = "" # Community Management posting is approval-gated. Keep it disabled until # the operator has confirmed the application products and write scopes in # LinkedIn Developer Portal. linkedin_publishing_enabled: bool = False linkedin_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600) linkedin_media_processing_poll_seconds: int = Field(default=5, ge=1, le=300) linkedin_media_processing_timeout_seconds: int = Field(default=600, ge=30, le=3600) x_client_id: str = "" x_client_secret: SecretStr | None = None # Exact OAuth 2.0 callback registered for the confidential X Web App. x_redirect_uri: str = "" # X does not expose a dependable startup entitlement probe. Keep posting # fail-closed until an operator confirms the project has current write and # media access in the X Developer Console. x_publishing_enabled: bool = False x_upload_chunk_bytes: int = Field(default=5 * 1024 * 1024, ge=1_048_576, le=64 * 1024 * 1024) x_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600) x_media_processing_poll_seconds: int = Field(default=5, ge=1, le=300) x_media_processing_timeout_seconds: int = Field(default=300, ge=30, le=3600) telegram_bot_token: SecretStr | None = None whatsapp_client_id: str = "" whatsapp_client_secret: SecretStr | None = None social_oauth_redirect_base_url: str = "" # Provider-neutral generation runtime. Providers remain optional and their # worker endpoints/tokens stay server-side only. generation_enabled: bool = True generation_job_retry_limit: int = Field(default=3, ge=0, le=20) # Shared remote-generation worker transport defaults. These do not enable # a provider and intentionally contain no worker URL or credentials. ai_worker_connect_timeout_seconds: float = Field(default=10.0, gt=0, le=300) ai_worker_request_timeout_seconds: float = Field(default=60.0, gt=0, le=3600) ai_worker_read_timeout_seconds: float = Field(default=300.0, gt=0, le=7200) ai_worker_max_retries: int = Field(default=3, ge=0, le=10) ai_worker_retry_backoff_seconds: float = Field(default=0.5, ge=0, le=60) # WAN is optional. These values remain backend-only and are deliberately # not validated at Settings construction time: a bad optional worker # configuration must leave WAN unavailable without preventing unrelated # MediaRouter services from starting. wan_space_url: str = "" wan_space_token: SecretStr | None = None # Optional authenticated FLUX.2 Klein worker. Invalid configuration keeps # FLUX unavailable without affecting startup or other providers. flux_space_url: str = "" flux_space_token: SecretStr | None = None generation_worker_enabled: bool = True generation_worker_interval_seconds: float = Field(default=5.0, ge=0.5, le=3600) generation_worker_poll_backoff_seconds: float = Field(default=2.0, ge=0.5, le=300) generation_worker_batch_size: int = Field(default=8, ge=1, le=100) generation_job_stale_after_seconds: int = Field(default=900, ge=60, le=86_400) # Content Studio persistence/render safety limits. Rendering is optional # infrastructure; disabling its worker never prevents API startup. editor_state_max_bytes: int = Field(default=1_048_576, ge=16_384, le=16_777_216) render_worker_enabled: bool = True render_worker_interval_seconds: float = Field(default=2.0, ge=0.5, le=3600) render_job_stale_after_seconds: int = Field(default=900, ge=60, le=86_400) render_job_timeout_seconds: int = Field(default=7200, ge=60, le=86_400) render_job_retry_limit: int = Field(default=2, ge=0, le=10) render_max_active_jobs_per_project: int = Field(default=1, ge=1, le=10) render_max_tracks: int = Field(default=32, ge=1, le=256) render_max_clips: int = Field(default=500, ge=1, le=10_000) render_max_duration_seconds: int = Field(default=3600, ge=1, le=21_600) render_max_input_bytes: int = Field(default=4_294_967_296, ge=1_048_576) @field_validator("whisper_model") @classmethod def validate_whisper_model(cls, value: str) -> str: allowed = {"tiny", "base", "small", "medium", "large-v3"} if value not in allowed: raise ValueError(f"WHISPER_MODEL must be one of: {', '.join(sorted(allowed))}") return value @field_validator("log_level") @classmethod def normalize_log_level(cls, value: str) -> str: normalized = value.upper() allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} if normalized not in allowed: raise ValueError(f"LOG_LEVEL must be one of: {', '.join(sorted(allowed))}") return normalized @field_validator("app_environment") @classmethod def normalize_app_environment(cls, value: str) -> str: normalized = value.strip().lower() if normalized not in {"development", "test", "production"}: raise ValueError("APP_ENVIRONMENT must be development, test, or production") return normalized @property def allowed_cors_origins(self) -> tuple[str, ...]: """Return validated, normalized origins for Starlette CORS middleware.""" origins: list[str] = [] for configured in self.cors_allowed_origins.split(","): origin = configured.strip().rstrip("/") if not origin: continue parsed = urlparse(origin) if ( "*" in origin or parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password or parsed.path or parsed.params or parsed.query or parsed.fragment ): raise ValueError( "CORS_ALLOWED_ORIGINS must contain comma-separated HTTP(S) origins" ) if origin not in origins: origins.append(origin) return tuple(origins) @model_validator(mode="after") def validate_production_contract(self) -> Settings: """Fail clearly when the Docker production boundary is unsafe. Local development and tests retain the established SQLite defaults. The production Dockerfile sets ``APP_ENVIRONMENT=production``, making external PostgreSQL, RLS, explicit migrations, authentication, and an exact frontend CORS origin mandatory at process import/startup. """ origins = self.allowed_cors_origins if self.app_environment != "production": return self errors: list[str] = [] if not self._is_external_postgres(self.database_url): errors.append("DATABASE_URL must use an external PostgreSQL database in production") if self.security_auto_migrate: errors.append("SECURITY_AUTO_MIGRATE must be false in production") if not self.security_enforce_rls: errors.append("SECURITY_ENFORCE_RLS must be true in production") if not self.security_database_role.strip(): errors.append("SECURITY_DATABASE_ROLE is required in production") if not self.auth_enabled: errors.append("AUTH_ENABLED must be true in production") if not origins: errors.append("CORS_ALLOWED_ORIGINS must include the HTTPS Vercel frontend origin") elif any(urlparse(origin).scheme != "https" for origin in origins): errors.append("CORS_ALLOWED_ORIGINS must use HTTPS in production") if self.social_auto_migrate: errors.append("SOCIAL_AUTO_MIGRATE must be false in production") if self.social_enabled: if not self.social_database_url.strip() or not self._is_external_postgres( self.social_database_url ): errors.append( "SOCIAL_DATABASE_URL must use an explicit external PostgreSQL tenant connection" ) if not self.social_tenant_database_role.strip(): errors.append("SOCIAL_TENANT_DATABASE_ROLE is required when social is enabled") if not self._is_external_postgres(self.social_worker_database_url): errors.append( "SOCIAL_WORKER_DATABASE_URL must use an external PostgreSQL worker connection" ) if not self.social_worker_database_role.strip(): errors.append("SOCIAL_WORKER_DATABASE_ROLE is required when social is enabled") if not self.social_enforce_rls: errors.append("SOCIAL_ENFORCE_RLS must be true when social is enabled") if errors: raise ValueError("Invalid production configuration: " + "; ".join(errors)) return self @staticmethod def _is_external_postgres(value: str) -> bool: configured = value.strip() if not configured: return False parsed = urlparse(configured) if parsed.scheme not in {"postgres", "postgresql", "postgresql+asyncpg"}: return False hostname = (parsed.hostname or "").lower() return bool(hostname and hostname not in {"localhost", "127.0.0.1", "::1"}) def ensure_directories(self) -> None: self.temp_dir.mkdir(parents=True, exist_ok=True) self.output_dir.mkdir(parents=True, exist_ok=True) sqlite_prefixes = ("sqlite+aiosqlite:///", "sqlite:///") for url in {self.database_url, self.resolved_social_database_url}: for prefix in sqlite_prefixes: if url.startswith(prefix): database_path = url.removeprefix(prefix) if database_path and database_path != ":memory:": Path(database_path).expanduser().resolve().parent.mkdir( parents=True, exist_ok=True ) break @property def resolved_social_database_url(self) -> str: """Use an explicit social database when configured, otherwise local DB.""" return self.social_database_url.strip() or self.database_url @property def resolved_meta_app_id(self) -> str: return self.meta_app_id.strip() or self.meta_client_id.strip() @property def resolved_meta_app_secret(self) -> SecretStr | None: return self.meta_app_secret or self.meta_client_secret @field_validator("auth_bootstrap_environment") @classmethod def validate_auth_environment(cls, value: str) -> str: normalized = value.strip().lower() if normalized not in {"live", "test"}: raise ValueError("AUTH_BOOTSTRAP_ENVIRONMENT must be live or test") return normalized @field_validator("youtube_upload_chunk_bytes") @classmethod def validate_youtube_chunk_size(cls, value: int) -> int: # Google resumable uploads require every non-final chunk to be aligned # to 256 KiB. Keeping the constraint at configuration time avoids a # late failure after an upload session was already created. if value % (256 * 1024): raise ValueError("YOUTUBE_UPLOAD_CHUNK_BYTES must be a multiple of 262144") return value @field_validator("meta_graph_api_version") @classmethod def validate_meta_graph_api_version(cls, value: str) -> str: normalized = value.strip() if not re.fullmatch(r"v[0-9]+\.[0-9]+", normalized): raise ValueError("META_GRAPH_API_VERSION must use the form vNN.N") return normalized @field_validator("tiktok_redirect_uri") @classmethod def validate_tiktok_redirect_uri(cls, value: str) -> str: normalized = value.strip() if not normalized: return "" parsed = urlparse(normalized) local_hosts = {"localhost", "127.0.0.1", "::1"} if ( not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path != "/v1/social/accounts/tiktok/callback" or (parsed.scheme != "https" and parsed.hostname not in local_hosts) ): raise ValueError( "TIKTOK_REDIRECT_URI must be the HTTPS MediaRouter TikTok callback URI" ) return normalized @field_validator("x_redirect_uri") @classmethod def validate_x_redirect_uri(cls, value: str) -> str: normalized = value.strip() if not normalized: return "" parsed = urlparse(normalized) local_hosts = {"localhost", "127.0.0.1", "::1"} if ( not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path != "/v1/social/accounts/x/callback" or not ( parsed.scheme == "https" or (parsed.scheme == "http" and parsed.hostname in local_hosts) ) ): raise ValueError("X_REDIRECT_URI must be the HTTPS MediaRouter X callback URI") return normalized @field_validator("linkedin_redirect_uri") @classmethod def validate_linkedin_redirect_uri(cls, value: str) -> str: normalized = value.strip() if not normalized: return "" parsed = urlparse(normalized) local_hosts = {"localhost", "127.0.0.1", "::1"} if ( not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path != "/v1/social/accounts/linkedin/callback" or not ( parsed.scheme == "https" or (parsed.scheme == "http" and parsed.hostname in local_hosts) ) ): raise ValueError( "LINKEDIN_REDIRECT_URI must be the HTTPS MediaRouter LinkedIn callback URI" ) return normalized @lru_cache def get_settings() -> Settings: settings = Settings() settings.ensure_directories() return settings