feat(api): add configurable CORS middleware

The web UI is multi-instance and can connect to the backend at a
different origin (the direct :8000 port, a LAN IP, 127.0.0.1 vs
localhost), which the browser blocks without CORS headers. Adds
CORSMiddleware driven by a new cors_allow_origins setting (default "*",
safe here: bearer-token auth with allow_credentials=False). Accepts a
comma-separated string in .env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Цвылев Александр Вадимович
2026-07-28 14:33:24 +03:00
parent a263272935
commit c47242aa3a
2 changed files with 34 additions and 0 deletions
+20
View File
@@ -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"