Spaces:
Running
Running
File size: 19,914 Bytes
fba6023 e1104b3 fba6023 e1104b3 fba6023 3493993 fba6023 c91c7db fba6023 3493993 fba6023 3493993 fba6023 c91c7db 3493993 c91c7db 3493993 c91c7db e1104b3 3493993 e1104b3 3493993 e1104b3 a44271f 3493993 e1104b3 a44271f e1104b3 3493993 fba6023 3493993 fba6023 c91c7db e1104b3 c91c7db fba6023 e1104b3 a44271f 3493993 a44271f fba6023 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 | 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
|