Commit Graph
3 Commits
  • Bind invites to a provider, and email them
    Wanted shape: the owner signs in with GitHub, everyone invited later signs in
    with Google. That was previously a convention, not a boundary -- may_login
    checked only that a live row existed, so any enabled provider could satisfy
    any invite.
    
    Now an invite is bound to the provider it was issued for. friend@gmail.com
    invited for Google cannot be redeemed over GitHub, and there is never a
    second, weaker door onto an existing account. The per-request re-check still
    passes provider=None, because there the question is only whether the account
    is still live.
    
    Two related fixes fell out:
    
      - upsert_user no longer refreshes `provider` from the login. It is part of
        the identity an admin issued and may_login now enforces it, so overwriting
        it on sign-in would let a successful login silently rewrite the very rule
        that permitted it.
      - a Google invite must be a full email address. A bare username would
        authorise something Google can never return, and the invitee would see
        only "not authorised" with no way to work out why.
    
    INVITE EMAILS. Adding a user now mails them, when SMTP is configured and there
    is an address -- for Google there always is, since the identity IS one. The
    response reports whether the mail actually went and why not, because "Invited"
    alone reads as success while the person waits for an email nobody sent.
    
    The link carries NO token, deliberately. The row already names the exact
    identity permitted and Google will not hand us an unverified address, so
    possession of the mail is not what authorises anyone: a forwarded invite
    grants nothing, nothing expires, and resend is always safe. A signed expiring
    token would only buy the ability to withdraw an unused invitation, which
    deleting the row already does.
    
    Resend is offered in the panel on anyone who has an address and has never
    signed in -- the likeliest reason being that they missed it.
    
    tests/test_invites.py: 29 assertions driving a real SMTP conversation against
    a sink server, plus the full provider-binding matrix. 119 tests pass across
    four suites.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Add review, discover, email; rebuild the design system
    Three features and a typographic pass over everything.
    
    REVIEW. Picking a file and trusting it are different questions, so they now
    use different numbers. The existing rank decides which candidate to fetch and
    is right to weigh peer upload slots and queue length -- but none of that says
    anything about whether the file is the correct recording, and a fast peer must
    not be able to raise the confidence of a wrong match. So confidence() scores
    the filename alone: title 0.75, artist 0.25, album +0.05, and a variant tag
    you did not ask for multiplies by 0.8 AND caps below the auto threshold.
    
    Confidence gates the IMPORT, not the download. The transfer completes either
    way and the file waits in downloads, so approving is instant and rejecting is
    a delete. Holding the transfer instead would mean the peer is usually gone by
    the time anyone looks.
    
    The two cases it exists for: "02 Creep.flac" in an unsorted folder scores 0.75
    -- title exactly right, artist simply absent, the commonest honest case there
    is -- and must be reviewed rather than binned. And "Creep (Live at
    Glastonbury)" scores 0.80 with BOTH artist and title perfect; the cap is what
    stops a live recording auto-filing over the studio version. Ask for "Creep
    (Acoustic)" and it auto-imports at 0.97, because then the variant is the
    request.
    
    DISCOVER. Related artists seeded from what you have actually imported, plus a
    recap of top artists, genres and decades. Built on muse's own request history,
    never on listening data: Navidrome owns play counts and history, and a second
    source of truth for those would disagree with the one you listen through.
    
    EMAIL. Optional, off unless MUSE_SMTP_HOST is set. Sends on import and on
    review-needed. smtplib is blocking so it runs off the event loop, and every
    failure is logged and swallowed -- a notification is never worth failing an
    import over. Google accounts need no configuration, since the identity is an
    address. Each user owns their own setting under Settings, deliberately not an
    admin route: an address you cannot change yourself is someone else's chore.
    
    DESIGN. A real type scale (display/title/headline/body/callout/caption) whose
    tracking tightens as size grows, which is the single biggest tell of typography
    nobody set. Specular top edges on glass surfaces -- blur alone reads as a grey
    rectangle. A two-part elevation scale, contact shadow plus soft light. Semantic
    colours are now per-theme: the amber tuned for the dark background failed
    contrast outright on white, which was exactly the text a reviewer most needs to
    read. The tab underline became a sliding pill so the eye tracks one object, and
    Review only appears when something is actually waiting.
    
    Add tests/test_matching.py: 13 assertions on the confidence bands rather than
    the exact scores. 90 tests pass across the three suites.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • 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>