5 Commits

Author SHA1 Message Date
Jonathan 446a4bc2d2 fix(web): address auth review findings (open redirect, dead code, HMAC per-req)
Pre-merge review of the P0 branch:
- Open redirect: login redirected to an unvalidated ?next param, so a crafted
  /login?next=https://evil (or //evil) bounced off-site after login. Now only
  same-origin relative paths are honored (else fall back to "/").
- Remove the unused useRouter import/binding in the login form.
- Memoize expectedToken() keyed on (password, secret) so middleware stops
  recomputing an HMAC on every gated request.

Deferred (low-value/latent, noted): Soulseek cover.jpg isn't retrieved into
staging (UI still shows Cover Art Archive art); _retrieve's basename-keyed map
could undercount same-named files but source_refs come from a single slskd dir;
SignOut visibility rides a server-env read (cosmetic).

web 138 tests green, tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:25:37 +02:00
Jonathan 8cd5392ec3 fix(security): fail fast on a missing/placeholder/public LYRA_SECRET_KEY
.env.example shipped a valid, PUBLIC base64 key — if a user didn't replace it,
every credential got encrypted with a publicly-known key and everything still
"worked," so the footgun was silent.

- .env.example now ships an obvious placeholder (CHANGE_ME_generate_with_...)
  with a "you MUST replace this" note and the openssl hint.
- web + worker validate LYRA_SECRET_KEY at startup: missing / placeholder /
  wrong-length ⇒ loud FATAL banner and refuse to start (web via the Next.js
  instrumentation hook, worker via _require_secret_key before touching the DB).
  The old public example key ⇒ a loud WARNING but not fatal, since an existing
  install may have encrypted its creds under it (rotating needs re-entry).
- Validation lives in an edge-safe web/src/lib/secret-key.ts (no node:crypto)
  so the instrumentation hook bundles for both runtimes; crypto.ts re-exports.
- README emphasizes generating a fresh key + keeping it stable.

Verified live: worker exits 1 on placeholder/unset before any DB call; web
instrumentation throws and refuses to serve on the placeholder. web 138 tests,
worker 204/7-skip, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:19:46 +02:00
Jonathan 190e0ef4b6 feat(web): minimal shared-password auth gate
The web app had no auth — anyone reaching the host could browse the library,
trigger downloads, and read/overwrite the stored credentials in Settings. Adds
an opt-in single-shared-password gate:

