From b135f115579b02ea1d2e647420d73835d144e2a5 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 9 Jul 2026 14:25:55 -0600 Subject: [PATCH] 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 --- .gitignore | 1 + README.md | 61 ++- app/main.py | 31 +- app/routers/auth.py | 6 + app/routers/credentials.py | 10 +- app/routers/dashboard.py | 4 +- app/routers/health.py | 30 ++ app/routers/playlists.py | 17 +- app/security/crypto.py | 4 + app/security/session.py | 37 ++ app/services/credential_service.py | 14 +- app/services/diagnostics.py | 105 +++++ app/services/playlist_service.py | 76 ++-- app/services/scheduler_service.py | 19 +- app/settings.py | 17 +- app/templates/credentials/index.html | 7 + app/templates/dashboard.html | 9 + app/templates/setup.html | 38 ++ docker-compose.snippet.yml | 69 ++- docs/ARCHITECTURE.md | 70 +++ docs/MIGRATION.md | 76 ++++ entrypoint.sh | 14 +- pipeline/configs/_upgrade.csv | 413 ------------------ .../configs/artist-canonical.list.example | 62 +-- .../configs/cross-album-keep.list.example | 22 + pipeline/configs/djmix-albums.txt.example | 39 +- .../configs/legacy-playlists.json.example | 14 + pipeline/configs/regen.sh | 49 ++- pipeline/lib/backfill-buy-url.py | 2 +- pipeline/lib/backfill-spotify-tags.sh | 2 +- pipeline/lib/dedup-library.sh | 19 +- pipeline/lib/enrich-buy-url.py | 2 +- pipeline/lib/export-laptop-playlists.py | 2 +- pipeline/lib/fix-empty-album.sh | 2 +- pipeline/lib/fix-track-metadata.py | 2 +- pipeline/lib/import-dj-collection.py | 2 +- pipeline/lib/navidrome-scan.sh | 2 +- pipeline/lib/pipeline-status.sh | 4 +- pipeline/lib/recover-azuracast-playlists.py | 4 +- pipeline/lib/sync-bandcamp.py | 2 +- 40 files changed, 759 insertions(+), 600 deletions(-) create mode 100644 app/routers/health.py create mode 100644 app/security/session.py create mode 100644 app/services/diagnostics.py create mode 100644 app/templates/setup.html create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/MIGRATION.md delete mode 100644 pipeline/configs/_upgrade.csv create mode 100644 pipeline/configs/cross-album-keep.list.example create mode 100644 pipeline/configs/legacy-playlists.json.example diff --git a/.gitignore b/.gitignore index 73622e0..725843f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ *.db-wal *.db-shm .env +pipeline/configs/_upgrade.csv diff --git a/README.md b/README.md index 9bc46c7..e057288 100644 --- a/README.md +++ b/README.md @@ -82,29 +82,40 @@ This takes a few minutes the first time. You don't need to touch anything inside ### 2. Create your folders -alembic needs exactly two places to store things: +alembic needs three places to store things: -- **A music folder** — where your actual songs live. If you already have a music library, point at it directly. -- **A config folder** — where alembic keeps its own database, your saved credentials, and logs. +- **A music folder**, where your actual songs live. If you already have a music library, point at it directly. +- **A config folder**, where alembic keeps its own database, your saved credentials (encrypted), and logs. +- **A secrets folder**, which holds just one file: the master key described in the next step. This is kept separate from the config folder on purpose (see below). ```bash mkdir -p /path/to/your/music mkdir -p /path/to/alembic-config +mkdir -p /path/to/alembic-secrets ``` -Everything else (which playlists you follow, your credentials, scheduling) is stored inside that config folder, so if you ever need to move alembic to a new machine, copying those two folders is all it takes. +alembic runs inside the container as a non-root user (user and group id 1000). If you created these folders as yourself or as root, the container will not be able to write to them and will stop on startup with a clear message telling you this. Give that user ownership of the config and music folders: + +```bash +sudo chown -R 1000:1000 /path/to/alembic-config +sudo chown -R 1000:1000 /path/to/your/music +``` + +Everything about how alembic runs (which playlists you follow, your credentials, scheduling) is stored inside the config folder, so moving alembic to a new machine is a matter of copying it across. Back up the secrets folder separately, and do not keep it in the same backup as the config folder. The reason is in the next step. ### 3. Generate a master key -alembic encrypts every credential you give it (Spotify keys, Soulseek password, and so on) before storing it. It needs a key to do that: +alembic encrypts every credential you give it (Spotify keys, Soulseek password, and so on) before storing it in the config folder. It needs a key to do that: ```bash -openssl rand -base64 32 > /path/to/alembic-config/master.key -chmod 600 /path/to/alembic-config/master.key +openssl rand -base64 32 > /path/to/alembic-secrets/master.key +chmod 600 /path/to/alembic-secrets/master.key ``` Keep this file safe. If you lose it, alembic can no longer read your saved credentials and you'll need to re-enter them. +Keep it separate from your config folder, which is why it goes in its own folder above. The config folder holds the encrypted credentials. If the key sat in there too, then anyone who got hold of one backup would have both the locked box and its key, and the encryption would not protect you. Storing the key on its own keeps the two apart. + ### 4. Set up login (OIDC) Register alembic as an application in your OIDC provider (Pocket ID, Authentik, whichever you use). You'll need to tell it: @@ -145,12 +156,12 @@ services: secrets: alembic_master_key: - file: /path/to/alembic-config/master.key + file: /path/to/alembic-secrets/master.key ``` A few notes on the environment values: -- `SESSION_SECRET` can be any long random string, generate one with `openssl rand -base64 32`. +- `SESSION_SECRET` signs your login session. You can leave it out and alembic will generate one on first start and save it inside the config folder for next time. If you would rather set it yourself, use any long random string, for example from `openssl rand -base64 32`. - `ALLOWED_EMAIL` is optional but recommended. Even if your OIDC provider only has your account today, this makes sure only that one email can ever log in. - If you want to route Soulseek traffic through a VPN container instead of exposing alembic's port directly, see the comments in `docker-compose.snippet.yml` in this repo for the `network_mode: "service:"` pattern. Most people can skip this and use the simple port mapping above. @@ -218,10 +229,40 @@ docker build -t alembic:latest . docker compose up -d --force-recreate alembic ``` -Your library, credentials, and settings all live in your two mounted folders, so updating the image never touches your data. +Your library, credentials, and settings all live in your mounted folders, so updating the image never touches your data. + +## Backups + +Everything worth keeping is in the folders you already created: + +- **The config folder** holds the app database, the beets library database, your rendered settings, and your encrypted credentials. Backing this up captures your playlists, schedules, and history. +- **The secrets folder** holds `master.key`. Back this up too, but keep it in a separate backup from the config folder. The config folder holds your credentials in encrypted form, and the key is what unlocks them. Keeping the two apart means one leaked backup is not enough to read your credentials. +- **The music folder** is your library itself. Back it up however you already back up files. + +To restore on a new machine: put the config and secrets folders back where they were, point your compose file at them, and start the container. If you ever lose `master.key`, alembic can still start, but it can no longer read your saved credentials, so you would re-enter them once under Settings then Credentials. + +## Advanced settings + +Most people never touch these. They are environment variables you can add to your compose file's `environment:` block. + +- `TZ` sets the timezone used for scheduling and for times shown in the app, for example `TZ=America/Toronto`. Defaults to UTC. This is the standard Docker timezone variable, so if you already set it for other containers, alembic follows it too. +- `ALLOWED_EMAIL` locks login to a single email address (recommended for a personal server). +- `MUSIC_DATA_DIR`, `ALEMBIC_CONFIG_DIR`, `ALEMBIC_PORT`, and `ENCRYPTION_KEY_FILE` let you change the in-container paths and port. The defaults match everything in this README, so you only need these for unusual setups. + +You can confirm your setup at any time by visiting `/setup` in your browser (no login needed), which lists what is configured and what is missing. There is also a `/health` endpoint that returns the same information as JSON for uptime monitors. + +## Migrating from the older script version + +If you used the earlier version of this project, when it was a set of scripts rather than this container, there is a short guide for bringing your old playlists and credentials across in [docs/MIGRATION.md](docs/MIGRATION.md). ## Troubleshooting +**Something isn't working and I'm not sure what.** +Open `/setup` in your browser. It checks the things a fresh install commonly gets wrong (login not configured, master key missing, config folder not writable) and tells you exactly what to fix. You do not need to be logged in to see it. + +**The container won't start / exits immediately.** +Check the logs with `docker compose logs alembic`. The most common cause is folder ownership: the container runs as user id 1000, so the config and music folders must be owned by it. Run `sudo chown -R 1000:1000 ` on each, as described in step 2. + **I can't log in / get redirected in a loop.** Double check the redirect URI registered with your OIDC provider exactly matches `https:///auth/callback`, including `http` vs `https`. diff --git a/app/main.py b/app/main.py index 72bae7d..efff896 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,4 @@ +import logging from contextlib import asynccontextmanager from fastapi import FastAPI @@ -5,13 +6,38 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from app.db import enable_beets_db_wal, init_db -from app.routers import artist_casing, auth, credentials, dashboard, dedup, genres, import_, jobs, library, playlists +from app.routers import artist_casing, auth, credentials, dashboard, dedup, genres, health, import_, jobs, library, playlists +from app.security.session import resolve_session_secret from app.services import scheduler_service from app.settings import settings +log = logging.getLogger("alembic") + + +def _warn_if_key_colocated_with_config() -> None: + """The Fernet master key decrypts every stored credential. If it lives + inside the config volume, one leaked volume or backup exposes both the + key and the encrypted database together, which defeats the encryption. + Warn loudly rather than fail, since some users deliberately accept this.""" + try: + key_path = settings.encryption_key_file.resolve() + config_dir = settings.alembic_config_dir.resolve() + except OSError: + return + if config_dir == key_path or config_dir in key_path.parents: + log.warning( + "Master key %s is inside the config directory %s. Anyone with a " + "copy of that volume or backup gets both the key and the encrypted " + "credentials. Mount the key from a separate location kept out of " + "the config backup. See docker-compose.snippet.yml.", + key_path, + config_dir, + ) + @asynccontextmanager async def lifespan(_app: FastAPI): + _warn_if_key_colocated_with_config() init_db() enable_beets_db_wal() @@ -27,10 +53,11 @@ async def lifespan(_app: FastAPI): def create_app() -> FastAPI: app = FastAPI(title="alembic", lifespan=lifespan) - app.add_middleware(SessionMiddleware, secret_key=settings.session_secret) + app.add_middleware(SessionMiddleware, secret_key=resolve_session_secret()) app.mount("/static", StaticFiles(directory="app/static"), name="static") + app.include_router(health.router) app.include_router(auth.router) app.include_router(dashboard.router) app.include_router(library.router) diff --git a/app/routers/auth.py b/app/routers/auth.py index 84eb797..856753c 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -7,12 +7,18 @@ from sqlalchemy import select from app.db import SessionLocal from app.models import User from app.security.oidc import oauth +from app.services import diagnostics router = APIRouter(prefix="/auth", tags=["auth"]) @router.get("/login") async def login(request: Request): + # If OIDC isn't configured, the client was never registered and calling it + # would raise an opaque 500. Send the operator to the setup check instead, + # which spells out exactly which variables are missing. + if not diagnostics.oidc_configured(): + return RedirectResponse(url="/setup") redirect_uri = request.url_for("auth_callback") return await oauth.pocketid.authorize_redirect(request, redirect_uri) diff --git a/app/routers/credentials.py b/app/routers/credentials.py index ae414dd..e9e6849 100644 --- a/app/routers/credentials.py +++ b/app/routers/credentials.py @@ -3,6 +3,7 @@ from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates from app.db import get_db +from app.security import crypto from app.security.deps import require_auth from app.services import credential_service @@ -31,6 +32,7 @@ async def credentials_index(request: Request, user: dict = Depends(require_auth) "core_scopes": credential_service.CORE_SCOPES, "configured": configured, "enabled": enabled, + "key_present": crypto.key_present(), }, ) @@ -47,7 +49,13 @@ async def save_credentials( for field, value in form.items() if field in credential_service.SCOPE_FIELDS[scope] and value != "" } - credential_service.set_credentials(db, scope, values) + try: + credential_service.set_credentials(db, scope, values) + except RuntimeError: + # Almost always the encryption key is missing, so the value can't be + # encrypted. Redirect back; the page shows a key-missing banner with + # exactly what to do. + return RedirectResponse(url="/settings/credentials", status_code=303) return RedirectResponse(url="/settings/credentials", status_code=303) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index e2a202d..3bfe266 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -8,7 +8,7 @@ from sqlalchemy import select from app.db import get_db from app.models import JobRun from app.security.deps import require_auth -from app.services import beets_service, credential_service, network_service, playlist_service, scheduler_service +from app.services import beets_service, credential_service, diagnostics, network_service, playlist_service, scheduler_service router = APIRouter(tags=["dashboard"]) templates = Jinja2Templates(directory="app/templates") @@ -34,11 +34,13 @@ async def dashboard(request: Request, user: dict = Depends(require_auth), db=Dep ) recent_tracks = beets_service.recently_added(time.time() - _RECENT_TRACKS_WINDOW_SECONDS) public_ip = await asyncio.to_thread(network_service.public_ip) + setup_warnings = [c for c in diagnostics.checks() if not c["ok"] and (c["critical"] or c.get("warn"))] return templates.TemplateResponse( request, "dashboard.html", { "user": user, + "setup_warnings": setup_warnings, "stats": stats, "total_size_human": _human_bytes(stats.get("total_bytes", 0)), "playlist_count": len(playlists), diff --git a/app/routers/health.py b/app/routers/health.py new file mode 100644 index 0000000..2d831f4 --- /dev/null +++ b/app/routers/health.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from fastapi.templating import Jinja2Templates + +from app.services import diagnostics + +router = APIRouter(tags=["health"]) +templates = Jinja2Templates(directory="app/templates") + + +@router.get("/health") +async def health(): + """Unauthenticated JSON health check. 200 when everything critical is + configured, 503 when something critical is missing so an uptime monitor + notices. The body always lists every check either way.""" + result = diagnostics.summary() + status_code = 200 if result["status"] == "ok" else 503 + return JSONResponse(result, status_code=status_code) + + +@router.get("/setup") +async def setup(request: Request): + """Unauthenticated, human-readable version of the same checks, so a fresh + operator can see what still needs configuring before login works.""" + result = diagnostics.summary() + return templates.TemplateResponse( + request, + "setup.html", + {"status": result["status"], "checks": result["checks"]}, + ) diff --git a/app/routers/playlists.py b/app/routers/playlists.py index e3472fa..6f2ebbb 100644 --- a/app/routers/playlists.py +++ b/app/routers/playlists.py @@ -28,13 +28,16 @@ async def create_playlist( user: dict = Depends(require_auth), db=Depends(get_db), ): - playlist_service.create( - db, - name=name, - spotify_url=spotify_url, - cron_expr=playlist_service.time_to_cron(sync_time), - no_m3u=no_m3u, - ) + try: + playlist_service.create( + db, + name=name, + spotify_url=spotify_url, + cron_expr=playlist_service.time_to_cron(sync_time), + no_m3u=no_m3u, + ) + except ValueError as exc: + raise HTTPException(400, str(exc)) return RedirectResponse(url="/playlists", status_code=303) diff --git a/app/security/crypto.py b/app/security/crypto.py index 3e9e37c..fe7fe9b 100644 --- a/app/security/crypto.py +++ b/app/security/crypto.py @@ -17,6 +17,10 @@ def _fernet() -> Fernet: return Fernet(key_path.read_bytes().strip()) +def key_present() -> bool: + return settings.encryption_key_file.exists() + + def encrypt(value: str) -> bytes: return _fernet().encrypt(value.encode()) diff --git a/app/security/session.py b/app/security/session.py new file mode 100644 index 0000000..0a9f762 --- /dev/null +++ b/app/security/session.py @@ -0,0 +1,37 @@ +import secrets + +from app.settings import settings + +# A leftover from an earlier build that shipped this as the default. Still +# rejected explicitly so anyone who copied it into their env doesn't end up +# with a publicly-known signing key. +_KNOWN_PLACEHOLDER = "changeme-dev-only-override-in-production" + + +def resolve_session_secret() -> str: + """Return the secret used to sign session cookies. + + Preference order: + 1. SESSION_SECRET from the environment, if set to a real value. + 2. A random secret persisted to the config volume, generated once on + first boot and reused on every restart after that. + + This means a user who never sets SESSION_SECRET still gets a strong, + stable secret instead of the app silently falling back to a constant + that would make every session cookie forgeable. + """ + env_value = settings.session_secret.strip() + if env_value and env_value != _KNOWN_PLACEHOLDER: + return env_value + + secret_path = settings.alembic_config_dir / "session.secret" + if secret_path.exists(): + stored = secret_path.read_text().strip() + if stored: + return stored + + generated = secrets.token_urlsafe(48) + secret_path.parent.mkdir(parents=True, exist_ok=True) + secret_path.write_text(generated + "\n") + secret_path.chmod(0o600) + return generated diff --git a/app/services/credential_service.py b/app/services/credential_service.py index b1248b4..74ea389 100644 --- a/app/services/credential_service.py +++ b/app/services/credential_service.py @@ -1,4 +1,5 @@ import re +import shlex import time from pathlib import Path @@ -30,7 +31,7 @@ SCOPE_DESCRIPTIONS = { "navidrome": "Triggers a library rescan after downloads/imports/edits, and lets the daily status report check Navidrome's scan health. Required.", "bandcamp": "Pulls new purchases from your Bandcamp collection, imports them automatically, and stamps a buy link into the file tag.", "qobuz": "A buy-link source (alongside Bandcamp) for tracks you didn't purchase there -- DRM-free hi-res links when available, written directly into the file tag.", - "azuracast": "Not a buy-link source itself (that's Bandcamp/Qobuz, written straight to the file tag) -- this lets alembic tell AzuraCast (ephemeral.club's backend) to immediately reprocess touched files or fix a playlist assignment, instead of waiting for its periodic scan.", + "azuracast": "Only relevant if you run an AzuraCast radio station off the same library. Not a buy-link source itself (that's Bandcamp/Qobuz, written straight to the file tag) -- this lets alembic tell AzuraCast to immediately reprocess touched files or fix a playlist assignment, instead of waiting for its periodic scan.", "telegram": "Sends the daily pipeline health digest to a chat instead of you having to check the dashboard.", } @@ -99,6 +100,9 @@ def _patch_conf_field(path: Path, key: str, value: str) -> None: every other line (including PLAYLIST_NAME/SPOTIFY_URL substitutions regen.sh already applied) untouched.""" text = path.read_text() + # A newline in the value would inject an extra `key = value` line into the + # sldl config. Credentials never legitimately contain one, so strip any. + value = value.replace("\r", "").replace("\n", "") pattern = re.compile(rf"^{re.escape(key)}\s*=.*$", re.MULTILINE) new_line = f"{key} = {value}" if pattern.search(text): @@ -114,7 +118,11 @@ def _every_playlist_conf() -> list[Path]: def _write_env_file(path: Path, values: dict[str, str]) -> None: path.parent.mkdir(parents=True, exist_ok=True) - lines = [f"{k}='{v}'" for k, v in values.items()] + # shlex.quote so a credential value containing a quote, space, or shell + # metacharacter (e.g. a password like `p'a$(id)ss`) stays a single literal + # value when a pipeline script `source`s this file, instead of breaking out + # and executing. shlex.quote also handles the empty string correctly (''). + lines = [f"{k}={shlex.quote(v)}" for k, v in values.items()] path.write_text("\n".join(lines) + "\n") path.chmod(0o600) @@ -147,7 +155,7 @@ def render_scope(db: Session, scope: str) -> None: settings.pipeline_config_dir / "navidrome" / "admin.env", { "ND_BASE": values.get("base_url", "http://navidrome:4533"), - "ND_USER": values.get("admin_user", "andrew"), + "ND_USER": values.get("admin_user", ""), "ND_PASS": values.get("admin_pass", ""), }, ) diff --git a/app/services/diagnostics.py b/app/services/diagnostics.py new file mode 100644 index 0000000..ccba57f --- /dev/null +++ b/app/services/diagnostics.py @@ -0,0 +1,105 @@ +"""Setup and health checks. + +These answer "is this install configured enough to work?" without needing a +login, so a fresh operator can see what is missing before the point where a +missing piece would otherwise surface as an opaque 500. Nothing here reads or +exposes a secret value; it only reports presence and reachability. +""" +import os + +from app.settings import settings + +# Checks whose failure means the app cannot function (can't log in, can't +# persist state). Everything else is informational. +CRITICAL = {"oidc", "master_key", "config_writable"} + + +def _config_writable() -> bool: + probe = settings.alembic_config_dir / ".write-probe" + try: + probe.write_text("ok") + probe.unlink() + return True + except OSError: + return False + + +def oidc_configured() -> bool: + return bool( + settings.pocketid_issuer + and settings.pocketid_client_id + and settings.pocketid_client_secret + ) + + +def master_key_present() -> bool: + return settings.encryption_key_file.exists() + + +def _key_colocated() -> bool: + try: + key = settings.encryption_key_file.resolve() + cfg = settings.alembic_config_dir.resolve() + except OSError: + return False + return cfg == key or cfg in key.parents + + +def checks() -> list[dict]: + """Return one dict per check: key, ok, critical, detail.""" + results = [ + { + "key": "oidc", + "ok": oidc_configured(), + "detail": "Login provider configured." + if oidc_configured() + else "Set POCKETID_ISSUER, POCKETID_CLIENT_ID and POCKETID_CLIENT_SECRET. Without them, login cannot work.", + }, + { + "key": "master_key", + "ok": master_key_present(), + "detail": f"Encryption key found at {settings.encryption_key_file}." + if master_key_present() + else f"No encryption key at {settings.encryption_key_file}. Generate one with `openssl rand -base64 32` and mount it as the alembic_master_key secret. Saving any credential will fail until then.", + }, + { + "key": "config_writable", + "ok": _config_writable(), + "detail": f"Config directory {settings.alembic_config_dir} is writable." + if _config_writable() + else f"Cannot write to {settings.alembic_config_dir}. Fix ownership on the host so the container user (uid 1000) can write: chown -R 1000:1000 .", + }, + { + "key": "music_dir", + "ok": os.path.isdir(settings.music_data_dir), + "detail": f"Music directory {settings.music_data_dir} is present." + if os.path.isdir(settings.music_data_dir) + else f"Music directory {settings.music_data_dir} is not mounted.", + }, + { + "key": "beets_library", + "ok": True, # absence is normal before the first import + "detail": "Library database present." + if settings.beets_db_path.exists() + else "No library database yet. This is normal until your first playlist sync or import.", + }, + ] + if master_key_present() and _key_colocated(): + results.append( + { + "key": "key_location", + "ok": True, + "detail": "Note: the master key sits inside the config directory. A single leaked backup would expose both the key and the encrypted credentials. Consider mounting it from a separate location.", + "warn": True, + } + ) + for r in results: + r["critical"] = r["key"] in CRITICAL + return results + + +def summary() -> dict: + """Overall status plus the per-check list, for /health.""" + results = checks() + degraded = any(c["critical"] and not c["ok"] for c in results) + return {"status": "degraded" if degraded else "ok", "checks": results} diff --git a/app/services/playlist_service.py b/app/services/playlist_service.py index fd1735f..d8d1bc5 100644 --- a/app/services/playlist_service.py +++ b/app/services/playlist_service.py @@ -1,4 +1,5 @@ import json +import re import subprocess import time @@ -8,6 +9,26 @@ from sqlalchemy.orm import Session from app.models import Playlist from app.settings import settings +# A playlist name becomes a filename (.conf), a directory under the +# Soulseek dropbox, an M3U path, and a log path, and it is substituted into +# rendered config files. Restrict it to a small safe charset so it can never +# escape those paths (../) or inject config/shell content. Kept deliberately +# tight; if a user wants a fancier display name that's a separate field to add +# later, not a reason to loosen the on-disk identifier. +_VALID_NAME = re.compile(r"^[A-Za-z0-9 _-]{1,64}$") + + +def validate_name(name: str) -> str: + """Return the name unchanged if it is a safe on-disk identifier, else + raise ValueError with a message suitable for showing to the user.""" + cleaned = (name or "").strip() + if not _VALID_NAME.match(cleaned): + raise ValueError( + "Playlist name must be 1-64 characters using only letters, " + "numbers, spaces, hyphens and underscores." + ) + return cleaned + def cron_to_time(cron_expr: str | None) -> str: """'30 1 * * *' -> '01:30'. Every playlist schedule today is a simple @@ -41,32 +62,6 @@ def time_to_cron(time_str: str | None) -> str | None: return None return f"{minute} {hour} * * *" -# The 17-entry array this project is migrating off of (was hardcoded in -# /opt/sldl/configs/regen.sh). Used once by seed_legacy() during Stage 1/2 -# of the migration; the DB is the source of truth from then on. cron_expr -# values are the exact staggered slots from /etc/cron.d/sldl-maintenance, -# preserved so cutover doesn't change anyone's download schedule. -LEGACY_PLAYLISTS = { - "digicore": ("https://open.spotify.com/playlist/6tWHtPBECZTkZHPKFA3Fq4", "0 0 * * *", False), - "techno": ("https://open.spotify.com/playlist/5GlAkczmLWJFIJO4FvkLEZ", "0 1 * * *", False), - "house": ("https://open.spotify.com/playlist/2XF8ye6O5xqzT4NSfIXdfJ", "30 1 * * *", False), - "lofi": ("https://open.spotify.com/playlist/0qnyF6TAmuT3U6hFH2fiF1", "0 2 * * *", False), - "y2k": ("https://open.spotify.com/playlist/0iaA7ZJS00aRxhMwZn2GCU", "30 2 * * *", False), - "weeb": ("https://open.spotify.com/playlist/0599AbpsKRjsp2rlJI8jhf", "0 3 * * *", False), - "shoegaze": ("https://open.spotify.com/playlist/3R4S3tmbVKjXQ1RPAmryC5", "30 3 * * *", False), - "bass": ("https://open.spotify.com/playlist/2zkF4S16efaMmmzdIOe5w2", "0 4 * * *", False), - "hiphop": ("https://open.spotify.com/playlist/42JxTtOLJ7gG1ybfyy7Vmi", "30 4 * * *", False), - "modular": ("https://open.spotify.com/playlist/2kOfP2EM8dzdMYC6fXvlLb", "0 5 * * *", False), - "hotdog": ("https://open.spotify.com/playlist/53HrOL0qw47G9ywYZz3kgX", "30 5 * * *", False), - "kpop": ("https://open.spotify.com/playlist/31e2V512TIqr5JIfgkyseo", "0 6 * * *", False), - "jungle": ("https://open.spotify.com/playlist/4LjLeXg6ElneQiTaIcAHhE", "30 6 * * *", False), - "goldenera": ("https://open.spotify.com/playlist/57vArhigysJfgwB6CY4VXR", "0 7 * * *", False), - "botanica": ("https://open.spotify.com/playlist/5KgQT9YWz3EqhIN4Jh69IU", "30 7 * * *", False), - "hardcore": ("https://open.spotify.com/playlist/1J1lbzlQKligzr2LWaq9Ex", "0 23 * * *", False), - "liked": ("https://open.spotify.com/playlist/4Z3qCYuU1sjNeNYO3Amzeo", "30 23 * * *", True), -} - - def list_all(db: Session) -> list[Playlist]: return list(db.execute(select(Playlist).order_by(Playlist.name)).scalars()) @@ -88,6 +83,7 @@ def create( notes: str | None = None, cron_expr: str | None = None, ) -> Playlist: + name = validate_name(name) now = time.time() playlist = Playlist( name=name, @@ -141,13 +137,33 @@ def delete(db: Session, playlist_id: int) -> None: def seed_legacy(db: Session) -> int: - """One-time import of the legacy hardcoded array (migration Stage 1/2). - Skips any name that already exists. Returns the number of rows created.""" + """One-time import of playlists from an optional legacy-playlists.json in + the config dir (migration helper). This is not personal data baked into + the code: point it at your own export. Each entry is an object with + "name", "spotify_url", and optional "cron_expr" and "no_m3u". Skips any + name that already exists. Returns the number of rows created, or 0 if the + file is absent. + + Example legacy-playlists.json: + [{"name": "techno", "spotify_url": "https://open.spotify.com/playlist/...", + "cron_expr": "0 1 * * *", "no_m3u": false}] + """ + legacy_path = settings.pipeline_config_dir / "legacy-playlists.json" + if not legacy_path.exists(): + return 0 + entries = json.loads(legacy_path.read_text()) created = 0 - for name, (url, cron_expr, no_m3u) in LEGACY_PLAYLISTS.items(): + for entry in entries: + name = entry["name"] if get_by_name(db, name) is not None: continue - create(db, name=name, spotify_url=url, no_m3u=no_m3u, cron_expr=cron_expr) + create( + db, + name=name, + spotify_url=entry["spotify_url"], + no_m3u=entry.get("no_m3u", False), + cron_expr=entry.get("cron_expr"), + ) created += 1 return created diff --git a/app/services/scheduler_service.py b/app/services/scheduler_service.py index d5735a2..791d86f 100644 --- a/app/services/scheduler_service.py +++ b/app/services/scheduler_service.py @@ -1,4 +1,6 @@ +import logging import time +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger @@ -9,7 +11,22 @@ from app.models import Playlist, ScheduledJob from app.services import dedup_review_service, genre_review_service, pipeline_runner from app.settings import settings -TIMEZONE = "America/Edmonton" +log = logging.getLogger("alembic") + + +def _resolve_timezone(name: str) -> str: + """Validate the configured timezone, falling back to UTC if it isn't a + real IANA zone. Returned as a string because that's what APScheduler and + CronTrigger accept directly.""" + try: + ZoneInfo(name) + return name + except (ZoneInfoNotFoundError, ValueError): + log.warning("Unknown timezone %r, falling back to UTC.", name) + return "UTC" + + +TIMEZONE = _resolve_timezone(settings.timezone) # --------------------------------------------------------------------------- # Maintenance jobs, ported 1:1 from /etc/cron.d/sldl-maintenance. Each entry diff --git a/app/settings.py b/app/settings.py index 28fe63e..956e9ca 100644 --- a/app/settings.py +++ b/app/settings.py @@ -1,5 +1,6 @@ from pathlib import Path +from pydantic import AliasChoices, Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -21,7 +22,11 @@ class Settings(BaseSettings): encryption_key_file: Path = Path("/run/secrets/alembic_master_key") # Session cookie signing secret for Starlette's SessionMiddleware. - session_secret: str = "changeme-dev-only-override-in-production" + # 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:///auth/callback). @@ -33,6 +38,16 @@ class Settings(BaseSettings): # 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" diff --git a/app/templates/credentials/index.html b/app/templates/credentials/index.html index 29a651d..39ed214 100644 --- a/app/templates/credentials/index.html +++ b/app/templates/credentials/index.html @@ -4,6 +4,13 @@

Credentials

Write-only: values are encrypted at rest and never sent back to the browser. Leave a field blank to keep it unchanged.

+{% if not key_present %} +
+ No encryption key found. +

Credentials cannot be saved until the master key is in place. Generate one with openssl rand -base64 32 and mount it as the alembic_master_key secret, then restart. See the setup check.

+
+{% endif %} +
{% for scope, fields in scope_fields.items() %} {% set is_core = scope in core_scopes %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 98d0caa..bd18df9 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,6 +1,15 @@ {% extends "base.html" %} {% block title %}Dashboard — alembic{% endblock %} {% block content %} +{% if setup_warnings %} +
+ Setup needs attention +
    + {% for c in setup_warnings %}
  • {{ c.detail }}
  • {% endfor %} +
+

Full checklist on the setup page.

+
+{% endif %}