diff --git a/app/core/config.py b/app/core/config.py index a63eba6..2c7a673 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -61,6 +61,17 @@ class Settings(BaseSettings): # admin-only (POST /admin/users). Registered users are never superusers. allow_registration: bool = True + # -- CORS ------------------------------------------------------------- + # Origins allowed to call the API from a browser. The web UI is multi- + # instance — it connects to whatever origin the operator types on the + # connect screen — so a page served from origin A may call this backend at + # origin B (e.g. the direct :8000 port, a LAN IP, or 127.0.0.1 vs localhost). + # Auth rides in the ``Authorization`` bearer header (not cookies), so the + # wildcard default is safe here — it is paired with ``allow_credentials=False``. + # Set explicit origins in hardened deployments. Accepts a comma-separated + # string in ``.env`` (``CORS_ALLOW_ORIGINS=https://a,https://b``) or ``*``. + cors_allow_origins: list[str] = Field(default_factory=lambda: ["*"]) + # -- subsonic --------------------------------------------------------- # Symmetric key (any string) used to encrypt each user's recoverable # Subsonic app-password at rest. A Fernet key is derived from it; rotating @@ -127,6 +138,15 @@ class Settings(BaseSettings): raise ValueError("database_url must use the asyncpg driver: postgresql+asyncpg://") return v + @field_validator("cors_allow_origins", mode="before") + @classmethod + def _split_cors_origins(cls, v: object) -> object: + # Allow a plain comma-separated string in .env (pydantic would otherwise + # try to JSON-decode a list field): "a, b" -> ["a", "b"]; "*" -> ["*"]. + if isinstance(v, str): + return [origin.strip() for origin in v.split(",") if origin.strip()] + return v + @property def is_prod(self) -> bool: return self.environment == "prod" diff --git a/app/main.py b/app/main.py index a796230..3012f16 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket +from fastapi.middleware.cors import CORSMiddleware from app.api.errors import register_exception_handlers from app.api.health import router as health_router @@ -40,6 +41,19 @@ def create_app() -> FastAPI: ) app.add_middleware(CorrelationIdMiddleware) + # CORS added last → outermost, so browser preflight (OPTIONS) is answered + # before anything else. The web UI can connect cross-origin (direct :8000, + # a LAN IP, 127.0.0.1 vs localhost), which needs these headers. Bearer-token + # auth (no cookies) → wildcard origins are safe with allow_credentials=False. + if settings.cors_allow_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_allow_origins, + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["Content-Range", "Accept-Ranges", "Content-Length"], + ) register_exception_handlers(app) app.include_router(health_router)