- Next.js middleware redirects unauthenticated page requests to /login and
  returns 401 for /api/*; static assets are excluded via matcher.
- Auth is enabled ONLY when LYRA_PASSWORD is set (unset ⇒ app stays open, so
  existing deployments are unchanged). Documented in README "Security".
- The session cookie is httpOnly and carries HMAC-SHA256(password) keyed by
  LYRA_SECRET_KEY — unforgeable, and the plaintext password never touches the
  client. Edge-safe Web Crypto so one lib/auth.ts serves middleware + route.
- Cookie is NOT Secure: Lyra serves plain HTTP on the LAN; a Secure cookie would
  loop. Works behind an HTTPS proxy too.
- /login page + form, a Sign out button (shown only when auth is enabled), and
  chrome (nav + worker-status poll) suppressed on /login.
- docker-compose passes LYRA_PASSWORD to web; .env.example documents it.

Verified live end-to-end (built + next start): no-cookie page→307 /login,
API→401, wrong password→401, correct→200+cookie, authed page/API→200, forged
cookie→401, and the auth-disabled (no LYRA_PASSWORD) path stays fully open.
web 133 tests green, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:12:51 +02:00
Jonathan 851f725a98 feat(ops): scheduled Postgres backups via db-backup sidecar
All durable state (library, monitor/wanted lists, discovery, config, and the
encrypted credentials) lived only in the single postgres-data volume with no
backup — one volume loss = total loss. Adds a db-backup compose sidecar that
pg_dumps immediately on start then every BACKUP_INTERVAL_SECONDS (default daily),
keeping the last BACKUP_KEEP (default 7) custom-format dumps in ${BACKUP_DIR}.

Travels with the stack (no host cron), starts with `up -d`. .env.example
documents the knobs; README adds a "Backup & restore" section with pg_restore
steps and an off-box note. Verified live: sidecar wrote a valid 128K dump of the
real lyra DB (pg_restore -l lists all tables + _prisma_migrations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:02:17 +02:00
Jonathan f74fd5ab88 fix(worker): retrieve Soulseek downloads from slskd's dir into staging
SlskdClient enqueued the transfer and polled to Completed but never moved any
files into `dest` — slskd saves them to its OWN downloads dir, so staging stayed
empty and every Soulseek job failed "incomplete download". Qobuz/YouTube write
straight into staging and were unaffected.

- SlskdClient._retrieve() moves the finished files out of slskd's downloads dir
  into staging after completion, matching tolerantly by basename and preferring
  a parent-folder + size match (slskd's on-disk layout varies by version).
- download() returns the moved count as track_count so the pipeline's
  completeness check reflects what actually landed; raises loudly if nothing was
  found (misconfigured/unmounted downloads dir).
- Downloads dir resolves slskd.downloadsDir Config > SLSKD_DOWNLOADS_ROOT env >
  /slskd-downloads default; docker-compose mounts ${SLSKD_DOWNLOADS_DIR} there.
- .env.example documents SLSKD_DOWNLOADS_DIR; retrieval unit-tested offline.

slskd runs as an external daemon, so the shared dir must be a host path both
slskd and the worker mount — set once Lyra runs on the slskd host. Not yet
verified live end-to-end (no slskd access from this VM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:59:16 +02:00
24 changed files with 753 additions and 4 deletions
+24 -2
View File
@@ -3,8 +3,16 @@ POSTGRES_PASSWORD=lyra
POSTGRES_DB=lyra
DATABASE_URL=postgresql://lyra:lyra@db:5432/lyra
# 32 random bytes, base64. Generate with: openssl rand -base64 32
LYRA_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
# Encryption key for stored credentials — 32 random bytes, base64. YOU MUST REPLACE THIS.
# Generate your own: openssl rand -base64 32
# Leaving the placeholder (or an empty key) makes web + worker refuse to start.
LYRA_SECRET_KEY=CHANGE_ME_generate_with_openssl_rand_base64_32
# Shared password protecting the whole web UI + API. When set, every request must sign in
# (one shared password, no per-user accounts). LEAVE UNSET AND THE APP IS OPEN to anyone
# who can reach the host — only safe on a trusted LAN or behind a VPN/auth proxy. Strongly
# recommended for any exposed deployment. See README "Security".
# LYRA_PASSWORD=change-me
# Host directory for the downloaded music library (bind-mounted into the worker at /music)
MUSIC_DIR=./music
@@ -14,3 +22,17 @@ MUSIC_DIR=./music
# share (SMB/NFS) so temporary downloads don't hammer the share; the final import copies the
# finished album onto the library volume and swaps it in atomically.
# STAGING_DIR=/srv/lyra-staging
# Host directory where slskd (the external Soulseek daemon) writes FINISHED downloads,
# bind-mounted into the worker at /slskd-downloads. The worker moves completed Soulseek
# transfers out of here into staging, so this MUST be the same directory slskd downloads to.
# Set this only once Lyra runs on the same host as slskd; until then Soulseek can search but
# not deliver. Defaults to ./slskd-downloads (an empty local dir) so the stack still starts.
# SLSKD_DOWNLOADS_DIR=/var/lib/slskd/downloads
# Postgres backups (db-backup sidecar). Dumps go to BACKUP_DIR on the host; the sidecar
# keeps the last BACKUP_KEEP dumps and runs every BACKUP_INTERVAL_SECONDS (default daily).
# Point BACKUP_DIR at a NAS/synced path for off-box safety. See README "Backup & restore".
# BACKUP_DIR=./backups
# BACKUP_INTERVAL_SECONDS=86400
# BACKUP_KEEP=7
+2
View File
@@ -40,3 +40,5 @@ Thumbs.db
.idea/
.vscode/
*.swp
slskd-downloads/
backups/
+54
View File
@@ -90,7 +90,61 @@ cp .env.example .env # then edit: set LYRA_SECRET_KEY (openssl rand -ba
docker compose up -d --build # web on http://localhost:8770 ; NEVER `down -v` (wipes the Postgres volume)
```
**Generate your own `LYRA_SECRET_KEY`** (`openssl rand -base64 32`) before first run — it
encrypts your stored Qobuz/Soulseek/Last.fm credentials. `.env.example` ships an obvious
placeholder, and both web and worker **refuse to start** while the key is missing or still
the placeholder. Once set, keep it stable: changing it makes already-stored credentials
undecryptable (you'd re-enter them in Settings). Optionally set `LYRA_PASSWORD` too (see
[Security](#security)).
The web entrypoint runs `prisma migrate deploy` on every rebuild, so schema changes
reach the live DB automatically. Enter Qobuz / Soulseek / Last.fm credentials in
**Settings** (they persist across rebuilds), then follow an artist or request an album
to feed The Floor.
## Backup & restore
**All** durable state — library metadata, monitored/wanted lists, discovery data,
config, and the encrypted Qobuz/Soulseek/Last.fm credentials — lives in the single
`postgres-data` volume. Losing that volume (disk failure, or a stray `down -v`) means
losing everything, recoverable only by a full re-scan and re-entering every credential.
The `db-backup` sidecar guards against that. It runs `pg_dump` immediately on start and
then every `BACKUP_INTERVAL_SECONDS` (default daily), keeping the last `BACKUP_KEEP`
dumps (default 7) as compressed custom-format `.dump` files in `BACKUP_DIR`
(default `./backups`). It starts automatically with `docker compose up -d`.
For **off-box** safety (a backup on the same disk doesn't survive a disk failure), point
`BACKUP_DIR` at a NAS/synced path, or rsync `./backups` to another machine on a cron.
**Restore** into a fresh/empty database (stop `web`/`worker` first so nothing writes
mid-restore):
```sh
# with the stack up and db healthy:
docker compose stop web worker
docker compose cp ./backups/lyra-YYYYMMDD-HHMMSS.dump db:/tmp/restore.dump
docker compose exec db pg_restore --clean --if-exists -U lyra -d lyra /tmp/restore.dump
docker compose start web worker
```
`--clean --if-exists` drops and recreates each object, so restoring over an existing DB
is safe. To restore onto a brand-new host, bring up just `db` first
(`docker compose up -d db`), restore, then `up -d --build` the rest.
## Security
Lyra has **no per-user accounts**. Two layers protect it:
- **Shared-password gate.** Set `LYRA_PASSWORD` in `.env` to require a single shared
password for the entire UI and API. A request without a valid session cookie is
redirected to `/login` (or gets `401` for `/api/*`). The cookie is httpOnly and carries
an HMAC of the password keyed by `LYRA_SECRET_KEY`, so it can't be forged. **If
`LYRA_PASSWORD` is unset, the app is completely open** — anyone who can reach the host
can browse the library, trigger downloads, and read/overwrite the stored credentials in
Settings. Set it for any deployment that isn't on a fully trusted, isolated network.
- **Network.** The shared password is deliberately minimal (single password, bearer
cookie, plain HTTP on the LAN). For anything internet-facing, also put Lyra behind a
reverse proxy with TLS and/or a VPN or auth gateway — don't rely on the shared password
alone.
+48
View File
@@ -22,6 +22,8 @@ services:
environment:
DATABASE_URL: ${DATABASE_URL}
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
# Optional shared-password gate for the whole UI/API. Unset ⇒ app is open. See README.
LYRA_PASSWORD: ${LYRA_PASSWORD:-}
ports:
# host:container — host 8770 avoids the Quill app on 3000 (and Lidarr on 8686)
- "8770:3000"
@@ -36,6 +38,7 @@ services:
DATABASE_URL: ${DATABASE_URL}
LYRA_SECRET_KEY: ${LYRA_SECRET_KEY}
STAGING_ROOT: /staging
SLSKD_DOWNLOADS_ROOT: /slskd-downloads
# Qobuz's CDN also resolves to IPv6, but the container has no IPv6 route
# ("Network is unreachable"); disable IPv6 so downloads use IPv4 only.
sysctls:
@@ -45,9 +48,54 @@ services:
# Per-job download staging on its own volume. Defaults to MUSIC_DIR/.staging (prior
# behavior); set STAGING_DIR to a fast local path when MUSIC_DIR is a network share.
- ${STAGING_DIR:-${MUSIC_DIR:-./music}/.staging}:/staging
# slskd (an EXTERNAL daemon, not in this compose stack) writes finished Soulseek
# downloads into its own directory; the worker moves them out of here into staging.
# Point SLSKD_DOWNLOADS_DIR at slskd's downloads dir on the host (must be the SAME
# path slskd writes to — set it once Lyra runs on the slskd host). Defaults to an
# empty local dir so the mount is always valid; Soulseek delivery only works once
# this points at the real slskd downloads directory.
- ${SLSKD_DOWNLOADS_DIR:-./slskd-downloads}:/slskd-downloads
depends_on:
db:
condition: service_healthy
# Scheduled Postgres backups. ALL durable state (library, monitored/wanted lists,
# discovery data, config, AND the encrypted credentials) lives in the single
# postgres-data volume — one volume loss = total loss. This sidecar pg_dumps on an
# interval (immediately on start, then every BACKUP_INTERVAL_SECONDS), keeping the
# last BACKUP_KEEP dumps in ${BACKUP_DIR:-./backups}. Restore steps: see README.
# For off-box safety, point BACKUP_DIR at a synced/NAS path or rsync ./backups away.
db-backup:
image: postgres:17
restart: unless-stopped
environment:
PGHOST: db
PGUSER: ${POSTGRES_USER}
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB}
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
BACKUP_KEEP: ${BACKUP_KEEP:-7}
volumes:
- ${BACKUP_DIR:-./backups}:/backups
depends_on:
db:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo "db-backup: starting (interval=$${BACKUP_INTERVAL_SECONDS}s keep=$${BACKUP_KEEP})"
while true; do
ts=$$(date +%Y%m%d-%H%M%S)
out="/backups/lyra-$${ts}.dump"
if pg_dump -Fc -f "$${out}.tmp" 2>/tmp/err; then
mv "$${out}.tmp" "$${out}"
echo "db-backup: wrote $${out} ($$(du -h "$${out}" | cut -f1))"
else
echo "db-backup: pg_dump FAILED: $$(cat /tmp/err)"; rm -f "$${out}.tmp"
fi
ls -1t /backups/lyra-*.dump 2>/dev/null | tail -n +$$(( $${BACKUP_KEEP} + 1 )) | xargs -r rm -f
sleep "$${BACKUP_INTERVAL_SECONDS}"
done
volumes:
postgres-data:
+1
View File
@@ -13,6 +13,7 @@ const LINKS: { href: string; label: string }[] = [
export function ContentsNav() {
const pathname = usePathname();
if (pathname === "/login") return null; // no section nav on the login screen
const active = (href: string) => (href === "/" ? pathname === "/" : pathname.startsWith(href));
return (
<nav className="contents">
+3
View File
@@ -1,7 +1,9 @@
import { ThemeToggle } from "./theme-toggle";
import { WorkerStatus } from "./worker-status";
import { SignOut } from "./sign-out";
export function Masthead() {
const authEnabled = !!process.env.LYRA_PASSWORD;
return (
<header className="masthead">
<div className="wordmark">
@@ -13,6 +15,7 @@ export function Masthead() {
<div className="press-status">
<WorkerStatus />
<ThemeToggle />
{authEnabled ? <SignOut /> : null}
</div>
</header>
);
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { usePathname } from "next/navigation";
/** Clears the auth cookie and returns to /login. Rendered only when auth is enabled. */
export function SignOut() {
const pathname = usePathname();
if (pathname === "/login") return null;
async function signOut() {
await fetch("/api/auth", { method: "DELETE" }).catch(() => {});
window.location.assign("/login");
}
return (
<button type="button" className="sign-out" onClick={signOut} aria-label="sign out">
Sign out
</button>
);
}
+6 -1
View File
@@ -1,12 +1,16 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
/** Live worker liveness in the masthead — polls /api/worker (heartbeat recency). */
export function WorkerStatus() {
const [online, setOnline] = useState<boolean | null>(null);
const pathname = usePathname();
const onLogin = pathname === "/login";
useEffect(() => {
if (onLogin) return; // don't poll (would 401) on the login screen
let alive = true;
async function check() {
try {
@@ -22,8 +26,9 @@ export function WorkerStatus() {
alive = false;
clearInterval(id);
};
}, []);
}, [onLogin]);
if (onLogin) return null;
const label = online === null ? "…" : online ? "listening" : "offline";
return (
<div className={`worker-status${online === false ? " off" : ""}`}>
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { AUTH_COOKIE, expectedToken } from "@/lib/auth";
// POST /api/auth — exchange the shared password for a session cookie.
// DELETE /api/auth — log out (clear the cookie).
export async function POST(request: Request) {
const token = await expectedToken();
if (!token) {
// No password configured — auth is disabled, nothing to log in to.
return NextResponse.json({ ok: true, authDisabled: true });
}
let password = "";
const ctype = request.headers.get("content-type") ?? "";
if (ctype.includes("application/json")) {
const body = (await request.json().catch(() => ({}))) as { password?: unknown };
password = typeof body.password === "string" ? body.password : "";
} else {
const form = await request.formData().catch(() => null);
password = form ? String(form.get("password") ?? "") : "";
}
if (password !== process.env.LYRA_PASSWORD) {
return NextResponse.json({ ok: false, error: "Incorrect password" }, { status: 401 });
}
const res = NextResponse.json({ ok: true });
res.cookies.set(AUTH_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
// NOT `secure`: Lyra serves plain HTTP on the LAN (:8770); a Secure cookie would be
// dropped by the browser and cause a login redirect loop. Works fine behind an HTTPS
// proxy too (Secure is not required, only recommended).
maxAge: 60 * 60 * 24 * 30, // 30 days
});
return res;
}
export async function DELETE() {
const res = NextResponse.json({ ok: true });
res.cookies.set(AUTH_COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 });
return res;
}
+23
View File
@@ -172,6 +172,29 @@ nav.contents .sep { flex: 1; }
.job .chip { grid-column: 2 / 3; justify-self: start; margin-top: 12px; }
}
/* ── Login ────────────────────────────────────────────── */
.login { display: flex; justify-content: center; padding: 48px 0; }
.login-panel {
border: 1.5px solid var(--rule-2); padding: 34px 32px; max-width: 380px; width: 100%;
}
.login-panel .eyebrow {
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--accent); margin: 0 0 8px;
}
.login-panel h1 { font-family: var(--serif); font-size: 1.9rem; margin: 0 0 6px; }
.login-lede { color: var(--graphite); margin: 0 0 22px; font-size: 0.9rem; }
.login-form { display: flex; flex-direction: column; gap: 18px; align-items: stretch; }
.login-form .field input { min-width: 0; }
.login-form .btn { align-self: flex-start; }
.login-error {
font-family: var(--mono); font-size: 0.72rem; letter-spacing: 0.04em; color: var(--accent); margin: 0;
}
.sign-out {
font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--graphite); background: transparent; border: 0; cursor: pointer; padding: 0;
}
.sign-out:hover { color: var(--accent); }
/* ── Phase 2: page headers, list rows, tools ──────────── */
.page-head { margin: 6px 0 26px; }
.page-head .eyebrow {
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
export function LoginForm() {
const params = useSearchParams();
// Only allow same-origin relative paths, so a crafted ?next=https://evil or ?next=//evil
// can't turn login into an open redirect.
const raw = params.get("next") || "/";
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError("");
try {
const res = await fetch("/api/auth", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
// full navigation so middleware re-evaluates with the new cookie
window.location.assign(next);
return;
}
const data = (await res.json().catch(() => ({}))) as { error?: string };
setError(data.error || "Login failed");
setBusy(false);
} catch {
setError("Login failed");
setBusy(false);
}
}
return (
<form className="login-form" onSubmit={submit}>
<label className="field">
<span>Password</span>
<input
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="shared password"
/>
</label>
{error ? <p className="login-error">{error}</p> : null}
<button className="btn accent" type="submit" disabled={busy || !password}>
{busy ? "Entering…" : "Enter"}
</button>
</form>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Suspense } from "react";
import { LoginForm } from "./login-form";
export const metadata = { title: "Sign in · Lyra" };
export default function LoginPage() {
return (
<main className="login">
<div className="login-panel">
<p className="eyebrow">Restricted pressing</p>
<h1>Sign in</h1>
<p className="login-lede">This Lyra instance is password-protected.</p>
<Suspense>
<LoginForm />
</Suspense>
</div>
</main>
);
}
+21
View File
@@ -0,0 +1,21 @@
// Runs once on server startup (Next.js instrumentation hook). Fail fast (or warn loudly)
// on a missing/placeholder/public LYRA_SECRET_KEY so a publicly-known key can't silently
// encrypt every credential.
import { secretKeyProblem } from "@/lib/secret-key";
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return; // check once, in the Node runtime
const problem = secretKeyProblem();
if (!problem) return;
const banner = "=".repeat(72);
if (problem.fatal) {
console.error(
`\n${banner}\nFATAL: ${problem.message}.\n` +
`Generate one: openssl rand -base64 32\n` +
`Then set LYRA_SECRET_KEY in your .env and restart.\n${banner}\n`,
);
throw new Error(problem.message); // fail fast
}
console.warn(`\n${banner}\nWARNING: ${problem.message}.\n${banner}\n`);
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect, beforeEach } from "vitest";
import { expectedToken, safeEqual } from "./auth";
beforeEach(() => {
process.env.LYRA_SECRET_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
delete process.env.LYRA_PASSWORD;
});
describe("auth token", () => {
it("returns '' when no password is configured (auth disabled)", async () => {
expect(await expectedToken()).toBe("");
});
it("derives a stable, non-empty token from the password", async () => {
process.env.LYRA_PASSWORD = "hunter2";
const a = await expectedToken();
const b = await expectedToken();
expect(a).not.toBe("");
expect(a).toBe(b); // deterministic
});
it("changes with the password and with the secret key", async () => {
process.env.LYRA_PASSWORD = "hunter2";
const base = await expectedToken();
process.env.LYRA_PASSWORD = "different";
expect(await expectedToken()).not.toBe(base);
process.env.LYRA_PASSWORD = "hunter2";
process.env.LYRA_SECRET_KEY = "ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmY=";
expect(await expectedToken()).not.toBe(base);
});
});
describe("safeEqual", () => {
it("matches equal strings and rejects others", () => {
expect(safeEqual("abc", "abc")).toBe(true);
expect(safeEqual("abc", "abd")).toBe(false);
expect(safeEqual("abc", "ab")).toBe(false);
expect(safeEqual("", "")).toBe(true);
});
});
+46
View File
@@ -0,0 +1,46 @@
// Minimal shared-password auth. Enabled ONLY when LYRA_PASSWORD is set; otherwise the
// app stays open (unchanged behavior) — see README "Security". The auth cookie holds a
// token derived from the password via HMAC-SHA256 keyed by LYRA_SECRET_KEY, so it can't
// be forged without the secret and the plaintext password is never stored client-side.
// Edge-safe: uses global Web Crypto + btoa (no node:crypto / Buffer), so the same code
// runs in middleware (Edge runtime) and in the API route (Node runtime).
export const AUTH_COOKIE = "lyra_auth";
function toB64(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
// Memoize the derived token so the middleware doesn't recompute an HMAC on every request
// (password/secret don't change at runtime; keyed on both so a change still recomputes).
let _cache: { password: string; secret: string; token: string } | null = null;
/** The token a valid session cookie must carry, or "" when auth is disabled (no password). */
export async function expectedToken(): Promise<string> {
const password = process.env.LYRA_PASSWORD;
if (!password) return "";
const secret = process.env.LYRA_SECRET_KEY ?? "lyra";
if (_cache && _cache.password === password && _cache.secret === secret) return _cache.token;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(password));
const token = toB64(new Uint8Array(sig));
_cache = { password, secret, token };
return token;
}
/** Constant-time string compare (both operands are our own derived tokens). */
export function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
+4
View File
@@ -1,5 +1,9 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
// Re-exported for existing callers; the checks live in an edge-safe module (secret-key.ts)
// so the instrumentation hook can import them without pulling in node:crypto.
export { secretKeyProblem, PLACEHOLDER_SECRET_KEY } from "./secret-key";
function key(): Buffer {
const b64 = process.env.LYRA_SECRET_KEY;
if (!b64) throw new Error("LYRA_SECRET_KEY is not set");
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect, afterEach } from "vitest";
import { secretKeyProblem, PLACEHOLDER_SECRET_KEY } from "./secret-key";
const GOOD = Buffer.from("x".repeat(32)).toString("base64");
const LEGACY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
const original = process.env.LYRA_SECRET_KEY;
afterEach(() => {
if (original === undefined) delete process.env.LYRA_SECRET_KEY;
else process.env.LYRA_SECRET_KEY = original;
});
describe("secretKeyProblem", () => {
it("is fatal when missing", () => {
delete process.env.LYRA_SECRET_KEY;
expect(secretKeyProblem()).toEqual({ message: "LYRA_SECRET_KEY is not set", fatal: true });
});
it("is fatal on the placeholder", () => {
process.env.LYRA_SECRET_KEY = PLACEHOLDER_SECRET_KEY;
expect(secretKeyProblem()?.fatal).toBe(true);
});
it("is fatal on wrong length", () => {
process.env.LYRA_SECRET_KEY = Buffer.from("short").toString("base64");
expect(secretKeyProblem()).toMatchObject({ fatal: true });
expect(secretKeyProblem()?.message).toContain("32 bytes");
});
it("warns (not fatal) on the old public key", () => {
process.env.LYRA_SECRET_KEY = LEGACY;
const p = secretKeyProblem();
expect(p?.fatal).toBe(false);
expect(p?.message).toContain("PUBLIC");
});
it("accepts a proper key", () => {
process.env.LYRA_SECRET_KEY = GOOD;
expect(secretKeyProblem()).toBeNull();
});
});
+33
View File
@@ -0,0 +1,33 @@
// Edge-safe LYRA_SECRET_KEY validation (no node:crypto / Buffer), so the Next.js
// instrumentation hook — which is bundled for both the Node and Edge runtimes — can import
// it. crypto.ts re-exports these for its own callers.
// The obvious placeholder shipped in .env.example — must be replaced with a real key.
export const PLACEHOLDER_SECRET_KEY = "CHANGE_ME_generate_with_openssl_rand_base64_32";
// The valid-but-PUBLIC key git used to ship in .env.example. Warn (don't hard-fail): an
// existing install may have encrypted its credentials under it.
const LEGACY_PUBLIC_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
/** Describe an unusable/insecure LYRA_SECRET_KEY, or null if it's fine. Used at startup. */
export function secretKeyProblem(): { message: string; fatal: boolean } | null {
const b64 = process.env.LYRA_SECRET_KEY;
if (!b64) return { message: "LYRA_SECRET_KEY is not set", fatal: true };
if (b64 === PLACEHOLDER_SECRET_KEY)
return { message: "LYRA_SECRET_KEY is still the .env.example placeholder", fatal: true };
let byteLen: number;
try {
byteLen = atob(b64).length;
} catch {
return { message: "LYRA_SECRET_KEY is not valid base64", fatal: true };
}
if (byteLen !== 32)
return { message: "LYRA_SECRET_KEY must decode to 32 bytes", fatal: true };
if (b64 === LEGACY_PUBLIC_KEY)
return {
message:
"LYRA_SECRET_KEY is the old PUBLIC example key that shipped in git — anyone can " +
"decrypt your stored credentials; generate a fresh key and re-enter them",
fatal: false,
};
return null;
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse, type NextRequest } from "next/server";
import { AUTH_COOKIE, expectedToken, safeEqual } from "@/lib/auth";
// Gate every request behind the shared password when LYRA_PASSWORD is set. The /login
// page and the auth API are always reachable; static assets are excluded via `matcher`.
export async function middleware(req: NextRequest) {
const token = await expectedToken();
if (!token) return NextResponse.next(); // auth disabled (no LYRA_PASSWORD)
const { pathname } = req.nextUrl;
if (pathname === "/login" || pathname === "/api/auth") return NextResponse.next();
const cookie = req.cookies.get(AUTH_COOKIE)?.value ?? "";
if (safeEqual(cookie, token)) return NextResponse.next();
if (pathname.startsWith("/api/")) {
return new NextResponse("Unauthorized", { status: 401 });
}
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname + req.nextUrl.search);
return NextResponse.redirect(url);
}
export const config = {
// Run on everything except Next internals and static assets (which carry no secrets).
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon.svg|fonts/).*)"],
};
+74 -1
View File
@@ -1,10 +1,13 @@
import json
import os
import shutil
import time
from typing import Callable
import requests
_DEFAULT_DOWNLOADS_ROOT = "/slskd-downloads"
_AUDIO_EXT = {"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac"}
_LOSSLESS_EXT = {"flac", "wav"}
_SEARCH_POLLS = 40 # * 3s ≈ 2 min
@@ -34,6 +37,15 @@ class SlskdClient:
def __init__(self, config: dict):
self._url = (config.get("slskd.url", "") or "").rstrip("/")
self._key = config.get("slskd.api_key", "")
# Where slskd (an external daemon) writes finished downloads. slskd saves into ITS
# OWN directory, so this path must be a share/mount both slskd and this worker see.
# Precedence: the slskd.downloadsDir Config row (Settings-overridable) > the
# SLSKD_DOWNLOADS_ROOT env (the container mount point) > a sensible default.
self._downloads_dir = (
config.get("slskd.downloadsDir")
or os.environ.get("SLSKD_DOWNLOADS_ROOT")
or _DEFAULT_DOWNLOADS_ROOT
)
def is_configured(self) -> bool:
return bool(self._url and self._key)
@@ -138,4 +150,65 @@ class SlskdClient:
if not completed:
raise TimeoutError(f"soulseek download did not complete for {username}")
return {"track_count": total, "path": dest}
# slskd saved the files into its OWN downloads dir — retrieve them into `dest` so the
# pipeline (which verifies + imports against `dest`/staging) actually sees the audio.
moved = self._retrieve(files, dest)
if moved == 0:
raise RuntimeError(
f"soulseek transfer completed but no files were found under "
f"{self._downloads_dir!r} to move into staging — check that the slskd "
f"downloads dir is shared/mounted (SLSKD_DOWNLOADS_ROOT / slskd.downloadsDir)"
)
return {"track_count": moved, "path": dest}
def _retrieve(self, files: list[dict], dest: str) -> int:
"""Move the just-finished slskd files out of its downloads dir into `dest`.
slskd's on-disk layout can vary by version/config, so match tolerantly: for each
wanted file, find on-disk candidates by basename anywhere under the downloads root,
then prefer the one whose parent-folder name matches the remote directory and whose
size matches. Returns the number of files actually moved. Offline-testable.
"""
root = self._downloads_dir
if not root or not os.path.isdir(root):
raise RuntimeError(
f"slskd downloads dir not found: {root!r} — mount slskd's downloads "
f"directory into the worker (SLSKD_DOWNLOADS_ROOT / slskd.downloadsDir)"
)
os.makedirs(dest, exist_ok=True)
wanted: dict[str, dict] = {} # basename(lower) -> {parent, size}
for f in files:
name = f.get("filename", "")
wanted[_basename(name).lower()] = {
"parent": _basename(_dirname(name)).lower(),
"size": f.get("size"),
}
found: dict[str, list[str]] = {b: [] for b in wanted}
for cur_root, _dirs, fnames in os.walk(root):
for fn in fnames:
key = fn.lower()
if key in found:
found[key].append(os.path.join(cur_root, fn))
moved = 0
for basename, paths in found.items():
if not paths:
continue
want = wanted[basename]
def _score(p: str) -> tuple[int, int]:
parent_ok = int(os.path.basename(os.path.dirname(p)).lower() == want["parent"])
size_ok = 0
try:
size_ok = int(bool(want["size"]) and os.path.getsize(p) == want["size"])
except OSError:
pass
return (parent_ok, size_ok)
best = max(paths, key=_score)
shutil.move(best, os.path.join(dest, os.path.basename(best)))
moved += 1
return moved
+30
View File
@@ -4,6 +4,36 @@ import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# The obvious placeholder shipped in .env.example — must be replaced with a real key.
PLACEHOLDER_SECRET_KEY = "CHANGE_ME_generate_with_openssl_rand_base64_32"
# The valid-but-PUBLIC key git used to ship in .env.example. Anyone can decrypt creds
# encrypted with it, so warn — but don't hard-fail, since an existing install may have
# encrypted its credentials under it and hard-failing would brick it mid-flight.
_LEGACY_PUBLIC_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
def secret_key_problem() -> tuple[str, bool] | None:
"""Return (message, fatal) if LYRA_SECRET_KEY is unusable or insecure, else None.
fatal=True ⇒ the app should refuse to start; fatal=False ⇒ start but warn loudly."""
b64 = os.environ.get("LYRA_SECRET_KEY")
if not b64:
return ("LYRA_SECRET_KEY is not set", True)
if b64 == PLACEHOLDER_SECRET_KEY:
return ("LYRA_SECRET_KEY is still the .env.example placeholder", True)
try:
k = base64.b64decode(b64)
except Exception:
return ("LYRA_SECRET_KEY is not valid base64", True)
if len(k) != 32:
return ("LYRA_SECRET_KEY must decode to 32 bytes", True)
if b64 == _LEGACY_PUBLIC_KEY:
return (
"LYRA_SECRET_KEY is the old PUBLIC example key that shipped in git — anyone "
"can decrypt your stored credentials; generate a fresh key and re-enter them",
False,
)
return None
def _key() -> bytes:
b64 = os.environ.get("LYRA_SECRET_KEY")
+21
View File
@@ -1,4 +1,5 @@
import os
import sys
import time
import uuid
@@ -6,6 +7,7 @@ import psycopg
from lyra_worker.claim import claim_next
from lyra_worker.config import get_config
from lyra_worker.crypto import secret_key_problem
from lyra_worker.db import wait_for_db
from lyra_worker.discovery import DiscoveryConfig, DiscoveryResult, run_discovery
from lyra_worker.library import clear_staging_root
@@ -165,7 +167,26 @@ def maybe_run_discovery(conn, sources, browser, config, dcfg, now, last_discover
return now if due else last_discover_tick
def _require_secret_key() -> None:
"""Fail fast (or warn loudly) on a missing/placeholder/public LYRA_SECRET_KEY before
the worker touches any encrypted credential."""
problem = secret_key_problem()
if problem is None:
return
msg, fatal = problem
banner = "=" * 72
if fatal:
print(
f"\n{banner}\nFATAL: {msg}.\nGenerate one: openssl rand -base64 32\n"
f"Then set LYRA_SECRET_KEY in your .env and restart.\n{banner}\n",
flush=True,
)
sys.exit(1)
print(f"\n{banner}\nWARNING: {msg}.\n{banner}\n", flush=True)
def run_forever() -> None:
_require_secret_key()
conn = wait_for_db()
clear_staging_root(STAGING_ROOT) # sweep any staging dirs orphaned by a prior crash
adapters = build_adapters(get_config(conn))
+40
View File
@@ -0,0 +1,40 @@
import base64
import pytest
from lyra_worker.crypto import PLACEHOLDER_SECRET_KEY, secret_key_problem
_GOOD = base64.b64encode(b"x" * 32).decode()
_LEGACY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.delenv("LYRA_SECRET_KEY", raising=False)
def test_missing_key_is_fatal(monkeypatch):
assert secret_key_problem() == ("LYRA_SECRET_KEY is not set", True)
def test_placeholder_is_fatal(monkeypatch):
monkeypatch.setenv("LYRA_SECRET_KEY", PLACEHOLDER_SECRET_KEY)
msg, fatal = secret_key_problem()
assert fatal is True and "placeholder" in msg
def test_wrong_length_is_fatal(monkeypatch):
monkeypatch.setenv("LYRA_SECRET_KEY", base64.b64encode(b"short").decode())
msg, fatal = secret_key_problem()
assert fatal is True and "32 bytes" in msg
def test_legacy_public_key_warns_not_fatal(monkeypatch):
monkeypatch.setenv("LYRA_SECRET_KEY", _LEGACY)
msg, fatal = secret_key_problem()
assert fatal is False and "PUBLIC" in msg
def test_good_key_is_fine(monkeypatch):
monkeypatch.setenv("LYRA_SECRET_KEY", _GOOD)
assert secret_key_problem() is None
+73
View File
@@ -0,0 +1,73 @@
"""Offline tests for SlskdClient._retrieve — the step that moves slskd's finished
downloads out of its own dir into the pipeline's staging dir. The search/download REST
flow stays live-only (test_soulseek_live.py); retrieval is pure filesystem, so it is
unit-testable here."""
import os
from lyra_worker.adapters._slskd import SlskdClient
def _client(downloads_dir: str) -> SlskdClient:
return SlskdClient({"slskd.url": "http://x", "slskd.api_key": "k",
"slskd.downloadsDir": downloads_dir})
def _write(path: str, content: bytes = b"audio") -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(content)
def test_retrieve_moves_files_from_slskd_dir(tmp_path):
downloads = tmp_path / "slskd"
dest = tmp_path / "staging"
# slskd's typical layout: <downloads>/<remote parent folder>/<file>
_write(str(downloads / "Artist - Album" / "01 One.flac"))
_write(str(downloads / "Artist - Album" / "02 Two.flac"))
files = [
{"filename": r"@@abc\music\Artist - Album\01 One.flac", "size": 5},
{"filename": r"@@abc\music\Artist - Album\02 Two.flac", "size": 5},
]
moved = _client(str(downloads))._retrieve(files, str(dest))
assert moved == 2
assert sorted(os.listdir(dest)) == ["01 One.flac", "02 Two.flac"]
# the source files were moved, not copied
assert os.listdir(downloads / "Artist - Album") == []
def test_retrieve_disambiguates_by_parent_and_size(tmp_path):
downloads = tmp_path / "slskd"
dest = tmp_path / "staging"
# a decoy with the same basename in a different folder + wrong size
_write(str(downloads / "Other Album" / "01 One.flac"), b"wrongsize!!")
_write(str(downloads / "Artist - Album" / "01 One.flac"), b"right")
files = [{"filename": r"x\Artist - Album\01 One.flac", "size": len(b"right")}]
moved = _client(str(downloads))._retrieve(files, str(dest))
assert moved == 1
with open(dest / "01 One.flac", "rb") as fh:
assert fh.read() == b"right" # picked the parent+size match, not the decoy
def test_retrieve_returns_zero_when_nothing_matches(tmp_path):
downloads = tmp_path / "slskd"
downloads.mkdir()
dest = tmp_path / "staging"
files = [{"filename": r"x\Album\missing.flac", "size": 1}]
assert _client(str(downloads))._retrieve(files, str(dest)) == 0
def test_retrieve_raises_when_downloads_dir_absent(tmp_path):
dest = tmp_path / "staging"
client = _client(str(tmp_path / "does-not-exist"))
files = [{"filename": r"x\Album\a.flac", "size": 1}]
try:
client._retrieve(files, str(dest))
except RuntimeError as e:
assert "downloads dir not found" in str(e)
else:
raise AssertionError("expected RuntimeError for a missing downloads dir")