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
+46 -30
View File
@@ -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 (<name>.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