Commit Graph
4 Commits
  • Fix a path traversal out of the music library
    `year` was appended to the album directory name AFTER safe() had run, so it
    was the one path component reaching the filesystem unchecked:
    
      POST /api/request {"artist":"E","title":"p","year":"../../../../tmp/pwned"}
      -> library/E/Singles (../../../../tmp/pwned)/p.flac
      -> normalises to ../tmp/pwned)/p.flac
    
    Any authenticated user could do it, guests included, and it wrote a real
    audio file to an arbitrary path the process could reach. Existing files were
    not overwritten -- tag_and_file returns early when the destination exists --
    so this created new files and directories rather than clobbering.
    
    Fixed in two layers, because the first is a property of five call sites that
    must each stay correct and the second cannot be forgotten:
    
      1. safe_year() whitelists \d{4}, applied in enqueue_job (the choke point for
         all three request paths) and again in tag_and_file.
      2. tag_and_file resolves the destination and refuses anything that is not
         under the library root, before mkdir rather than after.
    
    Also from the same pass:
    
      - Cap import at 500 pasted lines / 1000 expanded tracks. URL expansion runs
        BEFORE the quota check -- it has to, since a playlist URL is one line and
        200 tracks -- so without a cap a paste of 10,000 album URLs was 10,000
        requests to Deezer from this IP before any quota was consulted.
      - Clamp search limit (1-50), jobs limit (1-1000) and activity days (1-365).
        search limit fanned out one /album/{id} enrichment per distinct album.
      - safe_cover() requires https and <=300 chars. img-src permits data:, so
        the field was free storage for megabytes of base64 per job.
      - Truncate filename components to 150 chars; a long title used to fail the
        import with ENAMETOOLONG after the download had already completed.
      - Treat Deezer's 403 as no-results. Its WAF rejects injection-looking
        queries, which surfaced to the user as a 502 "service broken".
    
    Add tests/: 42 security assertions and 35 authorization assertions covering
    the role matrix, last-admin protection, mid-session revocation, job scoping
    and OAuth CSRF. All 77 pass.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Stop the admin panel hanging when slskd is down
    /api/admin/system probed slskd with the shared client, whose 30s timeout is
    sized for searches and transfers. So the panel sat on skeletons for the full
    30s exactly when slskd was unreachable -- the case you open it to diagnose --
    and the 5s UI poll stacked requests behind it. Probes now get 4s: above a LAN
    round-trip, under the poll interval.
    
    httpx.ConnectTimeout also stringifies to "", so the banner rendered
    "slskd unreachable -- " with nothing after the dash. Fall back to the
    exception class name.
    
    Add dev.sh, because the documented dev commands could not work: MUSE_DB
    defaults to /config/muse.db which a Mac cannot write, and the auth preflight
    refuses to boot without a client ID.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Add multi-user: Google OAuth, roles, quotas, admin panel
    Phase 2. The schema landed last commit; this makes it real.
    
    AUTH is now multi-provider. MUSE_AUTH takes a comma list, so github,google
    runs both. The callback URL is unchanged — /auth/callback for every provider,
    with the provider carried in the state COOKIE rather than the query string —
    so adding Google needs no edit to an already-registered GitHub app, and the
    callback can't be steered to a different provider by the redirect. With more
    than one enabled, /auth/login shows a chooser built from style attributes
    only, which the CSP already permits, so it needs no stylesheet and no extra
    hash.
    
    Google identities are the VERIFIED email. An unverified one is refused: anyone
    can put someone else's address on an account they control, and the allowlist is
    keyed on exactly that string.
    
    WHO MAY LOG IN is now two rules — the MUSE_ALLOWED_USERS bootstrap list, then
    a non-disabled users row. Inviting is adding a row; no email, no token, no
    expiry to manage. MUSE_ALLOWED_USERS stays mandatory only while the table is
    empty, so a working multi-user install isn't held hostage by an env var it has
    outgrown, but an empty database with no allowlist still refuses to start
    because nobody could ever get in. The rule is one function used at login AND
    on every request: checking only at sign-in isn't revocation, it's a delay.
    
    QUOTAS are requests per calendar month and bytes of imported audio, 0 =
    unlimited. Calendar month, not a rolling window — "12 left until the 1st" is
    something a person can plan around. Batches are checked against the whole
    batch, so a 30-track album with 5 requests left is refused up front rather
    than accepted and abandoned halfway. Storage is measured from the file
    actually written, not the size the peer advertised. Admins are exempt.
    
    ADMIN PANEL: host CPU/memory/disk read straight from /proc — no psutil, no
    compiled dependency, and in a container /proc reports the host, which is what
    you want for a homelab box. Plus library totals, slskd connection state, a
    30-day chart, per-user and per-artist breakdowns, and inline role/quota
    editing. The chart is hand-rolled SVG: a charting library would add ~150KB for
    one stacked bar chart and most of them inject <style> at runtime, which this
    app's CSP blocks.
    
    Guard rails: the last active admin cannot be demoted, disabled or deleted;
    disabling revokes sessions immediately; deleting a user keeps their jobs,
    because the history records what is in the library and why. Requests are
    private by default — users see their own, admins see all — and retrying
    someone else's job returns 404 rather than 403, since a distinct error would
    confirm the id exists.
    
    Also drops AnimatePresence mode="wait" from the tab container. It gates the
    incoming panel on the outgoing one's exit animation reporting finished, so
    anything that stalls the frame loop leaves the UI stuck on the old tab with no
    way forward — found while testing, where a backgrounded page froze navigation
    entirely. A cosmetic transition should never gate navigation.
    
    Verified: admin endpoints 200/403/403/401 for admin/user/guest/anon; last-admin
    demote, disable and delete all 409; disable revokes mid-session; quota refuses
    the 3rd of 2 and a 30-track batch, then succeeds once raised; users see only
    their own jobs; cross-user retry 404s; bad role/provider/duplicate/empty patch
    all rejected; multi-provider chooser and both redirects correct; three CSRF
    variants 400; preflight refuses unknown providers, missing secrets, and an
    empty database with no allowlist. CPU, memory and disk confirmed populated
    inside the linux/amd64 container.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Rebuild the UI on Next.js, and give search real metadata
    The old page was hand-written HTML/CSS/JS. That was fine for a search box and
    a list, but the roadmap here is multi-user, an admin dashboard with usage
    graphs, and an import-review queue — all of which get painful in vanilla DOM
    code. Moves to Next.js 16 / React 19 / Tailwind 4 / Framer Motion.
    
    Built as a STATIC EXPORT served by FastAPI. Running `next start` alongside
    uvicorn would add a Node runtime, a second process to supervise, and a second
    thing that can be down while the other is up. There is no use for SSR — every
    view is behind auth and renders from live JSON — so the deployment stays one
    container, one process, one healthcheck. Image is 60MB; Node only exists in
    the build stage.
    
    Search now carries what a music UI needs:
    
    - year and genre, grafted on from /album/{id} concurrently, deduplicated by
      album and cached forever since a release date is immutable
    - 30-second previews, so you can hear a track before committing a Soulseek
      search to it. Not a library player — that is Navidrome's job.
    - autocomplete over songs/albums/artists, debounced and aborted per keystroke
      so a slow response to "rad" cannot land after "radiohead"
    - album and artist pages, with `Get all` to queue a release in one request
    - the accent colour is sampled from the artwork, so the palette follows the
      music. Deezer's CDN sends ACAO:*, which is what makes canvas sampling work
      with no image proxy.
    
    /api/album/{id} deliberately calls Deezer twice: the track list embedded in
    the album response has no track_position, and using the list index instead
    misnumbers every multi-disc release — verified against the White Album, where
    disc 2 track 1 would have been filed as 18.
    
    Schema is multi-user ready ahead of the feature: users table with roles and
    quotas, requested_by on jobs, and the first login becomes admin. Auth is still
    the single-user GitHub allowlist.
    
    CSP survives all of this intact. Next emits inline hydration scripts, which
    script-src 'self' blocks; rather than per-request nonces, their SHA-256 is
    computed at startup from the built page — a static export never varies between
    requests, so hashes need no maintenance and no HTML rewriting. The one
    concession is style-src-attr 'unsafe-inline', because Framer Motion writes its
    pre-hydration state into style="" and without it every element flashes in.
    That permits style attributes only. img-src/media-src now name Deezer's CDN
    explicitly instead of allowing https: wholesale, closing an exfiltration
    channel that survived connect-src 'self'. /_next/* sits behind the session
    too — there is no local login page to style, so the bundle need not be public.
    
    Verified: unauthenticated /→302 and every /api/*→401 including the four new
    endpoints, forged state→400, forged cookie→401, misconfigured auth refuses to
    start, and the production image hydrates with zero CSP violations.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>