Files
alembic/app/services/playlist_service.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

234 lines
8.3 KiB
Python

import json
import re
import subprocess
import time
from sqlalchemy import select
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
daily HH:MM (no day-of-week/day-of-month complexity -- that's only used
by a couple of maintenance jobs), so the UI can offer a plain time
picker instead of raw cron syntax. Returns '' for anything else
(unscheduled, or a cron expression more complex than daily-at-HH:MM)."""
if not cron_expr:
return ""
parts = cron_expr.split()
if len(parts) == 5 and parts[2] == "*" and parts[3] == "*" and parts[4] == "*":
try:
hour, minute = int(parts[1]), int(parts[0])
if 0 <= hour < 24 and 0 <= minute < 60:
return f"{hour:02d}:{minute:02d}"
except ValueError:
pass
return ""
def time_to_cron(time_str: str | None) -> str | None:
"""'01:30' -> '30 1 * * *'. Empty/invalid input means unscheduled."""
if not time_str:
return None
try:
hour_str, minute_str = time_str.split(":")
hour, minute = int(hour_str), int(minute_str)
except ValueError:
return None
if not (0 <= hour < 24 and 0 <= minute < 60):
return None
return f"{minute} {hour} * * *"
def list_all(db: Session) -> list[Playlist]:
return list(db.execute(select(Playlist).order_by(Playlist.name)).scalars())
def get(db: Session, playlist_id: int) -> Playlist | None:
return db.get(Playlist, playlist_id)
def get_by_name(db: Session, name: str) -> Playlist | None:
return db.execute(select(Playlist).where(Playlist.name == name)).scalar_one_or_none()
def create(
db: Session,
name: str,
spotify_url: str,
active: bool = True,
no_m3u: bool = False,
notes: str | None = None,
cron_expr: str | None = None,
) -> Playlist:
name = validate_name(name)
now = time.time()
playlist = Playlist(
name=name,
spotify_url=spotify_url,
active=active,
no_m3u=no_m3u,
notes=notes,
cron_expr=cron_expr,
created_at=now,
updated_at=now,
)
db.add(playlist)
db.commit()
db.refresh(playlist)
_sync_to_disk(db)
return playlist
def update(db: Session, playlist_id: int, **fields) -> Playlist:
playlist = db.get(Playlist, playlist_id)
if playlist is None:
raise ValueError(f"no playlist with id={playlist_id}")
for key, value in fields.items():
setattr(playlist, key, value)
playlist.updated_at = time.time()
db.commit()
db.refresh(playlist)
_sync_to_disk(db)
return playlist
def delete(db: Session, playlist_id: int) -> None:
"""Remove the playlist from the DB and delete its rendered .conf file.
Does NOT touch anything already downloaded/imported for it — that's a
library decision, not a playlist-definition one."""
playlist = db.get(Playlist, playlist_id)
if playlist is None:
return
name = playlist.name
db.delete(playlist)
db.commit()
conf_path = settings.pipeline_config_dir / f"{name}.conf"
conf_path.unlink(missing_ok=True)
_write_playlists_json(db)
from app.services import scheduler_service
scheduler = scheduler_service.get_scheduler()
if scheduler is not None:
scheduler_service.sync_playlist_jobs(scheduler)
def seed_legacy(db: Session) -> int:
"""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 entry in entries:
name = entry["name"]
if get_by_name(db, name) is not None:
continue
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
def _write_playlists_json(db: Session) -> None:
"""The file regen.sh reads. Every active-or-not playlist is included
(see regen.sh's header comment) so a manual run is always possible;
the scheduler is what actually skips inactive ones."""
playlists = list_all(db)
payload = [
{
"name": p.name,
"spotify_url": p.spotify_url,
"active": p.active,
"no_m3u": p.no_m3u,
}
for p in playlists
]
settings.pipeline_config_dir.mkdir(parents=True, exist_ok=True)
out_path = settings.pipeline_config_dir / "playlists.json"
out_path.write_text(json.dumps(payload, indent=2))
def regenerate_confs(db: Session) -> subprocess.CompletedProcess:
"""Write playlists.json, then run regen.sh to render/refresh every
playlist's .conf file. Newly-created .conf files still have unpatched
SOULSEEK_USER/SOULSEEK_PASS/SPOTIFY_CLIENT_ID/SPOTIFY_CLIENT_SECRET
placeholders at this point -- _sync_to_disk() (this function's caller)
re-renders credentials into them immediately afterward."""
_write_playlists_json(db)
regen_script = settings.pipeline_dir / "configs" / "regen.sh"
return subprocess.run(
[str(regen_script)],
capture_output=True,
text=True,
env={
"ALEMBIC_CONFIG_DIR": str(settings.alembic_config_dir),
"MUSIC_DATA_DIR": str(settings.music_data_dir),
"PIPELINE_DIR": str(settings.pipeline_dir),
# Must include /opt/venv/bin: regen.sh shells out to `python3`
# to parse playlists.json, and that's where this image's Python
# actually lives (confirmed against the real container -- a
# bare /usr/bin:/bin PATH made every regen.sh call silently
# generate zero .conf files, no error surfaced to the caller).
"PATH": "/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
},
)
def _sync_to_disk(db: Session) -> None:
"""Convenience wrapper used by create()/update(): regenerate confs,
immediately re-patch credentials into any newly-rendered file, and push
the change to the live scheduler if one is running -- this is what
makes add/remove-in-the-UI take effect without a redeploy."""
regenerate_confs(db)
# Imported lazily to avoid a circular import (credential_service and
# scheduler_service don't depend on playlist_service, but importing at
# module load time would still work here — done lazily anyway to keep
# the dependency direction obvious as all three services grow).
from app.services import credential_service, scheduler_service
credential_service.render_scope(db, "soulseek")
credential_service.render_scope(db, "spotify")
scheduler = scheduler_service.get_scheduler()
if scheduler is not None:
scheduler_service.sync_playlist_jobs(scheduler)