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>
This commit is contained in:
andrew
2026-07-09 14:25:55 -06:00
parent 2a19f84575
commit b135f11557
40 changed files with 759 additions and 600 deletions
+70
View File
@@ -0,0 +1,70 @@
# Architecture
This is a short tour of how alembic is put together, for anyone who wants to
understand or modify it. If you only want to run it, the README is enough.
## One container, everything inside
alembic ships as a single Docker image. The image is code only. It holds no
playlists, no credentials, and no music. Everything that changes over time
lives in mounted folders, so you can rebuild or upgrade the image without
touching your data.
There are two data mounts, plus one small secret:
- `/data/music` is your library. alembic writes tagged files here in a normal
`Artist/Album/Title.ext` layout that Navidrome (or any folder-based music
server) can read directly.
- `/config` holds everything else: the app database, the beets database, the
rendered pipeline config and credential files, and job logs.
- `master.key` is mounted as a Docker secret at `/run/secrets/alembic_master_key`.
It is the key that encrypts your saved credentials, and it is kept separate
from `/config` on purpose (see the README on why).
## The pieces
- **The web app** is FastAPI. It serves the UI, handles login through your
OIDC provider, and owns all the settings. It reads and writes two SQLite
databases: its own (`/config/alembic.db`, playlists, jobs, encrypted
credentials, dedup and genre review state) and the beets library database.
- **The scheduler** is APScheduler, running in the same process as the web app.
It fires playlist syncs and maintenance jobs on their cron schedules. It
keeps its schedule in memory and rebuilds it from code plus the database on
every startup, so there is no separate scheduler process or job store to
manage.
- **The pipeline** is a set of shell and Python scripts under `pipeline/`. The
app runs them as subprocesses. They do the actual downloading, tagging,
importing, deduplicating, and so on. `sldl` (the Soulseek downloader) is
vendored in the image and also run as a subprocess.
A single lock serializes pipeline work. Only one pipeline job runs at a time;
if a second is triggered while one is running, it is recorded as skipped rather
than run concurrently. This mirrors the old single-lock behavior and avoids two
jobs fighting over the same files.
## What happens on a playlist sync
1. The app writes the current playlist list to `/config/pipeline/playlists.json`
and renders one sldl config file per playlist from a template. Your Spotify
and Soulseek credentials are patched into those config files, decrypted from
the database just before use.
2. `sldl` reads a playlist's config, fetches the tracklist from Spotify, and
downloads each track from Soulseek into a per-playlist dropbox folder.
3. The downloaded files are tagged. Spotify is treated as the source of truth
for artist, title, album, and genre.
4. beets imports the tagged files, moving them into `/data/music` in the
canonical folder layout.
5. A matching `.m3u8` playlist file is written so the playlist plays back in
order.
6. Navidrome is asked to rescan so the new tracks show up.
Every run gets a row in the job history and its own log file, both visible in
the UI under Settings then Jobs.
## A note on the name
"alembic" here is this project. It is not the Python database-migration tool
also called Alembic. This project uses plain SQL files under `schema/` for its
own tiny migrations and does not depend on that tool. The name is a nod to a
still, which distills a raw input into something refined, which is roughly what
this does to a Spotify playlist.