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