MediaRouter / app /core /config.py
basyx's picture
Upload 340 files
3493993 verified
Raw
History Blame Contribute Delete
19.9 kB
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