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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user