Compare commits
5 Commits
c918cba654
...
446a4bc2d2
| Author | SHA1 | Date | |
|---|---|---|---|
| 446a4bc2d2 | |||
| 8cd5392ec3 | |||
| 190e0ef4b6 | |||
| 851f725a98 | |||
| f74fd5ab88 |
+24
-2
@@ -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
|
||||
|
||||
@@ -40,3 +40,5 @@ Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
slskd-downloads/
|
||||
backups/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" : ""}`}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/).*)"],
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user