Files
alembic/app/settings.py
T
andrew b135f11557 Security hardening and first-run/portability improvements
Security (P0):
- Remove committed session-secret default; auto-generate and persist a
  random secret to the config volume when SESSION_SECRET is unset
  (prevents forgeable session cookies / auth bypass).
- Validate playlist names to a safe charset and render sldl configs via
  literal Python substitution instead of sed (closes a command-injection
  and path-traversal path through playlist names).
- shlex-quote credential values written to shell-sourced env files, and
  strip newlines from values patched into .conf files.
- Render playlist .conf files 0600; warn at startup if the master key is
  co-located with the config volume; document keeping it separate.

Portability:
- Configurable timezone via TZ (default UTC) instead of hardcoded Edmonton.
- Remove personal defaults (navidrome user "andrew", ephemeral.club URLs).
- Ship generic example seeds; move the cross-album dedup keep-list and the
  legacy playlist import to editable config files; drop the personal
  _upgrade.csv.
- Generic VPN reference in docker-compose.snippet.yml.

First-run experience:
- Redirect to /setup instead of 500 when OIDC is unconfigured; surface a
  missing master key inline; entrypoint exits with an actionable message
  when the config folder is not writable.
- Add unauthenticated /health (JSON) and /setup (checklist) diagnostics.

Docs:
- Write docs/ARCHITECTURE.md and docs/MIGRATION.md (previously referenced
  but missing); expand README with ownership, backups, advanced settings,
  and migration guidance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:25:55 -06:00

76 lines
2.9 KiB
Python

from pathlib import Path
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="", extra="ignore")
# Mount points — see docker-compose.snippet.yml. Two volumes total:
# everything audio-related under MUSIC_DATA_DIR, everything else
# (app DB, beets config+DB, rendered pipeline credentials, job logs)
# under ALEMBIC_CONFIG_DIR.
music_data_dir: Path = Path("/data/music")
alembic_config_dir: Path = Path("/config")
pipeline_dir: Path = Path("/app/pipeline")
alembic_port: int = 8420
# Fernet key for the `secrets` table, read from a Docker secret file
# (not a plain env var) — see docker-compose.snippet.yml's `secrets:` block.
encryption_key_file: Path = Path("/run/secrets/alembic_master_key")
# Session cookie signing secret for Starlette's SessionMiddleware.
# Left empty by default on purpose: an empty/placeholder value would let
# anyone forge a signed session cookie and skip login entirely. When this
# is unset, resolve_session_secret() generates and persists a random one
# to the config volume instead of falling back to a known constant.
session_secret: str = ""
# Pocket ID OIDC client, registered once in the Pocket ID admin UI
# (redirect URI: https://<alembic-host>/auth/callback).
pocketid_issuer: str = ""
pocketid_client_id: str = ""
pocketid_client_secret: str = ""
# Defense-in-depth: this is a single-user app. If set, only this email
# may complete login even if Pocket ID ever grows a second account.
allowed_email: str = ""
# IANA timezone (e.g. "America/Edmonton") used for all scheduling and for
# times shown in the UI and the status report. Defaults to UTC so a fresh
# install behaves predictably anywhere. Reads the standard Docker `TZ`
# variable, or `ALEMBIC_TIMEZONE` if you'd rather be explicit. An
# unrecognized value falls back to UTC (see scheduler_service).
timezone: str = Field(
default="UTC",
validation_alias=AliasChoices("TZ", "ALEMBIC_TIMEZONE", "TIMEZONE"),
)
@property
def beets_dir(self) -> Path:
return self.alembic_config_dir / "beets"
@property
def beets_db_path(self) -> Path:
return self.beets_dir / "data" / "beets_library.db"
@property
def pipeline_config_dir(self) -> Path:
"""Rendered/instance pipeline config: per-playlist .conf files,
credential env files, editable lists — NOT the static code in
pipeline_dir, which is baked into the image."""
return self.alembic_config_dir / "pipeline"
@property
def app_db_path(self) -> Path:
return self.alembic_config_dir / "alembic.db"
@property
def logs_dir(self) -> Path:
return self.alembic_config_dir / "logs"
settings = Settings()