b135f11557
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>
234 lines
8.3 KiB
Python
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)
|