Compare commits

..
66 Commits
Author SHA1 Message Date
peter e23128b4a1 release: 0.80.0 (improvement/e9.5-onboarding-access)
gate / frontend (push) Failing after 2m26s
gate / backend (push) Successful in 3m42s
2026-09-04 23:28:30 +02:00
peter 0d5f8f0708 chore(release): 0.80.0 — E9.5 Onboarding & Access flow
Release notes for the epic: bilingual mail, one-mail-one-click approval, order-independent gates
with named states, canonical Gmail identity, the admin lifeline for sign-ups stuck outside, and the
four-round closing review that converged 12 -> 4 -> 3 -> 2.
2026-09-04 23:28:23 +02:00
peter a03bf8d371 fix(auth): stop erasing a reset link the signed-in tree never reads (E9.5 review round 4)
Round 4 came back converged — nothing above low, and both findings in one family. This is the half
worth fixing, because the branch introduced it.

Only the signed-out auth card consumes a `#reset=` token, and S2 moved the address-bar cleanup up to
a hook App mounts unconditionally. So opening a password-reset link in a browser that already holds a
session erased the token from the URL while nothing had read it: the page loads, nothing is said, and
clicking the link again does exactly the same, because the token is untouched and the session is
still there. That is the dead end this epic exists to remove, and `main` did not have it — the strip
used to live inside the card, which a signed-in visitor never renders.

An unspent reset fragment now stays, which restores the recovery that shape always had: signing out
reloads with the fragment intact and the form is waiting. Once the card has spent the token a later
reload meets S4's named dead-link screen, which is true. The affirmative version — a banner telling a
signed-in visitor that reset links open in a signed-out window — is logged with DX-15, along with the
`hashchange` half that still only recognises `#verify=`.
2026-09-04 23:14:46 +02:00
peter 3dc14402b0 test(auth): pin what rounds 1 and 2 left unpinned (E9.5 review round 3)
Three low findings, all in what the review rounds themselves wrote.

The comment on `test_approving_an_unverified_account_keeps_its_password` still asserted the argument
rounds 1 and 2 refuted — that dropping the approval-time revoke was safe because `password_login`
reads `email_verified` first — while the docstrings on the two functions it describes now say the
opposite. Two contradictory "why" records for one rule is how the round-1 takeover primitive got
reasoned into existence in the first place, so the test comment now carries the corrected account
and names SEC-16.

`test_a_later_registration_leaves_a_link_already_in_flight_alive` asserted a subset relation that is
vacuously true on an empty set, and the guard that made it non-empty had gone out with the revert.
It now pins both halves: the old link survives, and exactly one new one appears.

Round 2's own fix — activation moved to after a successful send — was unpinned; putting it back
above the send left the suite green. The failed-invitation test now also asserts the account was not
activated by a request the UI reported as a failure.
2026-09-04 23:04:09 +02:00
peter 540ca244c8 revert(auth): the newest registration does not own an unverified account (E9.5 review round 2)
Round 1 fixed the address pre-emption chain by letting a later registration replace the password on
an unverified account and delete its outstanding verify tokens. Round 2 read that code and showed it
was worse than what it fixed, so it goes back out.

It closes nothing. The rule is symmetric and an attacker can simply register last — on a timer,
inside the five-per-five-minutes budget — while the owner registers once. What it does add is two
primitives an unauthenticated caller did not have: overwrite a real registrant's chosen password,
and delete the confirmation link already sitting in their inbox (the approval mail's 7-day token is
an ordinary verify token, so the blanket delete took that too). Worst of all it fired on the rows
SEC-10 left stripped — password_hash NULL, password_revoked_at set, active — installing an
attacker's password where the entire state means "there is none", and clearing the stamp that is the
only reason password-reset still accepts them. That is a straight takeover which did not exist
before, in the code written to prevent one.

The owner's recourse needed no new primitive and already worked: password-reset accepts a
pre-empted account, proves the mailbox, rewrites the hash and bumps the session epoch. What remains
is the bounded window between a confirmation click and that reset, which is SEC-16 — logged with
this attempt written down, so the next person does not reach for it again.

Two smaller round-2 findings go with it. `upsert_pending_invite`'s new exact-spelling tie-break ran
before any status preference, so it could deterministically pick a DENIED sibling over a pending one
and reopen it — the very outcome its comment claimed to prevent; the status preference now comes
first. And `send_invitation` activated the account before the send it might have to report as
failed, so a request the UI called a failure had quietly let someone in; activation now follows a
mail that actually left. The verify token minted to render that mail still commits either way — it
is single-use, expires on its own and was mailed nowhere, and the note says so rather than leaving
the next reader to wonder whether it was missed.
2026-09-04 22:54:52 +02:00
peter 563fa0513d fix(auth): close what the epic's own review found (E9.5 review round 1)
Eleven findings survived adversarial verification; these are the fixes.

**Address pre-emption.** S1 dropped the approval-time password revoke, arguing `password_login`
refuses an unverified account before it reads `is_active`. Two verifiers showed that holds only
while the address stays unverified — and S6's approval mail exists to get the real owner to end that
state, at which point the squatter's password works. While an address is unverified the NEWEST
registration now owns it: the password is replaced and every outstanding verify token deleted, so
the owner takes the row back simply by registering, and a link already in flight cannot confirm it
for anyone else. The residual (an owner who confirms and neither registers nor resets) is logged as
SEC-16 beside SEC-15, where the re-attestation path it needs already lives. The comments that
asserted the old, false invariant now say what actually guards this.

**The entry URL.** S2 moved `clearEntryUrl()` out of the signed-out auth card into a hook App mounts
unconditionally, and it blanket-stripped the whole query string, the fragment and `history.state` on
the FIRST commit. That broke first-run setup outright — the wizard mounts only after its query
resolves and then finds no `?token=` — and silently ate `?link=`/`?oauth=` (no toast, no refetch
after connecting Google), `?v=`/`?chan=` deep links, and scroll restoration. It now clears only the
fragment and the two one-time flags it owns, and preserves `history.state`.

**The env allow-list.** S3 canonicalised the Invite branch of `is_allowed` and left the
ALLOWED_EMAILS/ADMIN_EMAILS fallback matching literally — the 2026-09-03 lockout with a different
source of truth, plus a shadowing trick where an unauthenticated request under a sibling spelling
permanently masked an env grant. Canonical on both sides now, and the same for the delete dialog's
warning and the ADMIN_EMAILS role bootstrap.

**Honest states.** `send_invitation` stamped "I already wrote to them" before a fail-soft background
send; it sends inline and records only what left. The admin-side resend answered `{ok: true}` for a
dropped mail; it now says so. Both also activate/serialize consistently with the other decide paths.

**Correctness sweep.** The 7-day approval link said "expires in 168 hours" — durations render in
days once there is more than one. `_register_account`, `upsert_pending_invite` and `add_invite`
picked among canonical siblings with no ORDER BY and no exact-spelling tie-break, so which account
got the re-send, and whether a denied row was reopened over a pending one, was up to Postgres.
`purge_user` deleted every canonically-equal invite, revoking a surviving sibling account's access.
`ConfirmProvider` wrapped its ReactNode message in a `<p>`. Approving no longer leaves the
stuck-sign-ups list stale. The refusal panels announce and take focus.

**Tests.** Three mailers rewritten this epic never rendered in any test; every `send_*` now renders
in both languages. The orphan-key guard could not fail (it scanned the catalog that defines the
keys) and had no reverse direction. Newly pinned: canonical activation, the surviving sibling's
grant, a failed invitation and resend, an expired reset link, a throttled login's silence, the
per-language admin fan-out, and that the entry URL keeps what it does not own.
2026-09-04 22:42:15 +02:00
peter 10c0ca4cf7 feat(email): write to people in their own language (E9.5 S7)
Twelve mails carried hardcoded English while the app itself has enforced HU/EN parity since E5.
A mail is the one user-facing surface nobody can re-render by switching the UI language: it arrives
once, in whatever language we picked, and stays that way. That made the epic's own headline mail —
"one mail, one click" — English for a Hungarian reader.

The text moved to a catalog (`mailtext.py`) with a `text(key, lang, **vars)` renderer, and the parity
rule is now a test rather than a habit: every key must carry both languages AND the same
`{placeholders}`. A translation that drops `{link}` sends a mail with no link in it; one that invents
a placeholder raises inside a background task nobody is watching. Both are silent in production.

Every `send_*` takes the language as its second argument, resolved by the REQUEST handler and never
inside the mail — these all run as background tasks, after the request's session is gone, which is
the same reason the approval mail computes its link in the handler.

Where the language comes from, in order: the account's own preference; failing that, the language the
person asked us in; failing that, English. The middle one is new — `invites.lang`, migration `0082` —
because the mails an access request produces go to an address that usually has no account yet, and
the signal does exist: the page their form was submitted from, or the locale Google reported for a
sign-in we refused. Captured rather than derived, for the third time in this epic (`google_email` in
S3, `preferences.language` in S6 taught the same lesson): a signal that exists only in one moment
cannot be backfilled later. So the admin is asked in exactly one place — the invitation to an address
they typed in themselves, where nobody has ever told us anything.

That send is now a dialog rather than a bare click, which is also where the "you already wrote to
them" warning belongs — it used to live only in a tooltip the admin may never hover, guarding the one
action whose failure mode is mailing a real person twice.

S6's registration `lang` field turned out to be dead on arrival: the backend accepted it, the
frontend never sent it, so every account has been storing nothing. The request builders now stamp the
UI language themselves — a signal that has to be passed in by every caller is one every caller can
forget, which is exactly how it shipped unset.

Admins are notified one mail per language group instead of one blast to everyone. They are people
with their own UI language; a single message would have to pick one of them and be wrong for the rest.

Also removes a duplicated `invited_at` column declaration on `Invite` (two identical definitions
landed in S6).
2026-09-04 20:29:33 +02:00
peter a76bab9b34 feat(auth): one mail, one click — the approval mail carries the confirmation link (E9.5 S6)
The funnel's common ordering made a person open TWO messages: one saying their access was approved,
another asking them to confirm their address. The approval mail now branches on what they actually
still have to do — carry the confirmation link, point at sign-in, or say a confirmation mail follows
once they register — so the usual case is one mail and one click. A mail that guesses wrong is worse
than a plain one: telling someone to sign in while their address is unconfirmed contradicts the
screen that will refuse them.

That embedded link lives 7 days rather than the usual 24 hours. Nobody sits waiting for an approval —
it can easily be read on Monday after a weekend, and an expired link spends the one-mail promise and
puts them back on two. The risk is small: the token only ever confirms an address; it signs nobody in
and sets no password.

The most likely root cause of the 2026-09-03 report that a confirmation mail "never arrived" is here
too, and it was never a delivery failure: re-registering an address that already had an unconfirmed
account returned in SILENCE — same cheerful "check your inbox", no mail sent at all. It now re-sends
the link. Nothing new leaks: the response and its timing are unchanged, and the public verify/resend
already does exactly this for the same input.

Whitelisting stays silent, and that is a decision, not an omission. The operator opens a door as a
possibility — usually already agreed with the person — and a row can sit approved for weeks with
nobody registering; a mail fired automatically there would be nudging someone who never asked.
Telling them is its own act on its own button, `invited_at` records it so a second click is a
decision rather than an accident, and the button only appears where nobody is behind the address yet:
"the door is open, come in" is meaningless to someone already inside.

Two things UAT caught that no test would have. Every fresh registration appeared in BOTH admin lists
at once, because the stuck-sign-ups filter keyed on "unconfirmed" — the rule is now "the address may
sign in, and this account still can't", which leaves an undecided request with the queue that owns
it. And the invitation button was offered on every approved row, half of which are existing users.

Also: the confirmation mail finally says what its link's rules are (once, 24 hours) — the reset mail
always has, while this one left the reader to discover both by meeting a dead link. The card's
"didn't get the email?" moves up under the form, and the three waiting messages name the spam folder,
which is where a first message to a fresh recipient sometimes lands.

Registration now records the language the sign-up page was in. Nothing reads it yet — the bilingual
templates are S7 — but it is the only moment that choice exists anywhere the server can see, and a
preference nobody recorded cannot be backfilled later.

UAT on localdev: re-registering re-sent the link (one token became two, one account); approval issued
a 7-day token and moved the account into the stuck list with only a re-send; whitelisting mailed
nobody; the invitation recorded itself and the row then warned that sending again would write twice.
Test data removed.
2026-09-04 04:05:28 +02:00
peter 7acdf5d577 feat(admin): surface the sign-ups that are stuck outside, and let an admin finish letting them in (E9.5 S5)
Approve an address BEFORE the person registers and their sign-up produces no queue row at all —
`_decide` only takes a still-pending invite — so the Access-requests screen sat empty, the tab badge
read zero, and the only working move was re-typing the address into the whitelist box. Nobody would
guess that, and on 2026-09-03 nobody did.

This is roadmap UX-25, where two earlier attempts were reverted after review. Both moved AUTHORITY:
auto-activating on registration turns a standing whitelist entry into a bearer grant for whoever
registers the address first, and reopening the approved invite to `pending` lets an anonymous sign-up
REVOKE a standing approval. What was actually missing was visibility, so this changes nothing about
what an unauthenticated caller can cause — the admin could already do exactly this by re-entering the
address; they just had no way to know they had to.

The section lists every account that cannot sign in yet and says WHICH gate is shut, because the two
dead ends look the same from the admin's chair: waiting for activation, or waiting on a confirmation
mail. Naming the unconfirmed ones is not decoration — on a no-SMTP instance that is the difference
between the owner and whoever registered the address first (SEC-15), and an admin about to let
someone in should see it. A person the queue above already covers is deliberately NOT listed here;
two buttons for one person is worse than one.

Activate is gated on `is_allowed` — the same function the sign-in paths consult, in core, so the
button that appears and the badge that counts cannot disagree. Without that gate it would
manufacture an inconsistent account: password sign-in only checks `is_active` and would let them in,
while their Google sign-in would be turned away and silently reopened as a fresh access request.
Letting in someone with no approved invite stays the whitelist box's job, which says so out loud.

The badge counts activations SEPARATELY from pending invites rather than widening an existing
field's meaning, and only counts what genuinely waits on the admin: an unconfirmed account waits on
its owner, so it is listed with a re-send but does not nag. The admin-side resend says plainly what
happened, including refusing when the instance has no working SMTP — mail is fail-soft everywhere
else, and that silence is exactly what left the 0.78.5 sign-ups with nothing to fall back on.

`send_verification_link` is now one function with three callers (registration, the public resend,
this one), so the link's shape and TTL cannot drift by who triggered it.

UAT on localdev, both languages: the UX-25 shape reproduced end to end (approve the address, then
register), the badge counting it, Activate letting them in and dropping the badge, the row losing
its Activate but keeping the re-send, a fresh token issued by the re-send, and both actions in the
audit log. Test data removed.
2026-09-04 03:27:48 +02:00
peter b1086e05dc fix(auth): stop drawing a reset form the link can no longer fill (E9.5 S4)
Reported from UAT: delete an account, then open its password-reset link. The card drew the full "set
a new password" form — because a `#reset=` fragment was present, and nothing more — and only after
the user had typed a password did the server answer 400. Measured first: no security hole. A user's
tokens cascade away with the row, so nothing can be set on a deleted or a re-created account. What
was left is a dead end of exactly the kind this epic exists to remove, and it is not rare: a reset
link lives one hour, mail is slow or lands in spam, and whoever got two mails clicks the older one.

password-reset/check answers whether a link is still usable WITHOUT spending it, so asking can never
cost the user their one real attempt — the property the tests pin hardest. It gets its own limiter,
because opening a link must not eat the budget for requesting one, and a throttled caller is told
"ok", never "dead": this check may only ever save someone a wasted form, so when it cannot answer it
degrades to the old behaviour instead of declaring a live link finished. The confirm endpoint stays
the authority, and its failure raises the same named screen, since the token can die between the two
calls or the probe itself may have failed.

A too-short password is deliberately NOT that screen. The length check runs before the token is
spent, so a typo costs the link nothing, and the card tells the two 400s apart by `reason` so only
one of them takes the form down.

Both email-link confirmations (verify, password-reset/confirm) now answer in the {code, reason}
shape S4 introduced. They were the last refusals a user meets by simply clicking an old link while
still being answered in hardcoded English under a bilingual UI. The seven remaining bare strings in
this module (register, request-access, the demo guards, set-password) sit behind client-side
validation the UI does first; they are logged as a follow-up rather than swept in silently here.

One bug found by the UAT itself, in the new code: the check went out FOUR times per opened link —
StrictMode double-invokes effects and the shell remounts the card as the session probe settles.
Harmless per request, but against a per-IP limit it would exhaust the budget in two reloads and then
silently fall back to drawing the form unchecked: nothing errors, the feature just stops working.
Deduped in a module-level map holding the in-flight promise (a ref dies with the remount that causes
it); a failed probe drops itself so a blip is never cached as "dead". The test renders the card
twice, because with one render the guard would rot undetected.

UAT on localdev: the reported scenario end to end (delete the account, open the link) shows the named
screen and no form; a live link still opens the form and completes; the CTA lands on the request-a-
link form; one probe per opened link. Throwaway accounts removed.
2026-09-04 02:38:01 +02:00
peter ed91c83dd7 fix(auth): name the state and the address behind a refused sign-in (E9.5 S4)
"Your account is awaiting admin approval." was true and useless. It never said WHICH mailbox the
account was on, and on 2026-09-03 that silence hid a Gmail alias mismatch for a day: a registration
as np.temu@gmail.com met a Google claim of nptemu@gmail.com, and every screen in the funnel agreed
to say nothing about it.

Every refusal password-login can answer with now uses the codebase's structured {code, reason}
detail, built in one helper. The three that arrive AFTER the password is proven carry the address on
the account plus both gate flags; the two that answer before it (invalid, rate_limited) carry
nothing, so a wrong password and an unknown account stay byte-identical. The frontend renders the
first three as a named screen in place of the form — there is nothing to retype once the password is
correct — with an icon, the two-gate checklist, the address in monospace, and a resend CTA where one
helps. Reporting each gate from its own flag is the payoff of S1: approved-but-unverified is a real
state now, and a screen that assumed a sequence would send that user off to wait for an admin who
has already decided.

The Google access-request notice names the address too, and says so when the request was already on
file under ANOTHER spelling of the same mailbox — the honest version of "we didn't open a second
one", stated only when it applies. It moved from the query string to the fragment: the notice now
carries an address, and a query string lands in every reverse-proxy access log. Same reasoning that
put the verify/reset tokens there, and the same owner already clears it. urlencode, not an f-string,
because URLSearchParams reads a bare + in a fragment as a space and would mangle exactly the
+tag addresses this epic exists to protect.

upsert_pending_invite returns the row it settled on beside the is-new flag, since the row's stored
address is what the notice has to name and what mail will go to.

Deviation from the PendingScreen mockup, deliberately: no "Sign out" button and no separate "Not
your address?" link. The mockup drew the panel in the app shell; the accepted decision (a) keeps the
pending user session-less, so there is no session to end, and the address is named directly above a
"Back to sign in" that answers the same question.

UAT on localdev, both languages: all three refusals with the typed spelling deliberately differing
from the stored one (the panel named the stored one), the resend CTA carrying the typed address into
the resend form, and both access-request banners with a +tag address surviving the fragment round
trip. Throwaway accounts and the language switch reverted.
2026-09-04 01:56:55 +02:00
peter a614ee2435 feat(auth): show the Google address beside the one the account registered with (E9.5 S3)
An account can now hold two true addresses: the one its owner typed, which a Google
sign-in no longer overwrites, and the one Google answers with. Only the first was
stored, so Settings could say 'Connected' without saying connected AS WHOM -- exactly
what someone who registered with a +tag needs to know.

users.google_email (migration 0080) records Google's answer at sign-in and at an
in-app link. /api/me exposes it ONLY when it differs from the account address, so the
comparison lives in one place and the majority, whose two addresses are the same
string, see no change at all. Rendered in the account menu as a muted third line under
the identity, and in the Settings sign-in row under the existing hint.

A short 'Google' label rather than a brand glyph: the app draws one icon set (lucide),
and a generic mail icon next to an email address says nothing. It also reads to a
screen reader, which an unlabelled logo would not.

No backfill is possible -- Google's answer is only known at sign-in -- so accounts
linked earlier stay NULL until their owner next signs in with Google.
2026-09-04 01:23:11 +02:00
peter c74487fbb3 fix(auth): keep the address the user registered with, and let it sign them in (E9.5 S3)
Two halves of one rule: the address someone typed is theirs to keep, and it must be
the one that gets them back in.

A plus-tagged or dotted Gmail is often a deliberate choice -- registering as
me+siftlode@gmail.com is how you find out later which site leaked your address. The
Google callback overwrote it with the account's primary, which can never contain a
'+', so the tag vanished on the first Google sign-in and, because sign-in matched the
stored address, took their chosen address with it. The rewrite line is older than
this epic, but S3 made it reachable here by matching adoption across spellings. It
now runs only when the mailbox GENUINELY changed, and lives in a named helper rather
than inline, so the rule has one home.

The three lookups that resolve a typed address -- password login, password reset,
verify resend -- go through find_user_by_email: exact spelling first, then the unique
canonical candidate, and None when several accounts share a mailbox rather than
guessing which one a password should open. Reset and resend previously answered a
different spelling with silence (they reply uniformly by design), which was a dead end
with no way out. Their mail now goes to the address on the account, never the one
typed into the form.
2026-09-04 01:07:54 +02:00
peter 876cece881 fix(auth): match identity on a canonical email, so one Gmail mailbox is one account (E9.5 S3)
Gmail delivers x.y@, xy@ and xy+tag@ to one inbox; the invite gate only lowercased.
On 2026-09-03 that turned an approved user's Google sign-in into a silent SECOND
access request -- OAuth returns the account's stored primary, which need not be the
spelling they typed -- and left prod with two user rows for one mailbox (ids 13, 14).
The +tag case fails every time: a Google account cannot contain +, so OAuth can only
ever answer with the base address.

Mail still goes to the address AS TYPED; only identity matching uses the canonical
form. utils.canonical_email is the single definition, and a model validator derives
users.email_canonical / invites.email_canonical on every write, so no call site can
forget it. Scope is narrow on purpose: dots are significant on most providers and the
Gmail rule does not hold for Workspace custom domains.

Swept every sibling of the same principle, not just the reported one: is_allowed,
upsert_pending_invite, the Google-callback adoption, _register_account (so no new
duplicate pair can be created), _activate_user_by_email, admin _accounts_for (an
invite backing a live user under another spelling used to look deletable) and
add_invite, plus purge_user, which now erases every canonically-equal invite instead
of leaving one behind that still grants access.

Migration 0079 backfills both columns and indexes them NON-uniquely: prod already
carries canonical duplicates (approved invite pairs since 2026-06-16), and merging
accounts is a judgement call about people's data, not something a migration does on
its own. A test pins the backfill SQL against the Python helper.
2026-09-04 00:14:12 +02:00
peter e5997ca567 fix(auth): consume a verification token that arrives without a page load (E9.5 S2)
Found in UAT. Navigating a tab that already runs the app to a URL differing only in
the fragment is a same-document navigation: nothing reloads, so the import-time entry
snapshot never sees the token. It sat unconsumed in the address bar and the previous
outcome stayed on screen -- the same silence this sprint set out to remove, wearing a
different hat. Pasting the emailed link into a running tab does exactly this, and our
verification mails were plain text until 0.78.8, which makes pasting the normal way to
use one.

A hashchange listener now picks up the new token, the guard tracks consumed tokens
rather than a single fired flag (still exactly one POST per token), and a fresh link
clears the standing outcome instead of leaving the old banner up.
2026-09-03 23:57:18 +02:00
peter c134b514c1 fix(auth): confirm an emailed verification link wherever it is opened (E9.5 S2)
The token was only ever consumed inside Welcome.tsx, which App renders ONLY when
there is no session. Opening the emailed link in a browser that already held a
Siftlode session posted nothing, the fragment was then stripped, and the user got
no signal at all -- the link simply did nothing. Incognito worked, which is why it
survived. Confirmation now happens in App, above that branch: signed out the outcome
still drives the auth card's banners, signed in it drives a top-of-app bar.

The entry URL is snapshotted once at import time (lib/entryUrl) instead of per
component, so no future URL writer can strip a token before whoever needs it has
read it -- that ordering race is what broke this in the first place. POST /auth/verify
now names the address it confirmed, which matters when the browser holds a session
for a different account; the same reasoning as password_reset_required already
covers the disclosure.

Also here, from the 2026-09-02 review tail: the dead GET /auth/verify goes (both
senders emit the fragment form, and the last query-token link expired 24h after the
0.78.7 deploy), a spent #verify=/#reset= fragment is cleared while signed in, and
the auth-card banners get role=status. The verifyOkReset copy no longer blames
admin approval, which E9.5 S1 made false.
2026-09-03 23:45:31 +02:00
peter 243869401a fix(auth): stop discarding a legitimate password on admin approval (E9.5 S1)
SEC-10 revoked any password set while an account's email was unverified, on both
the Google-adoption and the admin-approval path. On the approval path that was
redundant: password_login refuses an unverified account before it ever reads
is_active, so approval hands a pre-emptor's password nothing it could use. All the
revoke achieved there was destroying the password a legitimate registrant had just
chosen -- and since the admin normally decides before the user reads their mail,
'your password was cleared for security' was the DEFAULT experience, not an edge
case. The guard stays on the Google-adoption path, where the real owner does attest
ownership.

password_reset_required and the ok_reset banner stay: accounts stripped on prod
before this change have no other way back in.

Two existing tests pinned the old rule, so they are rewritten rather than deleted,
and the SEC-10 recovery pack now builds the revoked state through the surviving
producer.
2026-09-03 23:32:34 +02:00
peter 7339a0b84b release: 0.79.0 (improvement/e9-playlist)
gate / frontend (push) Failing after 1m43s
gate / backend (push) Successful in 2m48s
2026-09-03 21:59:31 +02:00
peter c960c07a6a release: 0.79.0 notes + version bump
E9 Playlist module: total playtime in both playlist headers, the header
compacted to two rows, and the sticky column header sitting flush. Plus the
six defects the epic-end deep review confirmed.
2026-09-03 21:59:09 +02:00
peter 3cfdb36f06 test(format): pin the sub-minute truncation (E9 R2)
Round 2 mutation-checked round 1's own fix and found the one line it left
uncovered: every table row was an integer, so `Math.floor(sec)` in the new
seconds tier could be deleted or swapped for round/ceil with the suite still
green — exactly the "later cleanup removes it as redundant" failure the fix's
comment claims to prevent. Two fractional rows kill all three mutants.
2026-09-03 21:51:14 +02:00
peter ada95d04a4 fix(playlists): one rendering for a playlist total, honest below a minute (E9 R1)
Epic-end deep review, round 1. Six findings survived verification; five of them
were one defect wearing different hats.

formatRuntime floored 1-59s to "0m". Harmless while it only ever formatted a
Plex movie's length, but 12D repurposed it for an aggregate, and a total is the
only input that can land under a minute: a playlist of Shorts reported "0m"
while the rail beside it read "0:45". Fixed in the function, not behind a
`>= 60` guard at each of the seven call sites — the invariant belongs where it
cannot be forgotten. Its contract comment claimed "Plex movie/episode lengths"
and was two epics stale.

The rail rendered the same playlist total as formatDuration (1:23:45) while the
new header rendered formatRuntime (1h 23m) — two shapes of one number, side by
side on one screen. The rail now uses formatRuntime too; formatTotalHours had
already recorded that H:MM:SS is wrong for an aggregate.

The rename input kept min-w-0 without flex-1 in the compacted row, so it was
the only shrinkable child beside a shrink-0 action cluster. The Plex half of
this same epic already had the fix; this restores the symmetry.

plex.browse.items had no plural forms, so a one-item Plex playlist read
"1 items". Pre-existing, but the new header is the call site where count=1 is
ordinary. Both languages get both forms, no key drift.

formatRuntime had no unit tests at all across seven call sites. Added a tier
table plus a sweep asserting no non-zero second ever renders as "0m".

Dropped after 3/3 adversarial refutations: the meta-line segment ordering
(the filtered count is labelled, and count-then-runtime is the convention) and
the null-duration lower bound (the server's own SUM has skipped NULLs since
long before this branch; changing only the client would split them).
2026-09-03 21:43:04 +02:00
peter 1b004e6ccf feat(plex): total playtime in the playlist header (E9 S3)
Parity with the YouTube playlist header (E9 12D): the Plex playlist detail
now carries a meta line under the title — "N items · Xh Ym", summed from the
items' duration_seconds. No backend change: leaf_card already serializes
duration_seconds for both movies and episodes, and no new i18n key either —
the count reuses plex.browse.items, the same key the collection header uses.

The title/rename input moved into a min-w-0 flex-1 column so the meta line
sits under both; the line is hidden on an empty playlist.
2026-09-02 18:30:06 +02:00
peter 95c33e0b0f refactor(playlists): compact the playlist header to two rows (E9 12A)
The header portals into the shell's FIXED toolbar band, so its height is
list height the user never gets back — and it was spending four stacked
blocks on it: title, meta line, an action row, then a separate view-control
row under the cover. Option B collapses that into two tight rows beside a
smaller cover: title + rename/YouTube-link on the first, with Play-all,
push/revert/delete and the unsynced badge pushed right; the meta line
(count · playtime · filtered) and the view controls (group-by, reset view,
save order, view status, undo/redo) sharing the second.

Every control and its gating is unchanged — the view-control group still
appears only for `items.length > 1 || viewActive`, so a one-item playlist
shows just the meta line.

Measured on a 32-item playlist: the band goes 170px -> 88px at rest
(122px while a view is active, where the status sentence wraps), which is
~80px more visible list.
2026-09-02 03:45:07 +02:00
peter 4e6bcbc078 fix(playlists): stop the table header shifting up on first scroll (E9 12B)
The playlist content wrapper carried `pt-3`, so its sticky column header sat
12px below the scroller top at rest and jumped up to `top:0` on the first
scroll. Drop the top padding so the header is flush from rest and never
shifts — the fixed toolbar band above supplies the breathing room, matching
the channel table's already-correct pattern.
2026-09-02 03:39:17 +02:00
peter e17fb5bbf5 feat(playlists): total playtime in header + sticky-header fade fix (E9 S1)
Surface the playlist's total playtime beside the item count in the detail
header (12D) — sums every item's duration (formatRuntime "h m", matching the
approved mockup), hidden when nothing sums. Data is already client-side; no
backend change.

Pass fadeTop:false for the playlists page (12B) so the shell's top edge-fade
no longer sits over the already-sticky column header (tableHeader.tsx:257),
matching the channels/audit pages.
2026-09-02 03:39:17 +02:00
peter a9044ece50 release: 0.78.8 (improvement/email-html-alternative)
gate / frontend (push) Failing after 1m40s
gate / backend (push) Successful in 2m40s
2026-09-02 03:24:59 +02:00
peter 473aafd1c8 release: bump VERSION to 0.78.8 + release notes 2026-09-02 03:24:56 +02:00
peter 7019e3afdf fix(email): give every mail an HTML alternative so its link is clickable
The bodies are text/plain only, so whether the URL in a verification or
password-reset mail is a link at all was the client's decision. Gmail, Outlook and
Apple Mail linkify plain text; lighter web clients render the body verbatim and leave
the link as dead text — in a message whose entire purpose is that one link.

_send now attaches an HTML part generated from the SAME plain body, rather than
per-template HTML that would have to be kept in sync with it: one converter, all
eleven templates, and text/plain stays the primary part and the source of truth.
Escaping runs before linkification — bodies interpolate untrusted values (a
requester's address, the operator contact) — so no caller can inject markup, and a
query string's `&` is already `&amp;` when it reaches the href, which is what an HTML
attribute wants. Sentence-final punctuation is trimmed out of the href while
url-safe token characters (`-`, `_`) are left alone. Inline styles only, no images,
no external CSS, no tracking.
2026-09-02 03:13:23 +02:00
peter c81b110637 release: 0.78.7 (bug/verify-link-and-approval-lockout)
gate / frontend (push) Failing after 1m44s
gate / backend (push) Successful in 2m42s
2026-09-02 02:31:19 +02:00
peter 2ecc70ca6d release: bump VERSION to 0.78.7 + release notes 2026-09-02 02:31:14 +02:00
peter f8c63e80c2 fix(auth): widen the 0078 backfill so revoke victims who verified are rescued too
Review found the backfill's fourth predicate `AND email_verified = false` strands
exactly the subset that needs it most. The revoke does not invalidate the account's
outstanding verification token, so clicking the emailed link is the natural next
thing such a user does — and it sets email_verified=True while password_hash stays
NULL. That row then has no route back at all: reset ineligible, resend refuses an
already-verified address, set-password needs a session the account cannot obtain,
and nothing re-stamps it because the revoke itself is guarded on being unverified.

The remaining three predicates already pin the victim set exactly: registration and
the setup wizard always set a hash, every Google row carries a google_sub (adoption
assigns it before revoking), and nothing in the codebase ever clears google_sub — so
a local, non-demo, passwordless row can only have come from the revoke. Widening is
therefore safe, and it is the argument the docstring was already making while the
SQL contradicted it.

Also corrects that docstring: set-password is unreachable because the account cannot
authenticate at all, not because the epoch bump killed a session — an unverified
account is refused at login before a session is ever minted, so it never had one.
2026-09-02 02:21:46 +02:00
peter 3ac3d382e7 fix(auth): stop the address-bar mirror eating emailed auth tokens, and unlock revoked accounts
Two independent bugs made an email+password registration unrecoverable in prod.

The verification link did nothing. urlWithParams/clearParams rebuilt the URL as
pathname+query and dropped the fragment — but the one-time auth tokens ride the
fragment (#verify=/#reset=, SB3), and the providers that mirror nav params
(useUrlMirror in FeedFilters/Playlists/Plex/Channels) sit ABOVE the lazily-loaded
<App> in main.tsx. Their mount effect replaceState'd the token away before the auth
card's chunk had loaded, so the card read an empty hash: the link opened a bare
sign-in page, no request, no banner. Both helpers now carry the fragment over.

The account was then locked out for good. SEC-10 nulls password_hash when an
unverified account is attested (here: admin approval landing before the user could
verify — which the bug above made impossible). That is indistinguishable from a
Google-only account, and reset eligibility keyed off password_hash alone, so no
reset link was ever issued: no password to sign in with, and no way to get one.
users.password_revoked_at records the revocation, reset eligibility honours it, and
setting a password again (reset-confirm or set-password) clears it. An account that
never had a password stays ineligible on purpose — its owner adds one from Settings.

/auth/verify now also answers password_reset_required so the card can say why the
registered password stopped working, instead of leaving the user at the uniform
"Invalid email or password". Telling the holder of a valid verify token leaks
nothing: they have already proved they own the mailbox.

The 0078 migration backfills the stamp for rows carrying the exact lockout signature
(local, non-demo, no password, unverified), so existing victims recover unaided.
2026-09-02 01:28:45 +02:00
peter 94c62ca699 release: 0.78.6 (feat/admin-invite-delete-email-health)
gate / frontend (push) Failing after 1m43s
gate / backend (push) Successful in 2m53s
2026-09-02 00:36:52 +02:00
peter 1fe24d374a docs(tests): correct the deny-vs-delete rationale in the invites test file
Round 4's only finding: the file header and one comment still said Deny stops a
refused person re-asking. It does not — upsert_pending_invite reopens a denied
row as pending — and the delete_invite docstring in the same branch already says
so. The real difference is that Deny records an auditable decision and mails the
requester; Delete forgets the address.
2026-09-02 00:15:21 +02:00
peter 2cba6b9fca docs: round-3 copy corrections, and pin the two behaviours nothing was guarding
Round 3's correctness/security pass came back clean; what was left said more than
the code does.

- emailOff promised the access-request list is where sign-ups show up. A sign-up
  on an address that is already approved or already pending adds nothing to it
  (roadmap UX-25), and "only" was falsified by this release's own inbox notice.
- deleteConfirm claimed the admin was dropping "an approval you already gave" —
  it is the confirmation for pending and denied rows too.
- sendFailed blamed the mail server for rejecting a message; the reason covers
  connect, DNS, TLS and login failures alike, so it now describes the symptom and
  leaves the diagnosis to the log.
- ADMIN_EMAILS re-promotion happens in the Google callback only, not on any
  sign-in.

Tests: the decided-list copy of the delete affordance was never rendered (both
new cases fail if that branch is dropped), and the throttle release — a
deliberate behaviour the notes advertise — was asserted by nothing; a drop with
no admin yet, or with a failing write, must not silence the following hour.
2026-09-02 00:06:01 +02:00
peter 8d7aaad9a8 fix: warn before the delete, not after — plus round-2 copy and a11y corrections
Round 2 on the feature diff (15 → 12):

- still_allowed_by_env now rides the invite LIST as well as the delete response,
  so the confirmation warns while the admin can still change their mind. It
  mattered most for a DENIED row on an env-allow-listed address: deleting it
  silently converted a refusal into a grant, and the only warning arrived after
  the irreversible click.
- Both inbox bodies claimed to name "the most recent" dropped message. The
  throttle records the FIRST of each hour and never updates it, so they now say
  "one of them", and the not-configured body finally names where to fix it.
- settings.invites.emailOff told the admin they "get no notice when someone
  signs up" — rendered directly above the queue that shows exactly that. Only
  the email is lost, and it now says so.
- The disabled delete buttons put their reason out of reach: `disabled` drops a
  control from the tab order and suppresses its title. Switched to
  aria-disabled + preventDefault, the pattern Playlists.tsx already established
  for the same reason.
- deleteInvite is quiet, so the 409 the UI handles with a translated toast no
  longer also raises the server's English detail as a modal.
- After a completed password reset, Back landed on the reset form again with a
  consumed token — the history change made that state reachable. The token is
  cleared on success so the reset view stops being the card's root.
- The hu deleteHasAccount named a tab that renders under a different label.

Tests: a dom test for the Access-requests panel covering both has_account
variants and all three delete outcomes, /api/me flag cases, an ADMIN_EMAILS
case, and the env-allowlist test now patches the raw setting so the parsing
stays under test.
2026-09-01 23:50:29 +02:00
peter 85043013f0 fix: address the feature review — accurate copy, reachable controls, real coverage
Round 1 on the feature diff found 15; the substantive ones:

- The delete control VANISHED on any invite whose address had registered, which
  is the ordinary sign-up shape (register writes the User and the pending invite
  together). The guard stays broad — that row is the account's approval gate, and
  orphaning it strands someone who can then neither sign in nor be approved — but
  the control is now a disabled button carrying the reason, on both lists, so an
  admin never sees a row silently missing a button its neighbours have.
- Deleting an entry is not always a revoke: is_allowed falls back to the env
  ALLOWED_EMAILS / ADMIN_EMAILS lists once no row exists, and an ADMIN_EMAILS
  address is force-promoted on its next Google sign-in. The route now reports
  still_allowed_by_env and the UI says so instead of claiming access was removed.
- "Deny keeps the row so they can't immediately re-ask" was simply false —
  upsert_pending_invite reopens a denied row. Corrected everywhere it was stated.
- The Configuration warning reads me.email_configured, which save() did not
  invalidate, so it kept warning about fields the admin had just filled in.
- The read-only variant was an aria-hidden span explained only by a Tooltip that
  the hints toggle can switch off; now a disabled button with title + aria-label.
- The delete error path threw away the 409's reason (the stale-list case) and
  left the stale row on screen.
- The throttle window was spent even when no notice was written, silencing the
  next hour on the path whose job is to stop silent drops.
- email_enabled() opened a second session and decrypted the SMTP password on
  every /api/me poll; it now takes the caller's session.
- welcomeMode.dom.test.ts never rendered the card, so reverting the history
  wiring to useState left it green. A render test now drives the real links and
  fails on that revert (verified).

Plus /api/me flag tests, the registration-shaped pending case, the env-allowlist
case, and a release note that matches what the code does.
2026-09-01 23:31:49 +02:00
peter 44f626f732 feat(admin): delete a whitelist entry, and stop hiding a broken mail setup
Three operator gaps, all found the same way — a person could not sign up and the
app showed nobody anything.

- DELETE /api/admin/invites/{id} with a delete control on both the pending
  requests and the previously-decided list. Deny keeps the row so the person
  cannot immediately re-ask; delete forgets the address. Refused with 409 while
  a User row exists for it, because is_allowed reads that row and removing it
  would revoke a live user's access with no other sign — the list ships
  has_account so the UI withholds the button instead of offering one that fails.
- Email is fail-soft by design, which had come to mean invisible. _send() now
  records an email_undelivered notification for every admin when a message is
  skipped or rejected, naming the message that was lost, throttled to one an
  hour. /api/me carries email_configured, driving a standing warning on
  Configuration → Email / SMTP and above the access-request list.
- The sign-in card's Create account / Forgot password / Didn't get the email
  views are history entries (useHistorySubview), so Back returns to the previous
  view instead of only moving the address bar.

Also: the per-test rate-limiter reset sweeps every loaded app.* module rather
than a hand-kept list of three — this change added a limiter outside it.
2026-09-01 23:09:25 +02:00
peter 24c93cbb83 docs: name the Configuration tab as it is rendered, drop an unused fixture
Round 6's two remaining nits. The operator note said "Configuration → Email"
but the group renders as "Email / SMTP" in both locales, and
test_an_admin_can_activate_a_pre_approved_registrant requested make_user without
using it — a leftover from the reverted rounds that made the test look like it
seeded an admin when it did not.
2026-09-01 22:15:56 +02:00
peter cb1f4f00b0 docs(auth): correct the last round of over-claiming comments and notes
Round 5's correctness/security pass came back clean; what was left was text that
said more than the code does.

- The register-path notify comment (and its test) claimed the admin "is still
  told". The call fires, but on a no-SMTP instance _send drops it — which is the
  open half of the problem, not something this branch fixed. Both now say so.
- The release notes tell admins what to actually do: an account whose address was
  whitelisted in advance has no pending request to approve, so the whitelist box
  is what activates it. That instruction only existed in the roadmap and in
  chores, which the dialog does not render.
- "That one line is the whole production change" now accounts for the test-only
  RateLimiter.reset() that also ships.
- The rate-limiter test claimed to pin that blocked attempts are not counted, on
  an assertion that held either way. Replaced with one that drives the clock
  across the window boundary and fails if hammering extends it.
2026-09-01 22:02:43 +02:00
peter f6e1d1e69f fix(auth): keep the admin notice unconditional on the register path
Round 4 showed the `inv is not None` gate added in b5a1002 removed a signal that
matters: for an address the admin had already approved, that email is their only
notice that someone has now created an account on it — the invite is not pending
so nothing lands in the Access-requests queue either. Suppressing it left the
registrant at "awaiting admin approval" with the admin unaware they existed. The
duplicate it avoided (Request access, then sign up) is mild noise by comparison.

The production change is now one db.commit(), which is all the reported bug ever
needed. Release notes corrected again: a previously-denied address was also
unaffected, the pending-request case never lost its queue entry (only the
account behind it), and the test inventory was missing a guard.
2026-09-01 21:48:40 +02:00
peter b64c3f514d fix(auth): drop the invite reopen — keep the fix to the lost account alone
Round 3 of the review killed the reopen from b5a1002: flipping an approved
invite back to pending let an unauthenticated POST /auth/register REVOKE a
standing approval for any whitelisted-but-unclaimed address, which then fails
is_allowed() and locks the real owner out of Google sign-in — the primary way
people get in here. It also re-armed the pre-emption window it was meant to
close, because with no SMTP email_verified is set True and the SEC-10 guard the
design leaned on never fires.

Two attempts at fixing the stranded-account UX in the same change both came back
worse than the wart. What remains is the narrow, real bug: the account was
silently rolled back and is now committed. An admin still binds the approval to
the account, exactly as before this branch. UX-25 in the roadmap records the
follow-up and both dead ends so they are not retried.

Also from round 3: the upsert docstring no longer claims the admin UI cannot
reach these accounts (re-entering the address in the same panel's whitelist box
does, and a test now pins it), the release notes are scoped to what the diff
actually changes with the operator note moved where the dialog renders it, and
RateLimiter has unit tests so reset() is not production code only a fixture
calls.
2026-09-01 21:34:38 +02:00
peter b5a1002412 fix(auth): reopen a pre-dated approval instead of auto-granting it
Replaces the activation added in ea931dc, which round 2 of the review showed was
a real weakening: on a no-SMTP instance it made a standing whitelist entry a
bearer grant — the first anonymous party to register an approved-but-unclaimed
address got an immediately usable account under that identity, with no
attestation, no admin action and no signal anywhere. The verify-time activation
had the same shape one step removed, and neither site ran the SEC-10 preset-
password guard or left an audit trail.

Keep a human in the loop instead. An approval granted before any account existed
was a decision about the ADDRESS, so registration reopens the invite
(upsert_pending_invite gains revive_approved, used only by the register worker).
The request lands in the Access-requests queue, approving it runs
_activate_user_by_email — and with it revoke_preset_password_if_unverified — and
the account activates. One mechanism, already audited, no new grant path, and
the "awaiting admin approval" copy is honest again.

The explicit commit from the previous commit still matters: with an invite
already pending, upsert returns without committing and _issue_token is gated on
SMTP, so a no-SMTP registration was still being rolled back.

Also from round 2: gate the admin notice on a fresh decision (matching the two
other upsert callers) so a retry no longer re-pings every admin, and reset the
module-level rate limiters between tests — the suite pooled every login into one
300s window and was two logins short of failing an unrelated test with a 429.
2026-09-01 21:18:14 +02:00
peter ea931dca30 fix(auth): honour an approval granted before sign-up instead of parking the account
Follow-up to the commit fix, from the deep-review. Persisting the account was
only half the story: when the admin had approved the address BEFORE the person
registered, the account landed is_active=False against an invite that is no
longer pending — absent from the Access-requests queue, and approve_invite 409s
on an already-decided invite. The sign-up worked and still could not sign in.

Derive activation from is_allowed() at the moment ownership is attested:
- verify_email_confirm activates a pre-approved account when the emailed link is
  confirmed (only the mailbox owner can follow it), so SEC-10 stays intact;
- _register_account activates only when there is no SMTP to verify with, where
  the admin whitelist is by design the sole gate — the same one add_invite
  already activates on.

Tests: the pre-approved no-SMTP case and the verify-activates case are real
regression tests (both fail without this change); guards cover the unknown-email
and denied->pending branches, verification of an unapproved account, and the
first end-to-end coverage of the /auth/register route and its
allow_registration gate. Release notes rewritten — the previous entry miscounted
its regression tests and advertised an email fix this release has no code for.
2026-09-01 20:56:43 +02:00
peter d954e71a77 fix(auth): commit the account on registration so a pre-approved sign-up isn't lost
_register_account() flushed the new User but relied on someone else to commit:
upsert_pending_invite() commits only when it actually writes an invite row, and
_issue_token() runs only when SMTP is enabled. For an email an admin had ALREADY
approved, on an instance where email is unavailable, neither fired — the session
closed and the account was rolled back. The would-be user got the uniform "ok"
and no account, could not sign in, and re-registering was a silent no-op.

Commit explicitly after the invite upsert (a no-op when upsert already committed
the same transaction). Three DB-lane regression tests cover the pre-approved
no-SMTP path (fails without the fix), the unknown-email path that always worked,
and the pre-approved path with SMTP on.
2026-09-01 16:25:05 +02:00
peter 602f62967a release: 0.78.4 (bug/plex-transcode-hls-startposition)
gate / frontend (push) Failing after 1m55s
gate / backend (push) Successful in 2m57s
2026-08-30 20:33:15 +02:00
peter ba00b50dea release: 0.78.4 — fix Plex transcode playback stall/rebuffer
hls.js startPosition:0 so the transcode/remux HLS session starts VOD-style
at the requested position instead of chasing the live edge of the growing
EVENT playlist.
2026-08-30 20:32:56 +02:00
peter 92adb593c8 fix(plex): start hls.js at position 0 for the transcode/remux HLS session
Our HLS is a growing EVENT playlist with no ENDLIST, which hls.js reads as
LIVE. Its default startPosition (-1) then starts at the live edge and hugs it
(no forward buffer → rebuffer spinners), and on a resume/seek session the
segments' absolute PTS (-copyts, offset by the server's -ss) don't line up
with the live-edge timeline → 0 fragments load → permanent stall. The
seek-restart model makes fragment 0 the requested content position, so start
VOD-style at 0 and buffer forward. Fixes the transcode playback stall/rebuffer
found in 0.78.2 UAT.
2026-08-30 20:09:00 +02:00
peter 66132b6dda release: 0.78.3 (feature/plex-playback-class-filter)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m35s
2026-08-30 19:09:22 +02:00
peter d09f021f0c release: 0.78.3 — filter the Plex feed by playback class 2026-08-30 19:09:08 +02:00
peter d7e9240e54 merge main (0.78.2) into playback-class before release
# Conflicts:
#	backend/app/plex/stream.py
2026-08-30 19:08:08 +02:00
peter f2fa49b778 release: 0.78.2 (feature/plex-hdr-hw-tonemap)
gate / frontend (push) Failing after 1m37s
gate / backend (push) Successful in 2m24s
2026-08-30 18:56:32 +02:00
peter d4ed45b2eb release: 0.78.2 — HW HDR→SDR tonemap for 10-bit/HDR Plex transcode 2026-08-30 18:56:18 +02:00
peter 375f6e58be merge main (0.78.1) into tonemap before release 2026-08-30 18:55:14 +02:00
peter 1a102ce81a release: 0.78.1 (bug/download-mediakind-probe)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m22s
2026-08-30 18:47:05 +02:00
peter bfd45a155e release: 0.78.1 — download media_kind from a file probe (BUG 2) 2026-08-30 18:46:33 +02:00
peter 1102ad6a28 fix(downloads): make the probe verdict authoritative over yt-dlp info by probe-ran, not per-field
Deep-review round 1 on bug/download-mediakind-probe found a real HIGH the prior
review missed:

- resolve_media_facts used a per-field `probe or info` fallback, so a probe that
  RAN and deliberately found no video track (cover-art-only audio: attached_pic
  skipped; a still image: format-guarded) had its None video overridden by
  yt-dlp's stale vcodec. An audio-mode download from a progressive source (info
  carries the source's video codec, embed-thumbnail on) was then classified VIDEO
  -> a Play button + a broken in-app <video> with no video track. Only the
  download path regressed (upload calls probe_media directly).

  Fix: gate precedence on whether ffprobe RAN, not per-field. If the probe read
  the file, its codec verdict wins outright (a None means 'no such track'); only a
  full ffprobe failure falls back to info. Dimensions still gap-fill from info.
  Refactored the parsing into _facts_from_probe so resolve_media_facts can see the
  raw probe-ran signal; probe_media keeps its 5-tuple (upload + TestProbeClassifier
  unchanged).

- tests: the cover-art-audio and still-image resurrection cases, a differing
  populated-info precedence case (the prior-flagged gap), and the ffprobe-failed
  info fallback — now stubbing run_ffprobe_json so the real parsing + precedence
  run together.

Backend gate green (ruff + full pytest).
2026-08-30 18:09:26 +02:00
peter c1a3b7b935 fix(plex): gate the part-key re-probe to single-version items; drop dead i18n key; cover cross-facet narrowing
Deep-review round 2 on the round-1 fixes (all low):

- part_key churn (state): round 1's part-key reset re-probed EVERY sync for a
  multi-version item (an original + a Plex 'Optimized' copy), because
  _media_facts reads media[0]/part[0] whose order Plex doesn't guarantee stable,
  oscillating transcode_hw between the two versions. Gate the part-key signal on
  a single-version item (len(Media)<=1); codec-change reset still applies to all.
  A multi-version same-codec re-encode not self-healing is an acceptable rare
  edge; a per-sync re-probe loop is not.

- dead i18n key: this diff's playableClass() collapses to transcode_hw/sw, so the
  bare plex.playable.transcode key it orphaned is now unused (knip can't see
  dynamic keys). Dropped from both locales, parity kept.

- test: cross-facet narrowing by an active playable class (a co-facet's year
  range collapses to the selected subset) — the flip side of self-exclusion,
  previously unpinned.

The aria-label+title double-announcement on the card dot is a deliberate accepted
trade-off (reliable SR name + mouse tooltip). Backend + FE gates green.
2026-08-30 17:58:38 +02:00
peter bf98b48517 fix(plex): re-probe on file replace, HU parity, public class const, playback-class test gaps
Deep-review round 1 on feature/plex-playback-class-filter:

- Sticky watermark (medium): _apply_item reset the bit-depth probe watermark
  only on a codec-string change, so a same-codec re-encode (8-bit->10-bit HEVC)
  kept a stale video_bit_depth/transcode_hw forever (the backfill only re-probes
  media_probed_at IS NULL rows). Also reset when the Plex Part key changes (the
  file was replaced), not just the codec.

- i18n parity (medium): the HU playback-class labels were mixed-language (only
  'direct' translated). Translated remux/transcode_hw/transcode_sw and the
  tooltips consistently to Hungarian (Kozvetlen streameles / Atkodolas).

- Layering (low): _PLAYBACK_CLASSES was module-private yet imported across into
  routes.plex.browse; renamed to public PLAYBACK_CLASSES (it is the shared
  source of truth for facet order + zero-fill).

- a11y (low): the browse-card playback-class dot conveyed meaning only via a
  mouse-hover title; added role=img + aria-label so SR/touch users get the class.

- tests: the NULL/unprobed transcode_hw -> SW fold (filter + facet), facet
  self-exclusion under an active playable filter, the migration codec-set ==
  HW_DECODABLE_CODECS drift guard, the probe-batch bound + oldest-first order,
  the transient probe-failure retry path, and the same-codec re-encode re-probe.

Backend gate green (ruff + full pytest); FE gate green (tsc/lint/knip/prettier/
vitest). NOTE: the facet-vs-runtime disagreement for 10-bit HEVC (C1) is the
known merge-order constraint — this branch's runtime is codec-only; the facet
becomes truthful once BUG1/tonemap's bit-depth-aware routing lands with it. Left
as a sequencing constraint, not code-changed here.
2026-08-30 17:51:33 +02:00
peter b563155a37 docs(plex): correct the cull-yield fallback comment to name the real route 404s
Deep-review round 3: the only surviving finding — a comment-drift nit. The
fallback's cull-yield comment named the route's 'Not available yet' path, but a
culled-key segment request actually returns 'No active playback session' (the
substantive invariant — never a None that 404s as file-not-found — is unchanged).
Reworded to match the route. Concurrency + correctness came back clean.
2026-08-30 15:18:26 +02:00
peter 915319fa35 fix(plex): make the HW->SW fallback supersede-check atomic; honest cull yield
Deep-review round 2 on the round-1 fix:

- Race (medium): the round-1 identity guard did the supersede check and the
  respawn in TWO separate _lock sections. A newer same-key session installed in
  the gap was still torn down by _spawn's unconditional pop+kill (the round-1
  comment even had the direction backwards). Fold the check into _spawn via a
  new replace_only= param: the identity test and the pop+replace now share ONE
  lock, so the respawn is a true no-op when superseded/culled.

- Honest states (low): the superseded branch returned current_session(key),
  which is None when the key was cap-culled during our out-of-lock wait; the
  route turns None into a dishonest 'Media file not found on disk' 404 for a
  file that exists. Yield 'current_session(key) or s' so a culled session
  degrades to the route's retryable 'not available yet' path instead.

- Hygiene: _want_hw docstring corrected (it is a test-only shim now; production
  decides via _select_encode) and the two stale _want_hw references refreshed
  (stream.py header, config.py plex_transcode_hw).

- tests: the cap-cull no-respawn/no-None sub-case; the _opencl_present except
  OSError branch (glob raising, not just empty); tightened a tautological -vf
  assertion. Backend gate green (ruff + full pytest).
2026-08-30 15:11:37 +02:00
peter a2a8f5681d fix(plex): don't tonemap 10-bit SDR; identity-guard the HW→SW fallback
Deep-review round 1 on feature/plex-hdr-hw-tonemap:

- F1 (correctness): _select_encode gated tonemap on bit-depth alone, so a
  10-bit *SDR* (bt709) source got the HDR hable tone curve applied, visibly
  altering an SDR picture where only a 10->8-bit downconvert was needed. Split
  the ffprobe gate into a 3-way classifier (_probe_video_kind: hwsafe / hdr /
  sdr10); only true-HDR (PQ/HLG) takes the OpenCL tonemap graph, 10-bit SDR
  drops to software libx264 (an exact downconvert). Applies to both auto and on.

- F2 (race): the runtime HW->SW fallback popped+killed _sessions[key] with no
  identity check. Because wait_for blocks OUTSIDE _lock, a concurrent same-key
  _spawn (rapid seek-restart) or _enforce_cap cull could kill this session; the
  fallback then respawned software over the NEWER session and breached the
  transcode cap. Guard the respawn: only fall back if _sessions[key] is still
  this session, else yield the currently-registered one.

- tests: 3-way classifier (incl. HLG + empty-probe), real _opencl_present glob,
  real wait_for proc fast-fail branch, tonemap-variant fallback, the F1
  regressions (auto + on), the supersede/no-respawn race, and the on/opencl
  matrix cell. Backend gate green (ruff + full pytest).
2026-08-30 15:02:41 +02:00
peter 4827aebb7f feat(plex): HW HDR→SDR tonemap for 10-bit/HDR transcode (OpenCL on the iGPU)
Upgrade mode-3's 10-bit/HDR branch from "route to software libx264" (BUG 1's
behaviour) to a full-GPU pipeline: VAAPI decode → OpenCL tonemap (hable,
HDR10/HLG → bt709 SDR nv12) → h264_vaapi. Gen 9.5 (UHD 630) can't tonemap in
the VAAPI VPP (tonemap_vaapi fails), so OpenCL is the only HW route — proven on
the prod iGPU at ~4.2x realtime, ~1 CPU core.

- stream.py: `_select_encode` returns (hw, tonemap); a 10-bit/HDR HW-decodable
  source picks the tonemap graph when the OpenCL runtime is present, else drops
  to software. `_opencl_present` gates on a registered ICD. `_venc_opts` /
  `_hwaccel_in_opts` gain the tonemap variant (named vaapi+opencl devices,
  `-filter_hw_device ocl`, hwmap→tonemap_opencl→hwmap reverse). `_want_hw` kept
  as the hw-half wrapper. The runtime HW→SW fallback still backstops a session
  that emits no segment.
- Dockerfile: add the Intel NEO OpenCL runtime (intel-opencl-icd). It was removed
  from Debian trixie, so it's pulled from bookworm (22.43 still supports Gen 9.5;
  the .deb runs on the trixie base — verified live). Harmless without /dev/dri.
- route + FE: thread `tonemap` through the session response; the stats overlay
  mode row reads "transcode · GPU · HDR→SDR" for the tonemap path.
- tests: tonemap command branch, the (hw, tonemap) selection matrix (auto/on ×
  8-bit/10-bit × opencl), start_session tonemap wiring, and the fmtMode tag.
2026-08-30 06:02:45 +02:00
peter 840526b1b8 feat(plex): filter the feed by playback class (Direct Play / Direct Stream / Transcode HW/SW)
Adds a movie-scoped "Playback" filter to the Plex feed so a user can isolate items by how
they play — the groundwork for collecting HW-transcodable (or no-transcode) versions.

Four classes derived from the stored `playable` + a new `transcode_hw` facet:
- Direct Play (direct), Direct Stream (remux)
- Transcode · HW  — playable=transcode AND the iGPU can hardware-transcode it
- Transcode · SW  — playable=transcode, software libx264 only

The HW/SW split is precise, not codec-only: a 10-bit/HDR HEVC is HW-decodable yet breaks the
VAAPI nv12 pipeline (the same case plex/stream special-cases), so it must count as SW.

Backend:
- New PlexItem columns (migration 0077): transcode_hw, video_bit_depth, media_probed_at.
  Migration backfills transcode_hw codec-only so the filter works immediately.
- `plex.sync.transcode_hw_capable` is the single classifier; the HW-decodable codec set moves
  to sync as `HW_DECODABLE_CODECS` and plex.stream imports it, so the catalog facet and the
  runtime encoder choice can't disagree by codec.
- Bit-depth backfill (`_probe_media_batch`, wired into sync): probes only HW-decodable transcode
  items' video Stream for bit-depth + HDR and refines a 10-bit/HDR HEVC to software. Bounded
  per run, watermarked by media_probed_at. A codec change resets the watermark (stale probe).
- Filter + self-excluded facet on a shared `playback_class_expr` CASE (movie-leg only — a show
  is an aggregate with no single class). transcode_hw threaded onto the feed/detail/card JSON.

Frontend:
- Multi-select "Playback" Combobox in the sidebar (movie scope, facet-empty drop-out), active
  chips, 4-way corner-dot badge (green/blue/amber/red) + tooltip, and an info-page playback chip
  with a codec · bit-depth subtitle. New `bg-info` (blue) token, i18n HU/EN parity.
- Fix a regression where the card's playback dot lost its hover tooltip: the full-card reveal
  overlay swallowed the cursor; the dot now sits above it (z-20).

Gate: backend full lane + frontend typecheck/lint/knip/prettier/vitest all green. Browser-UAT'd
on localdev (filter narrows to the SW set, facet counts, badge colour + tooltip, info chip).
2026-08-30 04:45:11 +02:00
peter 10d69cb316 fix(plex): route 10-bit/HDR HEVC to software + runtime VAAPI→libx264 fallback
A 10-bit/HDR HEVC source (e.g. an HDR10 x265 file) died at scale_vaapi=format=nv12
on the UHD 630 (Gen 9.5 VPP: "VAProfile not supported"); ffmpeg produced 0 segments
and the item never played, with no fallback — a retry just re-picked the failing HW
path. An 8-bit HEVC and the same file on libx264 both work.

Two defenses:
- `_want_hw` auto mode now also gates on an 8-bit-SDR source via a new
  `_probe_video_hwsafe` ffprobe (bit depth from pix_fmt/bits_per_raw_sample, HDR from
  color_transfer) — a 10-bit/HDR source takes the software libx264 path, which
  downconverts to yuv420p cleanly. The Plex mirror stores no bit-depth signal, so this
  is probed at session start. "on" still trusts the admin and skips the gate.
- `start_session` adds a runtime HW→SW fallback: a HW session that emits no first
  segment respawns on libx264 and reports hw=False (the route/stats overlay honestly
  show CPU). `wait_for` gained an optional `proc` so a dead ffmpeg fails fast instead
  of burning the 25s budget before the fallback fires.

Tests: TestProbeVideoHwsafe (parsing + probe-failure-assumes-safe), the 10-bit auto
gate + "on" skip in TestWantHw, and the no-segment HW→SW respawn in
TestTranscodeStartSession. Backend lane green.
2026-08-30 03:53:31 +02:00
peter a86131e062 fix(downloads): classify media_kind from a file probe, not yt-dlp info
A downloaded video was silently demoted to a download-only "file" (no
in-app play/edit) whenever yt-dlp's info dict omitted vcodec/acodec — which
it does for Facebook and other sources. Finalize derived media_kind from
those nulls (`derive(None, None)` -> file), so a genuine H.264/AAC MP4
became non-playable in the library.

Fix: probe the finished file on disk (the ground truth) at finalize and
derive from that, falling back to the info dict only for a field the probe
couldn't read. This is the same ffprobe the upload ingest already used, now
extracted to a shared downloads/probe.py so the download worker and the
upload path share one classifier (and can't drift). Also backfills the
stored vcodec/acodec/width/height from the real file.

Tests: TestResolveMediaFacts pins probe-wins-over-info (incl. the Facebook
no-codec case and yt-dlp's literal "none"); upload's probe tests repoint to
the shared module.
2026-08-30 03:32:14 +02:00
114 changed files with 9651 additions and 754 deletions
+13
View File
@@ -60,6 +60,19 @@ RUN apt-get update \
&& rm /tmp/deno.zip \
&& rm -rf /var/lib/apt/lists/*
# Intel NEO OpenCL runtime (intel-opencl-icd) — needed by the Plex transcode's HDR/10-bit HW path, which
# tone-maps HDR→SDR on the GPU via `tonemap_opencl` (Gen 9.5 / UHD 630 can't tonemap in the VAAPI VPP, so
# OpenCL is the only HW route; without this the 10-bit branch drops to CPU libx264). NEO was REMOVED from
# Debian trixie (this base = python:3.12-slim = trixie), so it's pulled from **bookworm** — the 22.43 there
# still supports Gen 9.5 and its .deb runs on trixie (verified live on the prod UHD 630). The bookworm apt
# source is added only for this one install and removed after, so it can't drift the rest of the image.
# Harmless on hosts with no /dev/dri or no OpenCL use (the runtime just sits unused).
RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.pgp\n' > /etc/apt/sources.list.d/bookworm.sources \
&& apt-get update \
&& apt-get install -y --no-install-recommends -t bookworm intel-opencl-icd \
&& rm /etc/apt/sources.list.d/bookworm.sources \
&& rm -rf /var/lib/apt/lists/*
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
+1 -1
View File
@@ -1 +1 @@
0.78.0
0.80.0
@@ -0,0 +1,54 @@
"""plex_items playback-class facet (E7: filter the feed by Direct Play / Direct Stream / Transcode)
Adds the columns the playback-class filter needs:
* transcode_hw — for a `playable="transcode"` item, whether the prod iGPU can hardware-transcode it
(codec HW-decodable AND 8-bit SDR) vs software-only. NULL for non-transcode items.
* video_bit_depth — the source video bit depth (from the Plex media Stream), NULL until probed.
* media_probed_at — watermark for the bit-depth backfill (NULL = not yet probed).
Backfill for existing rows: `transcode_hw` is derived from the ALREADY-stored `codec_video` alone (the
same HW-decodable allow-list `plex.sync.HW_DECODABLE_CODECS` / `plex.stream` use), so the filter works
immediately after migration. Bit depth is unknown for existing rows (the cheap Plex listing doesn't
carry it), so a 10-bit/HDR HEVC is provisionally 'hw' here and later refined to 'sw' by the bit-depth
backfill (`plex.sync._probe_media_batch`) once its Stream is probed. `video_bit_depth`/`media_probed_at`
start NULL.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0077_plex_playback_class"
down_revision: Union[str, None] = "0076_media_asset_kind"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("plex_items", sa.Column("transcode_hw", sa.Boolean(), nullable=True))
op.add_column("plex_items", sa.Column("video_bit_depth", sa.Integer(), nullable=True))
op.add_column(
"plex_items", sa.Column("media_probed_at", sa.DateTime(timezone=True), nullable=True)
)
op.create_index("ix_plex_items_transcode_hw", "plex_items", ["transcode_hw"])
# Codec-only derivation for existing rows — keep this codec set identical to
# plex.sync.HW_DECODABLE_CODECS. Bit depth is unknown here, so provisionally 8-bit (→ hw for a
# decodable codec); the bit-depth backfill downgrades 10-bit/HDR HEVC to sw later.
op.execute(
"""
UPDATE plex_items
SET transcode_hw = CASE
WHEN playable = 'transcode'
THEN lower(codec_video) IN ('h264','hevc','h265','vp9','vc1','mpeg2video')
ELSE NULL
END
"""
)
def downgrade() -> None:
op.drop_index("ix_plex_items_transcode_hw", table_name="plex_items")
op.drop_column("plex_items", "media_probed_at")
op.drop_column("plex_items", "video_bit_depth")
op.drop_column("plex_items", "transcode_hw")
@@ -0,0 +1,58 @@
"""users.password_revoked_at — remember that SEC-10 dropped a preset password
Revision ID: 0078_password_revoked_at
Revises: 0077_plex_playback_class
Create Date: 2026-09-02
SEC-10 (`auth.revoke_preset_password_if_unverified`) nulls `password_hash` when an account whose email
was never verified is attested (Google adoption, or admin approval). That left NO trace, and
password-reset eligibility keyed off `password_hash IS NOT NULL` — so such an account could neither
sign in (no password) nor request a reset link (not "eligible"): a permanent lockout with a silent,
uniform response at every step.
This column is that trace. A row with `password_revoked_at` set is a local account that HAD a password
and had it revoked, so the reset flow treats it as eligible again. Setting a password (reset-confirm or
/auth/set-password) clears the stamp. A Google-only account that never had a password keeps it NULL and
stays reset-ineligible by design — its owner adds a password from Settings while signed in.
Backfill: the signature is exact and narrow — a local (google_sub IS NULL), non-demo account with NO
password could ONLY have got there through the revoke. Registration always sets a hash, the setup
wizard sets one too, every Google row carries a google_sub (the adoption path assigns it BEFORE
revoking), and nothing in the codebase ever clears google_sub. Stamp those with the current time so
they become recoverable without operator intervention.
Deliberately NOT also requiring `email_verified = false`, even though the revoke only fires on an
unverified account: verifying is exactly what such a victim does NEXT. The revoke does not invalidate
the outstanding verification token, so clicking the emailed link sets email_verified=True while
password_hash stays NULL — and that row has no route back at all (reset ineligible, resend refuses an
already-verified address, and set-password needs a signed-in session the account can no longer obtain:
no password to sign in with, and no Google identity). Those are the victims who most need the stamp,
so the predicate must not exclude them.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0078_password_revoked_at"
down_revision: Union[str, None] = "0077_plex_playback_class"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("password_revoked_at", sa.DateTime(timezone=True), nullable=True),
)
op.execute(
"UPDATE users SET password_revoked_at = now() "
"WHERE password_hash IS NULL "
"AND google_sub IS NULL "
"AND is_demo = false"
)
def downgrade() -> None:
op.drop_column("users", "password_revoked_at")
@@ -0,0 +1,65 @@
"""email_canonical on users + invites, for matching one mailbox across Gmail spellings
Revision ID: 0079_email_canonical
Revises: 0078_password_revoked_at
Create Date: 2026-09-04
Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE mailbox; the invite gate
only lowercased. On 2026-09-03 that turned an approved user's Google sign-in into a silent second
access request (OAuth returns the account's stored primary, which need not be the spelling they typed)
and left prod with two user rows for one mailbox.
The backfill below reproduces `app.utils.canonical_email` in SQL. That duplication is deliberate — a
migration must not import application code that will keep changing — but it means the two have to
agree; `test_canonical_email_backfill_matches_the_helper` pins them together.
Deliberately NOT unique. Prod already carries canonical duplicates: two approved invites for one
mailbox dating back to 2026-06-16, and two active user rows from the 2026-09-03 walkthrough. Merging
accounts is a judgement call about people's data, not something a migration gets to do on its own, so
this migration only records the canonical form and leaves the duplicates in place for a human.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0079_email_canonical"
down_revision: str | None = "0078_password_revoked_at"
branch_labels: str | None = None
depends_on: str | None = None
# Mirrors app.utils.canonical_email: Gmail (and its googlemail alias) loses dots and any `+tag` from
# the local part and lands on gmail.com; every other domain is only lowercased, because the dot rule
# is not guaranteed elsewhere — and does not hold for Workspace custom domains unless their admin
# enabled it.
_CANON_SQL = """
CASE
WHEN split_part(lower({col}), '@', 2) IN ('gmail.com', 'googlemail.com')
-- an empty local part after stripping falls through to plain lowercase, as the helper does
AND replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') <> ''
THEN replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') || '@gmail.com'
ELSE lower({col})
END
"""
def _backfill(table: str) -> None:
op.execute(
f"UPDATE {table} SET email_canonical = {_CANON_SQL.format(col='email')} "
f"WHERE email IS NOT NULL"
)
def upgrade() -> None:
for table in ("users", "invites"):
op.add_column(
table,
sa.Column("email_canonical", sa.String(length=320), nullable=False, server_default=""),
)
_backfill(table)
op.create_index(f"ix_{table}_email_canonical", table, ["email_canonical"])
def downgrade() -> None:
for table in ("users", "invites"):
op.drop_index(f"ix_{table}_email_canonical", table_name=table)
op.drop_column(table, "email_canonical")
@@ -0,0 +1,32 @@
"""users.google_email — the address Google asserts, kept beside the one the user registered with
Revision ID: 0080_google_email
Revises: 0079_email_canonical
Create Date: 2026-09-04
Since 0079's follow-up a Google sign-in no longer overwrites `users.email`, so a deliberate
`me+siftlode@gmail.com` survives being linked to a Google account whose primary is `me@gmail.com`.
That leaves two true addresses for one account and only one of them stored. This column holds the
other, so the account menu and the Settings sign-in row can show which address Google actually
answers with.
No backfill is possible: Google's answer is only known at sign-in. Accounts linked before this
column existed carry NULL until their owner next signs in with Google — and for the majority the two
addresses are identical anyway, where the UI shows nothing either way.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0080_google_email"
down_revision: str | None = "0079_email_canonical"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("google_email", sa.String(length=320), nullable=True))
def downgrade() -> None:
op.drop_column("users", "google_email")
@@ -0,0 +1,34 @@
"""invites.invited_at — when the admin actually told this person the door was open
Revision ID: 0081_invite_invited_at
Revises: 0080_google_email
Create Date: 2026-09-05
Whitelisting an address stays SILENT by design: the operator is opening a door as a possibility,
usually after agreeing it with the person, and a row can sit approved for weeks without anyone
registering. Telling them is therefore a separate, deliberate act — a send button on the row.
Which raises the one question the admin cannot otherwise answer: "did I already write to them?"
Without a record the only failure mode is writing to a real person twice, which is exactly the
nudging the silent-by-default design exists to avoid. The audit log holds the event, but not where
the decision is made.
NULL means "never invited from here", which is true of every existing row: no backfill is possible
or wanted, since the historic approval mails answered a REQUEST rather than inviting anyone.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0081_invite_invited_at"
down_revision: str | None = "0080_google_email"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("invites", sa.Column("invited_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("invites", "invited_at")
@@ -0,0 +1,34 @@
"""invites.lang — the language this person asked us in
Revision ID: 0082_invite_lang
Revises: 0081_invite_invited_at
Create Date: 2026-09-05
The mails an access request produces — approved, denied, invited — go to an address that often has
no account yet, so there is no `preferences.language` to read. The signal does exist, though, and
only at the moment of the request: the language of the page the form was submitted from, or the
locale Google reported for a sign-in we refused. Recording it there is what lets the decision mail
answer in the requester's own language instead of an admin's guess about a stranger.
Captured rather than derived, for the third time in this epic: `preferences.language` (S6) and
`google_email` (S3) both taught that a signal which exists only in one moment cannot be backfilled
later. So no backfill here either — NULL means "we were never told", which is the honest state for
every historic row, and for every address an admin whitelists by hand. Those fall back to English
unless the admin picks otherwise on the invitation send.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0082_invite_lang"
down_revision: str | None = "0081_invite_invited_at"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("invites", sa.Column("lang", sa.String(length=8), nullable=True))
def downgrade() -> None:
op.drop_column("invites", "lang")
+8
View File
@@ -27,9 +27,17 @@ class AuditAction:
USER_SUSPEND = "user_suspend"
USER_UNSUSPEND = "user_unsuspend"
USER_DELETE = "user_delete"
# E9.5 S5: an admin let a stuck registrant in, or re-sent their confirmation link. Both are
# recorded because both are the admin acting ON someone's access, not merely reading a list.
USER_ACTIVATE = "user_activate"
USER_VERIFY_RESEND = "user_verify_resend"
INVITE_APPROVE = "invite_approve"
INVITE_DENY = "invite_deny"
INVITE_ADD = "invite_add"
INVITE_DELETE = "invite_delete"
# E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is
# silent, so this is the act that actually reaches a person — and the one worth a record.
INVITE_SEND = "invite_send"
DEMO_ADD = "demo_add"
DEMO_REMOVE = "demo_remove"
DEMO_RESET = "demo_reset"
+135 -8
View File
@@ -7,11 +7,13 @@ from sqlalchemy.orm import Session
from starlette.requests import HTTPConnection
from app import email as email_mod
from app import mailtext
from app import sysconfig
from app.config import settings
from app.db import get_db
from app.models import Invite, User
from app.security import decrypt_optional
from app.utils import canonical_email
PASSWORD_MIN_LEN = 10
@@ -63,16 +65,126 @@ def has_write_scope(user: User) -> bool:
return WRITE_SCOPE in tok.scopes.split()
def env_allowed_canonicals() -> set[str]:
"""The env bootstrap allow-list, in CANONICAL form — the only shape it may be compared in.
S3 canonicalised the Invite branch of `is_allowed` and left this one matching the literal string,
which is the 2026-09-03 lockout with a different source of truth: an operator who writes
`first.last@gmail.com` into ALLOWED_EMAILS is refused when Google answers with the undotted
primary, and a `+tag`ged entry can never match at all, because a Google account cannot contain a
`+`. The addresses themselves stay literal wherever we MAIL them (`admin_notify_groups`); only
the comparison is canonical.
"""
return env_admin_canonicals() | {canonical_email(e) for e in settings.allowed_email_set}
def env_admin_canonicals() -> set[str]:
"""The env ADMIN_EMAILS bootstrap list, canonical — for the role check only. Kept separate from
`env_allowed_canonicals` because being listed as an admin also grants access, but being allowed
does not make anyone an admin."""
return {canonical_email(e) for e in settings.admin_email_set}
def is_allowed(db: Session, email: str) -> bool:
"""May this email sign in? An approved Invite is the source of truth; the env
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB)."""
ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB).
Matched on the CANONICAL form (utils.canonical_email), not the literal string. Gmail delivers
every dotted / `+tagged` spelling to one mailbox, so approving one spelling always meant approving
that mailbox — matching literally only produced the 2026-09-03 lockout, where an approved user's
Google sign-in became a silent second access request because OAuth returned the undotted primary.
One canonical form can match SEVERAL invite rows (prod carries approved pairs for one mailbox,
the oldest from 2026-06-16). An approved row among them grants access: the API cannot flip an
approved invite to denied (`_decide` only accepts a pending one), so approved-beside-denied means
the admin approved that mailbox under a different spelling, not that they revoked it. Revoking is
deleting the invite row or suspending the account — both still work, since a deleted row is no
longer here to match and suspension is enforced at sign-in.
"""
if not email:
return False
email = email.lower()
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
if inv is not None:
return inv.status == "approved"
return email in settings.allowed_email_set or email in settings.admin_email_set
canon = canonical_email(email)
statuses = (
db.execute(select(Invite.status).where(Invite.email_canonical == canon)).scalars().all()
)
if statuses:
return "approved" in statuses
return canon in env_allowed_canonicals()
def can_activate(db: Session, user: User) -> bool:
"""Is this account merely waiting for an admin to finish letting it in?
True when it is inactive but its address is ALREADY allowed — the shape roadmap UX-25 describes:
the admin approved the address, the person signed up afterwards, and nothing connects the two
(the Access-requests queue only offers Approve on a still-pending invite).
Gated on `is_allowed`, the same function the sign-in paths consult, because activating an address
that is NOT allowed manufactures an inconsistent account: password sign-in only checks
`is_active` and would let them in, while their Google sign-in would be turned away and silently
reopened as a fresh access request. Letting in someone with no approved invite is the whitelist
box's job — that action says so out loud.
Lives in core so the admin surface and the /api/me badge count answer from ONE definition; a
button that appears and a number that counts must never disagree about who is stuck.
"""
if user.is_demo or user.is_suspended or user.is_active:
return False
return is_allowed(db, user.email)
def count_pending_activations(db: Session) -> int:
"""How many sign-ups are waiting on an admin click (see can_activate). Drives the admin badge.
Deliberately NOT "everyone who cannot sign in": an account that has simply not confirmed its
address yet is waiting on its owner, not on the admin, and badging that would nag about something
the admin did not cause. It is still listed on the admin screen — with a re-send — because the
mail may never have arrived.
"""
# Narrowed in SQL, then decided by can_activate itself — so the number the badge shows and the
# button the admin sees cannot drift apart by one of them re-deriving the rule.
rows = (
db.execute(
select(User).where(
User.is_demo.is_(False),
User.is_suspended.is_(False),
User.is_active.is_(False),
)
)
.scalars()
.all()
)
return sum(1 for u in rows if can_activate(db, u))
def find_user_by_email(db: Session, email: str) -> User | None:
"""The account someone means when they type an address at sign-in, password-reset or
verify-resend. None when there isn't exactly one answer.
Exact spelling wins. Failing that, the address is resolved to its mailbox
(utils.canonical_email) — so a password chosen at registration still works when the person types
another spelling of their own Gmail, and so the three flows that look an account up by typed
address stop being dead ends. Before this, requesting a reset for `xy@gmail.com` on an account
registered as `x.y@gmail.com` issued nothing and said nothing: the endpoint answers uniformly by
design, so the user got silence and no way in.
SEVERAL canonical candidates return None rather than a guess. That case is real — prod carries two
accounts for one mailbox — and picking one of them for a password sign-in would hand someone the
wrong account. The callers all treat None as "no such account", which is the uniform,
non-enumerating answer they already gave.
"""
if not email:
return None
email = email.strip().lower()
exact = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if exact is not None:
return exact
candidates = (
db.execute(select(User).where(User.email_canonical == canonical_email(email)))
.scalars()
.all()
)
return candidates[0] if len(candidates) == 1 else None
def get_or_create_demo_user(db: Session) -> User:
@@ -119,6 +231,9 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
touch any browser session — each caller handles its own."""
email = user.email.lower()
user_id = user.id
# Read the language off the row while it still exists — the confirmation below is queued as a
# background task that runs after this session (and this user) are gone.
lang = mailtext.lang_for_user(user)
tok = user.token
# Both are stored encrypted — decrypt for the revoke call (the access-token fallback was passing
# ciphertext, so a token row with no refresh token silently failed to revoke). decrypt_optional so a
@@ -130,13 +245,25 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
)
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
db.execute(delete(User).where(User.id == user_id))
db.execute(delete(Invite).where(Invite.email == email))
# Every canonically-equal invite goes, not just the literal spelling: is_allowed matches on the
# canonical form, so leaving a sibling row behind would keep granting access to a mailbox whose
# account was just erased — and prod really does carry approved pairs for one mailbox.
#
# Unless ANOTHER account still lives on that mailbox — prod carries that too (two user rows for
# one Gmail mailbox). Then NO invite goes, not even the literal one: is_allowed matches
# canonically, so every row here is what keeps the survivor allowed, and removing any of them
# would refuse their next Google sign-in and reopen it as a fresh access request — this epic's
# founding bug, caused by its own cleanup. `delete_invite` refuses for exactly this reason; the
# user row is already gone by now, so this query sees only the others.
canon = canonical_email(email)
if db.execute(select(User.id).where(User.email_canonical == canon)).first() is None:
db.execute(delete(Invite).where(Invite.email_canonical == canon))
db.commit()
if google_token:
background.add_task(revoke_google_token, google_token)
# Confirm the erasure to the (now former) user — GDPR good practice, and covers both the
# self-service and admin deletion paths since both funnel through here.
background.add_task(email_mod.send_account_deleted, email, operator_contact())
background.add_task(email_mod.send_account_deleted, email, lang, operator_contact())
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
+1 -1
View File
@@ -265,7 +265,7 @@ class Settings(BaseSettings):
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_want_hw`.
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`.
plex_transcode_hw: str = "auto"
# Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf`
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
+3 -3
View File
@@ -447,9 +447,9 @@ def run_ffprobe_json(
) -> dict | None:
"""One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit,
missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads
package: the browser-playability codec probe here and the upload-ingest probe (`upload._probe`)
both go through it, so the subprocess flags/timeout/error handling live in one place instead of
two drifting copies."""
package: the browser-playability codec probe here and the shared media probe (`probe.probe_media`,
used by both the download worker's finalize and the upload ingest) both go through it, so the
subprocess flags/timeout/error handling live in one place instead of drifting copies."""
args = ["ffprobe", "-v", "error"]
if show_streams:
args.append("-show_streams")
+8 -9
View File
@@ -15,7 +15,7 @@ from app.config import settings
from app.db import SessionLocal
from app.downloads import edit as editmod
from app.downloads import errors as dlerrors
from app.downloads import adapters, credentials, formats, mediakind, service, storage
from app.downloads import adapters, credentials, formats, mediakind, probe, service, storage
from app.downloads.jobdb import (
_edit_staging_dir,
_job_status,
@@ -475,15 +475,14 @@ def _download(
asset.rel_path = rel
asset.size_bytes = size
asset.duration_s = meta.duration_s
asset.width = int(info["width"]) if info.get("width") else None
asset.height = int(info["height"]) if info.get("height") else None
# Codecs/dims come from a PROBE of the finished file — the ground truth — NOT yt-dlp's
# `info` dict, whose codec fields are absent for some sources (Facebook among them):
# deriving media_kind from those nulls misclassified a real H.264 video as a
# download-only 'file' with no in-app play/edit. Same probe the upload ingest uses.
asset.vcodec, asset.acodec, asset.width, asset.height = probe.resolve_media_facts(
final, info
)
asset.container = ext
asset.vcodec = (
info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
)
asset.acodec = (
info.get("acodec") if info.get("acodec") not in (None, "none") else None
)
asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec)
asset.title = meta.title
asset.uploader = meta.uploader
+107
View File
@@ -0,0 +1,107 @@
"""Ground-truth media probe for a produced library file — the SINGLE place both ingest paths ask
"what is actually on disk?".
`media_kind` (and the stored `vcodec`/`acodec`/`width`/`height`) must reflect the real file, not an
extractor's metadata: yt-dlp's `info` dict omits codec fields for some sources (e.g. Facebook), and
deriving the kind from those nulls misclassified a genuine H.264 video as a download-only `file`
(no in-app play). So the download worker (`pipeline`) and the local upload ingest (`upload`) both
probe the finished file here; `mediakind.derive` then turns the probed codecs into the kind.
"""
from pathlib import Path
from app.downloads import edit as editmod
# ffprobe container formats that mean "this is a still image / image sequence", NOT playable media.
# A standalone jpg/png/gif/webp/bmp reports a real video stream (codec mjpeg/png/…) with no
# `attached_pic` flag, so without this guard it would classify as media_kind='video' and get a Play
# button + a broken in-app <video> player (and inline serving on the public share). Keyed on the
# container format so an animated gif/webp also lands as a generic 'file' (no browser plays those in
# a <video> either). SVG and anything else ffprobe can't decode already fails the probe → 'file'.
_IMAGE_FORMATS = {
"image2",
"image2pipe",
"png_pipe",
"mjpeg",
"mjpeg_pipe",
"jpeg_pipe",
"gif",
"webp_pipe",
"bmp_pipe",
"tiff_pipe",
"ico",
"svg_pipe",
}
def _facts_from_probe(
data: dict,
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
"""(vcodec, acodec, width, height, duration_s) parsed from a NON-None ffprobe JSON dict. A still
image / image-sequence container yields no vcodec (→ generic 'file'); a video stream flagged
`attached_pic` (embedded cover art in an mp3/m4a) is skipped — it's a thumbnail, not a real video
track, so an audio file with album art still classifies as audio."""
fmt = data.get("format") or {}
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
if fmt_names & _IMAGE_FORMATS:
return None, None, None, None, None
vcodec = acodec = None
width = height = None
for stream in data.get("streams", []):
kind = stream.get("codec_type")
if kind == "video" and vcodec is None:
if (stream.get("disposition") or {}).get("attached_pic"):
continue # cover art, not a video track
vcodec = stream.get("codec_name")
width = stream.get("width")
height = stream.get("height")
elif kind == "audio" and acodec is None:
acodec = stream.get("codec_name")
duration = None
try:
raw = fmt.get("duration")
if raw is not None:
duration = int(float(raw))
except (TypeError, ValueError):
duration = None
return vcodec, acodec, width, height, duration
def probe_media(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
non-media file, so it becomes a download-only 'file' rather than a broken 'video')."""
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
if data is None:
return None, None, None, None, None
return _facts_from_probe(data)
def resolve_media_facts(
path: Path, info: dict
) -> tuple[str | None, str | None, int | None, int | None]:
"""(vcodec, acodec, width, height) for the stored download asset: PROBE the finished file on disk
(the ground truth). yt-dlp's `info` omits codec/dimension fields for some sources (Facebook among
them), so deriving media_kind from it there labelled a real H.264 video a download-only 'file'.
Precedence is by whether the probe RAN, not per-field: if ffprobe read the file, its codec verdict
is authoritative — INCLUDING a deliberate None (cover-art-only audio, a still image). A per-field
`probe or info` fallback would let `info`'s stale vcodec resurrect a video track the file doesn't
have (yt-dlp reports a progressive `vcodec` for an audio-mode download whose video it then strips),
mislabelling an audio download as a broken in-app 'video'. Only when ffprobe could NOT read the
file at all do we fall back to `info` (the pre-probe behaviour, a safety net); the cosmetic
dimensions still gap-fill from `info` in either case."""
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
info_v = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
info_a = info.get("acodec") if info.get("acodec") not in (None, "none") else None
info_w = int(info["width"]) if info.get("width") else None
info_h = int(info["height"]) if info.get("height") else None
if data is None:
# ffprobe could not read the file — fall back to the extractor's info entirely.
return info_v, info_a, info_w, info_h
pv, pa, pw, ph, _dur = _facts_from_probe(data)
# Probe ran → its codecs win outright (a None means "no such track", not "unknown"); info only
# gap-fills the cosmetic width/height the probe couldn't read.
return pv, pa, pw if pw is not None else info_w, ph if ph is not None else info_h
+2 -63
View File
@@ -24,70 +24,9 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import edit as editmod
from app.downloads import mediakind, quota, service, storage
from app.downloads import mediakind, probe, quota, service, storage
from app.models import DownloadJob, MediaAsset
# ffprobe container formats that mean "this is a still image / image sequence", NOT playable media.
# A standalone jpg/png/gif/webp/bmp reports a real video stream (codec mjpeg/png/…) with no
# `attached_pic` flag, so without this guard it would classify as media_kind='video' and get a Play
# button + a broken in-app <video> player (and inline serving on the public share). Keyed on the
# container format so an animated gif/webp also lands as a generic 'file' (no browser plays those in
# a <video> either). SVG and anything else ffprobe can't decode already fails the probe → 'file'.
_IMAGE_FORMATS = {
"image2",
"image2pipe",
"png_pipe",
"mjpeg",
"mjpeg_pipe",
"jpeg_pipe",
"gif",
"webp_pipe",
"bmp_pipe",
"tiff_pipe",
"ico",
"svg_pipe",
}
def _probe(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
non-media upload, so it becomes a download-only 'file' rather than broken 'video').
A video stream flagged `attached_pic` (embedded cover art in an mp3/m4a) is skipped — it's a
thumbnail, not a real video track, so an audio file with album art still classifies as audio."""
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
if data is None:
return None, None, None, None, None
fmt = data.get("format") or {}
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
if fmt_names & _IMAGE_FORMATS:
# A still image / image sequence is a generic file, not video — no vcodec means media_kind='file'.
return None, None, None, None, None
vcodec = acodec = None
width = height = None
for stream in data.get("streams", []):
kind = stream.get("codec_type")
if kind == "video" and vcodec is None:
if (stream.get("disposition") or {}).get("attached_pic"):
continue # cover art, not a video track
vcodec = stream.get("codec_name")
width = stream.get("width")
height = stream.get("height")
elif kind == "audio" and acodec is None:
acodec = stream.get("codec_name")
duration = None
try:
raw = fmt.get("duration")
if raw is not None:
duration = int(float(raw))
except (TypeError, ValueError):
duration = None
return vcodec, acodec, width, height, duration
def _title_from_filename(filename: str) -> str:
"""A human display title from the original upload name: the stem with separators tidied to
@@ -112,7 +51,7 @@ def ingest_upload(
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
vcodec, acodec, width, height, duration = _probe(src_path)
vcodec, acodec, width, height, duration = probe.probe_media(src_path)
kind = mediakind.derive(vcodec, acodec)
# An upload never dedups (each file is its own asset): a random ref makes the cache key unique,
+252 -114
View File
@@ -3,36 +3,103 @@
Gmail SMTP + App Password by default. Deliberately fail-soft: if SMTP isn't configured
or a send errors, we log and return False so the caller can fall back to in-app
notifications — email is never load-bearing for the app's core flow.
Fail-soft must not mean invisible, though: every drop also leaves an in-app notice in the admins'
inbox (see `_alert_admins`). An instance whose SMTP was never filled in silently swallows
verification links, password resets and access-request notices, and the operator's first clue used
to be a person telling them nothing arrived.
"""
import html
import logging
import re
import smtplib
import ssl
from email.message import EmailMessage
from email.utils import formatdate
from app import sysconfig
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import mailtext, sysconfig
from app.config import settings
from app.db import SessionLocal
from app.models import User
from app.notifications import create_notification
from app.ratelimit import RateLimiter
log = logging.getLogger("siftlode.email")
def _smtp() -> dict:
"""Effective SMTP config (admin DB override over env defaults). Opens its own short
session because email is often sent from a BackgroundTask, outside a request's db."""
with SessionLocal() as db:
return {
"host": sysconfig.get_str(db, "smtp_host"),
"port": sysconfig.get_int(db, "smtp_port"),
"user": sysconfig.get_str(db, "smtp_user"),
"password": sysconfig.get_str(db, "smtp_password"),
"from": sysconfig.get_str(db, "smtp_from"),
}
# One admin notice an hour per reason. A relay that is down fails every send, and the inbox must
# stay readable; the log keeps the full record either way.
_alert_throttle = RateLimiter(max_events=1, window_seconds=3600)
def email_enabled() -> bool:
c = _smtp()
def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
"""Record an in-app notice that a message did NOT go out. Best effort in every direction: it
opens its own session (callers are often background tasks), swallows its own errors, and never
reports failure to the caller — this is the fallback path, it must not become a new way to
break a send."""
if not _alert_throttle.allow(reason):
return
try:
with SessionLocal() as db:
admins = (
db.execute(
select(User.id).where(
User.role == "admin",
User.is_active.is_(True),
User.is_suspended.is_(False),
)
)
.scalars()
.all()
)
if not admins:
# Nothing was recorded, so don't let this cost the next hour of alerts on the very
# path whose job is to stop drops going unseen. Same on the failure branch below.
_alert_throttle.reset()
return
for uid in admins:
create_notification(
db,
user_id=uid,
type="email_undelivered",
# English fallback; the inbox renders a translated version from `data`.
title="Email could not be sent",
body=subject,
data={"reason": reason, "subject": subject, "recipients": recipients},
commit=False,
)
db.commit()
except Exception:
_alert_throttle.reset()
log.exception("Could not record the undelivered-email notice (%r)", subject)
def _read_smtp(db: Session) -> dict:
return {
"host": sysconfig.get_str(db, "smtp_host"),
"port": sysconfig.get_int(db, "smtp_port"),
"user": sysconfig.get_str(db, "smtp_user"),
"password": sysconfig.get_str(db, "smtp_password"),
"from": sysconfig.get_str(db, "smtp_from"),
}
def _smtp(db: Session | None = None) -> dict:
"""Effective SMTP config (admin DB override over env defaults). Opens its own short session when
the caller has none, because email is usually sent from a BackgroundTask outside a request's db.
A caller that IS inside a request should pass its session: reading the password decrypts a Fernet
value, and /api/me polls this for every admin tab."""
if db is not None:
return _read_smtp(db)
with SessionLocal() as own:
return _read_smtp(own)
def email_enabled(db: Session | None = None) -> bool:
c = _smtp(db)
return bool(c["host"] and c["user"] and c["password"])
@@ -40,6 +107,48 @@ def _admin_contact() -> str | None:
return next(iter(sorted(settings.admin_email_set)), None)
# A bare URL in a text/plain mail is only clickable if the CLIENT decides to linkify it. Gmail,
# Outlook and Apple Mail do; plenty of lighter web clients render the body verbatim and leave the
# link as dead text — which, for a verification or password-reset mail, is the whole message. So
# every mail also carries an HTML alternative built from the SAME plain body (below), rather than
# per-template HTML that would have to be kept in sync with it.
#
# `[^\s<]` stops at whitespace and at the `<` of any following markup; the trailing-punctuation trim
# keeps a sentence-final period out of the href. `;` and `:` are deliberately NOT trimmed — escaping
# turns `&` into `&amp;`, so a query string can legitimately end in a semicolon.
_URL_RE = re.compile(r"https?://[^\s<]+")
def _html_body(text: str) -> str:
"""Render a plain-text mail body as minimal HTML: escaped, blank-line-separated paragraphs, with
real links. Escaping happens FIRST and the linkifier then runs over the escaped text — bodies
interpolate untrusted values (a requester's email address, the operator contact), so no caller
can inject markup, and `&` inside a URL is already `&amp;` by the time it reaches the href, which
is what an HTML attribute wants anyway."""
def _anchor(m: re.Match[str]) -> str:
url = m.group(0)
trail = ""
while url and url[-1] in ".,!?)":
url, trail = url[:-1], url[-1] + trail
if not url:
return trail
return f'<a href="{url}">{url}</a>{trail}'
paragraphs = [p for p in html.escape(text).split("\n\n") if p.strip()]
rendered = "".join(
f"<p>{_URL_RE.sub(_anchor, p).replace(chr(10), '<br>')}</p>" for p in paragraphs
)
# Inline styles only — many clients drop <style> blocks. No images, no external CSS, no tracking:
# the HTML part exists to make the link clickable, not to become a template to maintain.
return (
'<html><body style="margin:0;padding:16px;font-family:system-ui,-apple-system,'
"'Segoe UI',sans-serif;font-size:15px;line-height:1.6\">"
f'<div style="max-width:560px">{rendered}</div>'
"</body></html>"
)
def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -> bool:
recipients = [e for e in to if e]
if not recipients:
@@ -47,6 +156,7 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
c = _smtp()
if not (c["host"] and c["user"] and c["password"]):
log.info("SMTP not configured; skipping email %r to %s", subject, recipients)
_alert_admins("not_configured", subject, recipients)
return False
msg = EmailMessage()
msg["Subject"] = subject
@@ -57,7 +167,10 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
msg["Date"] = formatdate(localtime=True)
if reply_to:
msg["Reply-To"] = reply_to
# text/plain stays the primary part (it is the source of truth, and a text-only client still
# gets the full message); the HTML alternative below is what makes the link clickable everywhere.
msg.set_content(body)
msg.add_alternative(_html_body(body), subtype="html")
try:
with smtplib.SMTP(c["host"], c["port"], timeout=20) as s:
s.starttls(context=ssl.create_default_context())
@@ -67,128 +180,153 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
return True
except Exception:
log.exception("SMTP send failed (%r to %s)", subject, recipients)
_alert_admins("send_failed", subject, recipients)
return False
def send_access_approved(email: str) -> bool:
def _next_step(verify_link: str | None, has_account: bool, hours: int, lang: str) -> str:
"""What this person actually has to do next, in the one paragraph that carries the whole flow.
Three states, because a mail that guesses wrong sends someone looking for a link that isn't
coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will
refuse them (E9.5 S4 named that state on screen; the mail must not contradict it).
The link branch is the point of the C option: in the common ordering — approve first, the person
signs up later, or signs up and waits — they would otherwise have to find and open TWO separate
messages. One mail, one click.
"""
if verify_link:
return mailtext.text(
"next_step.verify", lang, link=verify_link, expiry=mailtext.link_expiry(hours, lang)
)
if has_account:
return mailtext.text("next_step.sign_in", lang, app=settings.app_base)
return mailtext.text("next_step.register", lang, app=settings.app_base)
def _reply_contact(lang: str) -> tuple[str | None, str]:
"""The admin address to answer to, and the closing line that names it — empty when no admin
contact is configured, so the mail never promises a reply nobody will read."""
admin = _admin_contact()
body = (
"Hi,\n\n"
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
"Open Siftlode and sign in, and your YouTube subscriptions feed will be ready:\n\n"
f"{settings.app_base}\n\n"
"If you weren't expecting this, you can ignore the message.\n\n"
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
return admin, (mailtext.text("contact.reply", lang, admin=admin) if admin else "")
def send_access_approved(
email: str,
lang: str,
verify_link: str | None = None,
has_account: bool = False,
hours: int = 24,
) -> bool:
"""Answer someone who ASKED for access. Sent automatically on approval, because a person who
made a request is owed a reply — unlike the admin-initiated invitation below."""
admin, contact = _reply_contact(lang)
body = mailtext.text(
"access_approved.body",
lang,
next_step=_next_step(verify_link, has_account, hours, lang),
contact=contact,
)
return _send([email], "You're in — your Siftlode access is approved", body, reply_to=admin)
return _send([email], mailtext.text("access_approved.subject", lang), body, reply_to=admin)
def send_access_denied(email: str) -> bool:
admin = _admin_contact()
body = (
"Hi,\n\n"
"Thanks for your interest in Siftlode. After review, your access request wasn't approved "
"this time.\n\n"
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
"— Siftlode" + (f"\n\nQuestions? Just reply to this email ({admin})." if admin else "")
def send_access_invitation(
email: str,
lang: str,
verify_link: str | None = None,
has_account: bool = False,
hours: int = 24,
) -> bool:
"""Invite someone the admin let in WITHOUT them asking — sent only when the admin presses send.
Whitelisting an address is opening a door, not knocking on one: the operator has usually already
agreed it with the person, and an address can sit approved for weeks without anyone registering.
So adding to the whitelist stays silent and this is a separate, deliberate act. That is also why
the closing line offers no deadline — nudging is the one thing this mail must not do.
Its language is the one place the app has no signal to read: the admin typed this address in, so
nobody has ever told us how to address its owner. That is why the send button asks (E9.5 S7).
"""
admin, contact = _reply_contact(lang)
body = mailtext.text(
"access_invitation.body",
lang,
next_step=_next_step(verify_link, has_account, hours, lang),
contact=contact,
)
return _send([email], "About your Siftlode access request", body, reply_to=admin)
return _send([email], mailtext.text("access_invitation.subject", lang), body, reply_to=admin)
def send_role_changed(to: str, role: str, operator: str | None) -> bool:
line = (
"You've been granted admin access on Siftlode — you can now manage settings, the "
"scheduler and users."
if role == "admin"
else "Your admin access on Siftlode has been removed — you're now a regular user."
def send_access_denied(email: str, lang: str) -> bool:
admin, contact = _reply_contact(lang)
body = mailtext.text("access_denied.body", lang, contact=contact)
return _send([email], mailtext.text("access_denied.subject", lang), body, reply_to=admin)
def send_role_changed(to: str, lang: str, role: str, operator: str | None) -> bool:
line = mailtext.text(
"role_changed.granted" if role == "admin" else "role_changed.revoked", lang
)
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
body = "Hi,\n\n" + line + contact + "\n\n— Siftlode"
return _send([to], "Your Siftlode role has changed", body, reply_to=operator)
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
body = mailtext.text("role_changed.body", lang, line=line, contact=contact)
return _send([to], mailtext.text("role_changed.subject", lang), body, reply_to=operator)
def send_account_unsuspended(to: str, operator: str | None) -> bool:
contact = f"\n\nQuestions? Contact the operator at {operator}." if operator else ""
body = (
"Hi,\n\n"
"Your Siftlode account has been reinstated — the suspension was lifted and you can sign "
"in again." + contact + "\n\n— Siftlode"
)
return _send([to], "Your Siftlode account has been reinstated", body, reply_to=operator)
def send_account_unsuspended(to: str, lang: str, operator: str | None) -> bool:
contact = mailtext.text("contact.operator", lang, operator=operator) if operator else ""
body = mailtext.text("account_unsuspended.body", lang, contact=contact)
return _send([to], mailtext.text("account_unsuspended.subject", lang), body, reply_to=operator)
def send_account_deleted(to: str, operator: str | None) -> bool:
def send_account_deleted(to: str, lang: str, operator: str | None) -> bool:
contact = mailtext.text("contact.deleted", lang, operator=operator) if operator else ""
body = mailtext.text("account_deleted.body", lang, contact=contact)
return _send([to], mailtext.text("account_deleted.subject", lang), body, reply_to=operator)
def send_account_suspended(to: str, lang: str, operator: str | None) -> bool:
contact = (
f"\n\nIf you didn't request this, contact the Siftlode operator at {operator}."
mailtext.text("contact.suspended", lang, operator=operator)
if operator
else ""
else mailtext.text("contact.suspended_unknown", lang)
)
body = (
"Hi,\n\n"
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
"Thanks for having used Siftlode." + contact + "\n\n— Siftlode"
body = mailtext.text("account_suspended.body", lang, contact=contact)
return _send([to], mailtext.text("account_suspended.subject", lang), body, reply_to=operator)
def send_admin_new_request(admins: list[str], lang: str, requester: str) -> bool:
"""Tell the admins someone is waiting. One call per language group (see
`routes.auth.notify_admins_of_request`) — admins are people with their own UI language, and a
single blast to all of them would have to pick one and be wrong for the rest.
The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
requester address is spelled out (mail clients auto-link both the URL and the address), and the
reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
"""
body = mailtext.text(
"admin_new_request.body",
lang,
requester=requester,
link=f"{settings.app_base}/?admin=access-requests",
)
return _send([to], "Your Siftlode account has been deleted", body, reply_to=operator)
subject = mailtext.text("admin_new_request.subject", lang, requester=requester)
return _send(admins, subject, body, reply_to=requester)
def send_account_suspended(to: str, operator: str | None) -> bool:
contact = (
f"If you think this is a mistake or have questions, contact the Siftlode operator "
f"at {operator}.\n\n"
if operator
else "If you think this is a mistake, please contact the Siftlode operator.\n\n"
def send_verify_email(to: str, lang: str, link: str, hours: int = 24) -> bool:
# The body says what the link's rules are. The reset mail always has; this one left the reader to
# discover both by meeting a dead link, which is precisely the silence this epic removes.
body = mailtext.text(
"verify_email.body", lang, link=link, expiry=mailtext.link_expiry(hours, lang)
)
body = (
"Hi,\n\n"
"A sign-in to your Siftlode account was just attempted, but the account is currently "
"suspended, so access was denied.\n\n" + contact + "— Siftlode"
)
return _send([to], "Your Siftlode account is suspended", body, reply_to=operator)
return _send([to], mailtext.text("verify_email.subject", lang), body)
def send_admin_new_request(admins: list[str], requester: str) -> bool:
# The link deep-links straight to the admin Access-requests page (the SPA reads ?admin=…). The
# requester address is spelled out (mail clients auto-link both the URL and the address), and the
# reply note is accurate: _send sets Reply-To to the requester, so a reply reaches THEM — not the
# sending mailbox. (The old copy said "Settings → Account", which was the wrong menu.)
approve_url = f"{settings.app_base}/?admin=access-requests"
body = (
f"{requester} just requested access to Siftlode.\n\n"
f"Approve or deny it here:\n{approve_url}\n"
f"(or in Siftlode: open Users → Access requests)\n\n"
f"Requester's email: {requester}\n"
f"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
)
return _send(admins, f"Siftlode access request from {requester}", body, reply_to=requester)
def send_password_reset(to: str, lang: str, link: str) -> bool:
body = mailtext.text("password_reset.body", lang, link=link)
return _send([to], mailtext.text("password_reset.subject", lang), body)
def send_verify_email(to: str, link: str) -> bool:
body = (
"Hi,\n\n"
"Confirm your email to finish creating your Siftlode account:\n\n"
f"{link}\n\n"
"If you didn't sign up, you can ignore this message.\n\n"
"— Siftlode"
)
return _send([to], "Confirm your Siftlode email", body)
def send_password_reset(to: str, link: str) -> bool:
body = (
"Hi,\n\n"
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
f"{link}\n\n"
"If you didn't request this, you can ignore this message — your password is unchanged.\n\n"
"— Siftlode"
)
return _send([to], "Reset your Siftlode password", body)
def send_test(to: str) -> bool:
body = (
"This is a test email from Siftlode.\n\n"
"If you're reading this, your SMTP settings work.\n\n"
"— Siftlode"
)
return _send([to], "Siftlode SMTP test", body)
def send_test(to: str, lang: str) -> bool:
return _send([to], mailtext.text("test.subject", lang), mailtext.text("test.body", lang))
+373
View File
@@ -0,0 +1,373 @@
"""Bilingual text for every outbound mail, and the one resolver that decides which language a
recipient gets (E9.5 S7).
Why a catalog instead of literals in `email.py`: HU/EN parity is a house rule for every user-facing
string, and a mail is the one user-facing surface the reader CANNOT re-render by switching the UI
language — it arrives once, in whatever language we picked, and stays that way. `test_mail_language.py`
enforces that every key carries both languages *and* the same `{placeholders}`, so a half-translated
mail fails the gate instead of reaching a person.
The language is resolved by the REQUEST handler and handed to `send_*` as a plain code. It is never
resolved inside the mail: every send runs as a `BackgroundTasks` job, after the request's session is
gone — the same reason `_welcome_mail_parts` computes its link in the handler and not in the task.
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Invite, User
from app.utils import canonical_email
SUPPORTED: tuple[str, ...] = ("en", "hu")
DEFAULT = "en"
def normalize(lang: str | None) -> str:
"""Coerce anything to a language we can actually render. Storage is not a guarantee: a
`preferences.language` written by an older build (or a locale we no longer ship) must degrade to
English rather than raise inside a background task nobody is watching."""
return lang if lang in SUPPORTED else DEFAULT
def text(key: str, lang: str | None, **values: object) -> str:
"""Render one catalog entry. Unknown languages fall back to English (see `normalize`); an
unknown KEY raises, because that is a programming error the tests must surface, not something to
paper over with an empty line in someone's inbox."""
return MAIL_TEXT[key][normalize(lang)].format(**values)
def link_expiry(hours: int, lang: str) -> str:
"""How long a link lasts, in a unit a reader converts on sight. A whole number of days is
said in days — the approval mail's token lives seven of them, and "168 hours" is not an answer
to "how long have I got". A single day stays "24 hours", which is how everyone writes it."""
if hours >= 48 and hours % 24 == 0:
return text("duration.days", lang, n=hours // 24)
return text("duration.hours", lang, n=hours)
def lang_for_user(user: User | None) -> str:
"""The language a signed-up recipient gets: the one they picked in the app."""
prefs = (user.preferences or {}) if user is not None else {}
return normalize(prefs.get("language"))
def lang_for_email(db: Session, email: str) -> str:
"""The language for an address that may not have an account yet: their own account preference,
else the language they filled our access-request form in, else English.
Matched CANONICALLY like every other identity lookup in this epic, and it walks past rows that
carry no usable code instead of stopping at the first one — prod really does hold two accounts
for a single mailbox, and only one of them may have ever picked a language.
"""
canon = canonical_email(email)
for prefs in (
db.execute(select(User.preferences).where(User.email_canonical == canon).order_by(User.id))
.scalars()
.all()
):
code = (prefs or {}).get("language")
if code in SUPPORTED:
return code
for code in (
db.execute(select(Invite.lang).where(Invite.email_canonical == canon).order_by(Invite.id))
.scalars()
.all()
):
if code in SUPPORTED:
return code
return DEFAULT
# The brand line ("— Siftlode") and the product name are deliberately NOT catalog entries: they are
# a name, not a translated string, and giving them keys would invite someone to "translate" them.
MAIL_TEXT: dict[str, dict[str, str]] = {
# ── How long a link lasts ───────────────────────────────────────────────────────────────
# Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days
# (E9.5 S6), and "expires in 168 hours" is a unit no reader converts.
"duration.hours": {"en": "{n} hours", "hu": "{n} óra"},
"duration.days": {"en": "{n} days", "hu": "{n} nap"},
# ── Shared closing fragments ────────────────────────────────────────────────────────────────
"contact.reply": {
"en": "\n\nQuestions? Just reply to this email ({admin}).",
"hu": "\n\nKérdésed van? Csak válaszolj erre a levélre ({admin}).",
},
"contact.operator": {
"en": "\n\nQuestions? Contact the operator at {operator}.",
"hu": "\n\nKérdésed van? Írj az üzemeltetőnek: {operator}.",
},
"contact.deleted": {
"en": "\n\nIf you didn't request this, contact the Siftlode operator at {operator}.",
"hu": "\n\nHa nem te kérted, írj a Siftlode üzemeltetőjének: {operator}.",
},
"contact.suspended": {
"en": (
"If you think this is a mistake or have questions, contact the Siftlode operator "
"at {operator}.\n\n"
),
"hu": (
"Ha szerinted ez tévedés, vagy kérdésed van, írj a Siftlode üzemeltetőjének: "
"{operator}.\n\n"
),
},
"contact.suspended_unknown": {
"en": "If you think this is a mistake, please contact the Siftlode operator.\n\n",
"hu": "Ha szerinted ez tévedés, keresd a Siftlode üzemeltetőjét.\n\n",
},
# ── The one paragraph that carries the whole onboarding flow (see email._next_step) ──────────
"next_step.verify": {
"en": (
"You already started an account, so there is just one step left. Confirm your email "
"address and you're in:\n\n"
"{link}\n\n"
"The link works once and expires in {expiry}. If it does, you can ask for a fresh "
"one from the sign-in page."
),
"hu": (
"A fiókodat már elkezdted, úgyhogy egyetlen lépés maradt. Erősítsd meg az "
"e-mail-címed, és bent is vagy:\n\n"
"{link}\n\n"
"A link egyszer használható, és {expiry} múlva lejár. Ha lejárt, a belépési oldalon "
"kérhetsz újat."
),
},
"next_step.sign_in": {
"en": "Your account is ready — just sign in:\n\n{app}",
"hu": "A fiókod készen áll — csak lépj be:\n\n{app}",
},
"next_step.register": {
"en": (
"Create your account whenever you feel like it:\n\n"
"{app}\n\n"
"We'll send you a confirmation link to finish, and then you're in."
),
"hu": (
"Hozd létre a fiókod, amikor kedved tartja:\n\n"
"{app}\n\n"
"Küldünk egy megerősítő linket a befejezéshez, és már bent is vagy."
),
},
# ── Access decision ─────────────────────────────────────────────────────────────────────────
"access_approved.subject": {
"en": "You're in — your Siftlode access is approved",
"hu": "Bent vagy — jóváhagytuk a Siftlode-hozzáférésed",
},
"access_approved.body": {
"en": (
"Hi,\n\n"
"Your request to use Siftlode has been approved — welcome aboard.\n\n"
"{next_step}\n\n"
"If you weren't expecting this, you can ignore the message.\n\n"
"— Siftlode{contact}"
),
"hu": (
"Szia,\n\n"
"Jóváhagytuk a Siftlode-hozzáférési kérelmed — üdv a fedélzeten.\n\n"
"{next_step}\n\n"
"Ha nem számítottál erre, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
"— Siftlode{contact}"
),
},
"access_invitation.subject": {
"en": "You're invited to Siftlode",
"hu": "Meghívtak a Siftlode-ra",
},
"access_invitation.body": {
"en": (
"Hi,\n\n"
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
"subscriptions. Every upload from the channels you follow lands in one feed you can "
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
"livestreams unless you want them.\n\n"
"{next_step}\n\n"
"No rush — the door stays open.\n\n"
"— Siftlode{contact}"
),
"hu": (
"Szia,\n\n"
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó "
"a YouTube-feliratkozásaidhoz. A követett csatornák minden feltöltése egyetlen "
"hírfolyamba érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
"eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
"{next_step}\n\n"
"Nem sürget semmi — az ajtó nyitva marad.\n\n"
"— Siftlode{contact}"
),
},
"access_denied.subject": {
"en": "About your Siftlode access request",
"hu": "A Siftlode-hozzáférési kérelmedről",
},
"access_denied.body": {
"en": (
"Hi,\n\n"
"Thanks for your interest in Siftlode. After review, your access request wasn't "
"approved this time.\n\n"
"If you think this was a mistake, you're welcome to reply and let us know.\n\n"
"— Siftlode{contact}"
),
"hu": (
"Szia,\n\n"
"Köszönjük az érdeklődésed a Siftlode iránt. Átnéztük a kérelmed, de most nem hagytuk "
"jóvá.\n\n"
"Ha szerinted ez tévedés, válaszolj nyugodtan erre a levélre.\n\n"
"— Siftlode{contact}"
),
},
# ── Account lifecycle ───────────────────────────────────────────────────────────────────────
"role_changed.subject": {
"en": "Your Siftlode role has changed",
"hu": "Megváltozott a szereped a Siftlode-on",
},
"role_changed.granted": {
"en": (
"You've been granted admin access on Siftlode — you can now manage settings, the "
"scheduler and users."
),
"hu": (
"Admin jogot kaptál a Siftlode-on — mostantól kezelheted a beállításokat, az ütemezőt "
"és a felhasználókat."
),
},
"role_changed.revoked": {
"en": "Your admin access on Siftlode has been removed — you're now a regular user.",
"hu": "Elvettük az admin jogod a Siftlode-on — mostantól sima felhasználó vagy.",
},
"role_changed.body": {
"en": "Hi,\n\n{line}{contact}\n\n— Siftlode",
"hu": "Szia,\n\n{line}{contact}\n\n— Siftlode",
},
"account_unsuspended.subject": {
"en": "Your Siftlode account has been reinstated",
"hu": "Visszaállítottuk a Siftlode-fiókod",
},
"account_unsuspended.body": {
"en": (
"Hi,\n\n"
"Your Siftlode account has been reinstated — the suspension was lifted and you can "
"sign in again.{contact}\n\n— Siftlode"
),
"hu": (
"Szia,\n\n"
"Visszaállítottuk a Siftlode-fiókod — a felfüggesztést feloldottuk, újra be tudsz "
"lépni.{contact}\n\n— Siftlode"
),
},
"account_deleted.subject": {
"en": "Your Siftlode account has been deleted",
"hu": "Töröltük a Siftlode-fiókod",
},
"account_deleted.body": {
"en": (
"Hi,\n\n"
"Your Siftlode account and all associated data — subscriptions, tags, watch history, "
"playlists and settings — have been permanently deleted. Nothing is retained.\n\n"
"Thanks for having used Siftlode.{contact}\n\n— Siftlode"
),
"hu": (
"Szia,\n\n"
"A Siftlode-fiókod és minden hozzá tartozó adat — feliratkozások, címkék, megtekintési "
"előzmények, lejátszási listák és beállítások — véglegesen törölve. Semmit nem őrzünk "
"meg.\n\n"
"Köszönjük, hogy használtad a Siftlode-ot.{contact}\n\n— Siftlode"
),
},
"account_suspended.subject": {
"en": "Your Siftlode account is suspended",
"hu": "A Siftlode-fiókod fel van függesztve",
},
"account_suspended.body": {
"en": (
"Hi,\n\n"
"A sign-in to your Siftlode account was just attempted, but the account is currently "
"suspended, so access was denied.\n\n{contact}— Siftlode"
),
"hu": (
"Szia,\n\n"
"Valaki most próbált belépni a Siftlode-fiókodba, de a fiók jelenleg fel van "
"függesztve, ezért a belépést elutasítottuk.\n\n{contact}— Siftlode"
),
},
# ── To the admins ───────────────────────────────────────────────────────────────────────────
"admin_new_request.subject": {
"en": "Siftlode access request from {requester}",
"hu": "Siftlode hozzáférési kérelem: {requester}",
},
"admin_new_request.body": {
"en": (
"{requester} just requested access to Siftlode.\n\n"
"Approve or deny it here:\n{link}\n"
"(or in Siftlode: open Users → Access requests)\n\n"
"Requester's email: {requester}\n"
"Replying to this message goes straight to the requester (it's the Reply-To address).\n"
),
"hu": (
"{requester} most kért hozzáférést a Siftlode-hoz.\n\n"
"Itt hagyhatod jóvá vagy utasíthatod el:\n{link}\n"
"(vagy a Siftlode-ban: Felhasználók → Hozzáférési kérések)\n\n"
"A kérelmező e-mail-címe: {requester}\n"
"Ha erre a levélre válaszolsz, az egyenesen a kérelmezőhöz megy (ő a Reply-To cím).\n"
),
},
# ── Link mails ──────────────────────────────────────────────────────────────────────────────
"verify_email.subject": {
"en": "Confirm your Siftlode email",
"hu": "Erősítsd meg a Siftlode e-mail-címed",
},
"verify_email.body": {
"en": (
"Hi,\n\n"
"Confirm your email to finish creating your Siftlode account:\n\n"
"{link}\n\n"
"The link works once and expires in {expiry}. If it does, you can ask for a fresh "
"one from the sign-in page.\n\n"
"If you didn't sign up, you can ignore this message.\n\n"
"— Siftlode"
),
"hu": (
"Szia,\n\n"
"Erősítsd meg az e-mail-címed, hogy elkészüljön a Siftlode-fiókod:\n\n"
"{link}\n\n"
"A link egyszer használható, és {expiry} múlva lejár. Ha lejárt, a belépési oldalon "
"kérhetsz újat.\n\n"
"Ha nem te regisztráltál, nyugodtan hagyd figyelmen kívül ezt a levelet.\n\n"
"— Siftlode"
),
},
"password_reset.subject": {
"en": "Reset your Siftlode password",
"hu": "Állíts be új Siftlode-jelszót",
},
"password_reset.body": {
"en": (
"Hi,\n\n"
"Use this link to set a new Siftlode password (it expires in an hour):\n\n"
"{link}\n\n"
"If you didn't request this, you can ignore this message — your password is "
"unchanged.\n\n"
"— Siftlode"
),
"hu": (
"Szia,\n\n"
"Ezzel a linkkel állíthatsz be új Siftlode-jelszót (egy óra múlva lejár):\n\n"
"{link}\n\n"
"Ha nem te kérted, hagyd figyelmen kívül ezt a levelet — a jelszavad változatlan.\n\n"
"— Siftlode"
),
},
"test.subject": {
"en": "Siftlode SMTP test",
"hu": "Siftlode SMTP-teszt",
},
"test.body": {
"en": (
"This is a test email from Siftlode.\n\n"
"If you're reading this, your SMTP settings work.\n\n"
"— Siftlode"
),
"hu": (
"Ez egy teszt e-mail a Siftlode-tól.\n\n"
"Ha ezt olvasod, az SMTP-beállításaid működnek.\n\n"
"— Siftlode"
),
},
}
+67 -3
View File
@@ -17,9 +17,10 @@ from sqlalchemy import (
func,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.db import Base
from app.utils import canonical_email
class TimestampMixin:
@@ -29,6 +30,21 @@ class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class CanonicalEmailMixin:
"""Keeps `email_canonical` derived from `email` on every write.
The invariant — "identity is matched on the canonical form, mail is sent to the typed one" —
lives HERE, in code, instead of in a comment every write site has to remember. That distinction
is what the 2026-09-03 lockout was made of: one gate lowercased, nothing else did, and an
approved user's Google sign-in silently became a second access request.
"""
@validates("email")
def _derive_email_canonical(self, _key: str, value: str | None) -> str | None:
self.email_canonical = canonical_email(value)
return value
class UpdatedAtMixin:
"""Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE."""
@@ -37,7 +53,7 @@ class UpdatedAtMixin:
)
class User(Base, TimestampMixin):
class User(Base, TimestampMixin, CanonicalEmailMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -45,8 +61,26 @@ class User(Base, TimestampMixin):
# multiple NULLs under a unique index). Set on Google sign-in / in-app linking.
google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
# The same mailbox, in the form used to MATCH identities (utils.canonical_email) — Gmail dots and
# `+tags` folded away. Kept by the validator below so no write site can forget it. Deliberately
# NOT unique: prod already carries two rows for one mailbox (a password registration and the
# Google sign-in that failed to find it), and a migration must not merge accounts on its own.
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
# The address Google asserted for this account, recorded at sign-in / link time. NOT the same
# thing as `email`: since E9.5 S3 a Google sign-in no longer overwrites the address the user
# registered with, so a deliberate `me+siftlode@gmail.com` survives — and this is where the other
# half of the truth lives, for the UI to show when the two differ. NULL until the next Google
# sign-in for accounts linked before this column existed.
google_email: Mapped[str | None] = mapped_column(String(320))
# argon2id hash for email+password login; NULL for Google-only / demo accounts.
password_hash: Mapped[str | None] = mapped_column(String(255))
# When SEC-10 dropped a preset password on Google adoption (see the single caller of
# auth.revoke_preset_password_if_unverified; admin approval no longer revokes — E9.5 S1). Without
# this trace a revoked account was indistinguishable from a Google-only one, so it failed the
# password-reset eligibility check and could never be recovered — it could neither sign in nor
# reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a
# password is set again (reset-confirm / set-password).
password_revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Email ownership proven (Google sign-in is implicitly verified; password registration
# verifies via a link). Login requires this AND is_active.
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
@@ -114,7 +148,7 @@ class AuthToken(Base, TimestampMixin):
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class Invite(Base):
class Invite(Base, CanonicalEmailMixin):
"""Access-request / whitelist row. Source of truth for who may sign in; the env
ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed)."""
@@ -122,6 +156,9 @@ class Invite(Base):
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
# See User.email_canonical. Not unique here either: prod has approved invites for both spellings
# of the same mailbox, dating back to 2026-06-16.
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
# pending | approved | denied
status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending")
note: Mapped[str | None] = mapped_column(Text)
@@ -130,6 +167,17 @@ class Invite(Base):
)
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
# When the admin last SENT this person an invitation from the whitelist row. Adding an address
# stays silent — it opens a door, it does not knock — so telling them is a separate, deliberate
# act, and this is what stops it happening twice to a real person.
invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# The language this person asked us in ("en"/"hu"), captured when the request was made: the page
# their form was on, or the locale Google reported for a refused sign-in. NULL for a row the
# admin whitelisted by hand — nobody has told us anything about that mailbox, which is exactly
# why the invitation send asks (E9.5 S7). Captured rather than derived for the same reason
# `preferences.language` and `google_email` are: the signal exists only in that one moment and
# cannot be backfilled afterwards.
lang: Mapped[str | None] = mapped_column(String(8))
class DemoWhitelist(Base, TimestampMixin):
@@ -1127,6 +1175,22 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
playable: Mapped[str | None] = mapped_column(String(12), index=True)
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Transcode HW-capability facet (E7 playback-class filter). NULL for non-transcode items; for a
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
# set codec-only at sync (bit depth unknown from the cheap listing), then refined by the bit-depth
# backfill (`_probe_media_batch`) which downgrades a 10-bit/HDR HEVC to False. The one filterable
# home of the invariant `_want_hw`'s source gate computes at play time — kept in sync via the shared
# classifier so the catalog facet and the runtime encoder choice never disagree by codec.
transcode_hw: Mapped[bool | None] = mapped_column(Boolean, index=True)
# Video bit depth (8/10/12…) from the Plex media Stream, filled by the bit-depth backfill. NULL =
# not yet probed. Surfaced on the info page (e.g. "HEVC · 10-bit") and feeds `transcode_hw`.
video_bit_depth: Mapped[int | None] = mapped_column(Integer)
# Watermark for the bit-depth backfill: when this item's media Stream was last probed for
# bit-depth/HDR. NULL = never (the backfill's work-list). Only HW-decodable transcode items are
# probed (the rest are SW-only by codec and need no bit-depth); see `_probe_media_batch`.
media_probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
markers: Mapped[list | None] = mapped_column(JSON)
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
+2
View File
@@ -33,6 +33,7 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict:
"thumb": f"/api/plex/image/{it.rating_key}",
"art": _art_url(it.rating_key, it.art_key),
"playable": it.playable,
"transcode_hw": it.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
}
@@ -65,6 +66,7 @@ def episode_card(e: PlexItem, st: PlexState | None) -> dict:
"duration_seconds": e.duration_s,
"thumb": f"/api/plex/image/{e.rating_key}",
"playable": e.playable,
"transcode_hw": e.transcode_hw, # HW/SW transcode split → the 4-way corner-dot badge
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
}
+31
View File
@@ -9,6 +9,8 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, case, func, or_, select, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
from app.models import PlexItem
TS_CONFIG = "public.unaccent_simple"
# --- Language-aware column pickers (E5 S6) ----------------------------------
@@ -206,6 +208,33 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
return q.where(*conds) if conds else q
# --- Playback-class filter (E7) — MOVIE-ONLY (a show is an aggregate with no single class) ---------
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill).
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
def playback_class_expr():
"""SQL CASE mapping a PlexItem (movie) to its playback-class string, or NULL. The HW branch must
precede the generic transcode branch (ordered evaluation)."""
return case(
(PlexItem.playable == "direct", "direct"),
(PlexItem.playable == "remux", "remux"),
(and_(PlexItem.playable == "transcode", PlexItem.transcode_hw.is_(True)), "transcode_hw"),
(PlexItem.playable == "transcode", "transcode_sw"),
else_=None,
)
def playable_conds(sel: list[str]) -> list:
"""A one-element condition list (or empty) selecting the given playback classes — an OR via
`class IN (...)` over the shared CASE. Unknown keys are dropped."""
keep = [c for c in sel if c in PLAYBACK_CLASSES]
return [playback_class_expr().in_(keep)] if keep else []
@dataclass
class LibraryFilters:
"""The shared sidebar filter query-params for /library + /facets, declared once and injected via
@@ -228,6 +257,7 @@ class LibraryFilters:
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
collections: str | None = None # CSV of collection rating_keys
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw
def as_dict(self) -> dict:
return {
@@ -247,6 +277,7 @@ class LibraryFilters:
"keywords": self.keywords,
"collections": self.collections,
"collection_mode": self.collection_mode,
"playable": self.playable,
}
+282 -78
View File
@@ -34,6 +34,7 @@ from app.config import settings
from app.downloads.edit import encode_thread_cap
from app.models import PlexItem
from app.plex import paths
from app.plex.sync import HW_DECODABLE_CODECS
log = logging.getLogger("siftlode.plex")
@@ -96,12 +97,14 @@ class HlsSession:
entry: str,
transcode: bool = False,
hw: bool = False,
tonemap: bool = False,
):
self.key = key
self.dir = directory
self.proc = proc
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
self.hw = hw # transcode ran on the VAAPI hardware encoder (else libx264 software)
self.tonemap = tonemap # HW HDR/10-bit path: OpenCL tonemap between VAAPI decode and encode
self.start_s = start_s # the REQUESTED seek offset
# The REAL media start: with `-ss X -c:v copy` ffmpeg can only cut at the nearest earlier
# keyframe K<=X, and hls.js zero-bases the timeline to K (so video.currentTime=0 is K, not X).
@@ -143,7 +146,7 @@ _HLS_TAIL = [
# --- Transcode (playable="transcode") video re-encode -----------------------------------------
# The remux path stream-copies the video (`-c:v copy`); a non-h264 source can't be copied and must be
# re-encoded to H.264. Two encoders, chosen per-session by `_want_hw`:
# re-encoded to H.264. The encoder + tonemap are chosen per-session by `_select_encode`:
# * VAAPI hardware (`h264_vaapi`) — the prod default once the iGPU is passed in. A full GPU pipeline
# (decode + scale + encode all on-device) offloads the CPU almost entirely, so several concurrent
# 1080p transcodes are fine and the API stays responsive.
@@ -153,24 +156,125 @@ _HLS_TAIL = [
# Everything else — the `-copyts`/`_HLS_TAIL`/segment/seek-restart model and the AAC audio path — is
# unchanged, so hls.js, subtitles, multi-audio and seeking keep working exactly as for remux.
_VAAPI_DEVICE = "/dev/dri/renderD128"
# Where an OpenCL ICD registers its driver. The Intel NEO runtime (intel-opencl-icd) drops an .icd file
# here; its presence is the precondition for the HW HDR→SDR tonemap graph (see _opencl_present).
_OPENCL_ICD_DIR = Path("/etc/OpenCL/vendors")
# Codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for the
# full-GPU VAAPI pipeline. A source outside this set (AV1, MPEG-4 ASP/XviD…) falls back to software even
# when the render node is present, because the GPU can't decode it to feed its own encoder.
_HW_DECODABLE = {"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"}
# full-GPU VAAPI pipeline. A source outside it (AV1, MPEG-4 ASP/XviD…) falls back to software even with
# the render node present, because the GPU can't decode it to feed its own encoder. THE single source is
# plex.sync, so the runtime encoder choice here and the catalog `transcode_hw` facet can't disagree by
# codec; aliased to the old name to keep the call sites below unchanged.
_HW_DECODABLE = HW_DECODABLE_CODECS
# Color-transfer values that mark a true-HDR source (PQ / HLG). A HW-decodable HDR source can't take the
# plain scale_vaapi=format=nv12 path (Gen 9.5 VPP refuses it) — it needs the OpenCL tonemap graph
# (_venc_opts tonemap branch) when the OpenCL runtime is present, else software libx264. A 10-bit source
# WITHOUT one of these transfers is SDR, not HDR, and must not be tone-mapped. See _probe_video_kind.
_HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}
def _hwaccel_in_opts(hw: bool) -> list[str]:
# The three source classes that drive the transcode-pipeline choice (see _probe_video_kind).
_VK_HWSAFE = "hwsafe" # 8-bit SDR — the plain scale_vaapi=format=nv12 GPU path
_VK_HDR = "hdr" # PQ/HLG HDR — needs the OpenCL tonemap graph (or software if no runtime)
_VK_SDR10 = "sdr10" # 10-bit *SDR* — a plain 10→8-bit downconvert, NEVER the HDR tone curve
def _probe_video_kind(src: Path) -> str:
"""Classify the source for the transcode pipeline — one of `_VK_HWSAFE` / `_VK_HDR` / `_VK_SDR10`:
* `_VK_HWSAFE` — 8-bit SDR: safe for the plain VAAPI path (`scale_vaapi=format=nv12` + h264_vaapi).
* `_VK_HDR` — PQ/HLG HDR (any depth): the plain scale path breaks on Gen 9.5 VPP, so it needs the
OpenCL tonemap graph (`tonemap_opencl=hable`) when the runtime is present, else software libx264.
* `_VK_SDR10` — 10-bit but *SDR* (bt709 etc.): NOT HDR. Applying the HDR hable tone curve here would
visibly alter an SDR picture (crushed highlights/midtones) where only a 10→8-bit downconvert was
needed, so it must NOT take the tonemap graph — it goes to software libx264, which CPU-decodes and
downconverts to 8-bit yuv420p exactly. (Kept off the plain scale_vaapi 10-bit path too: that path
is unverified on Gen 9.5 VPP, and software is the known-correct downconvert.)
Ground truth from ffprobe (bit depth via pix_fmt / bits_per_raw_sample, HDR via color_transfer); the
Plex mirror doesn't store these. Returns `_VK_HWSAFE` on any probe failure: the runtime HW→SW fallback
in start_session backstops a HW session that still produces nothing, so a probe miss only costs the
rare wasted first attempt, never a stranded item."""
try:
out = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=pix_fmt,bits_per_raw_sample,color_transfer",
"-of",
"default=noprint_wrappers=1",
str(src),
],
capture_output=True,
text=True,
timeout=10,
)
except (subprocess.SubprocessError, OSError):
return _VK_HWSAFE
pix_fmt = bits = transfer = ""
for line in out.stdout.splitlines():
k, _, v = line.strip().partition("=")
v = v.strip().lower()
if k == "pix_fmt":
pix_fmt = v
elif k == "bits_per_raw_sample":
bits = v
elif k == "color_transfer":
transfer = v
if transfer in _HDR_TRANSFERS:
return _VK_HDR # PQ/HLG — needs a real HDR→SDR tonemap, not a plain downconvert
# >8-bit but not HDR = 10-bit SDR. bits_per_raw_sample OR the pix_fmt bit-depth suffix
# (yuv420p10le, p010le, yuv444p12le…) signals >8-bit even when the other is absent.
if (bits.isdigit() and int(bits) > 8) or re.search(r"(?:10|12|16)(?:le|be)$", pix_fmt):
return _VK_SDR10
return _VK_HWSAFE
def _opencl_present() -> bool:
"""Whether an OpenCL runtime (Intel NEO / intel-opencl-icd) is installed — the precondition for the
HW HDR→SDR tonemap graph. Gen 9.5 VPP can't tonemap natively (tonemap_vaapi fails), so the working
HW path for a 10-bit/HDR source is VAAPI-decode → OpenCL tonemap → VAAPI-encode, which needs an ICD
registered here. Absent on the dev box and until the image bakes NEO in → those sources take
software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present."""
try:
return any(_OPENCL_ICD_DIR.glob("*.icd"))
except OSError:
return False
def _hwaccel_in_opts(hw: bool, tonemap: bool = False) -> list[str]:
"""VAAPI decode options, placed before `-i` so the source is decoded (and kept) on the GPU.
`-hwaccel_output_format vaapi` keeps frames as GPU surfaces all the way to `h264_vaapi` — no
round-trip to system memory. Empty for the software path."""
round-trip to system memory. Empty for the software path.
The tonemap variant additionally derives an OpenCL device FROM the VAAPI one (`opencl=ocl@va`) so
tonemap_opencl can map the decoded surfaces zero-copy (see _venc_opts); it needs named devices, so it
uses `-init_hw_device` + `-hwaccel_device` rather than the plain path's implicit `-vaapi_device`."""
if not hw:
return []
if tonemap:
return [
"-init_hw_device",
f"vaapi=va:{_VAAPI_DEVICE}",
"-init_hw_device",
"opencl=ocl@va",
"-hwaccel",
"vaapi",
"-hwaccel_output_format",
"vaapi",
"-hwaccel_device",
"va",
]
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
def _venc_opts(
transcode: bool,
hw: bool,
tonemap: bool = False,
preset: str = "veryfast",
crf: int = 20,
max_height: int = 0,
@@ -183,6 +287,23 @@ def _venc_opts(
if not transcode:
return ["-c:v", "copy"]
force_kf = ["-force_key_frames", f"expr:gte(t,n_forced*{_SEG_SECONDS})"]
if hw and tonemap:
# HDR/10-bit HW path: Gen 9.5 VPP can't tonemap (scale_vaapi=format=nv12 fails), so the decoded
# VAAPI surfaces are mapped to OpenCL (hwmap=derive_device=opencl), tone-mapped HDR→SDR to 8-bit
# nv12 there (tonemap_opencl, hable curve, bt709 out), then mapped back to VAAPI (reverse=1) for
# h264_vaapi. `-filter_hw_device ocl` tells the filter graph which device to run the OpenCL
# filters on. Full GPU pipeline — ~1 CPU core, several×realtime on the UHD 630 (measured).
return [
"-filter_hw_device",
"ocl",
"-vf",
"hwmap=derive_device=opencl,"
"tonemap_opencl=tonemap=hable:transfer=bt709:matrix=bt709:primaries=bt709:format=nv12,"
"hwmap=derive_device=vaapi:reverse=1",
"-c:v",
"h264_vaapi",
*force_kf,
]
if hw:
# scale_vaapi with format=nv12 downconverts a 10-bit (p010) HEVC source to 8-bit nv12 so
# h264_vaapi (8-bit High) can encode it; for an 8-bit source it's a cheap format pass-through.
@@ -220,20 +341,53 @@ def _render_node_present() -> bool:
return Path(_VAAPI_DEVICE).exists()
def _want_hw(db: DbSession, item: PlexItem, transcode: bool) -> bool:
"""Whether to use the VAAPI hardware encoder for this transcode session, honouring the admin
`plex_transcode_hw` policy: "off" → always software; "on" → VAAPI wherever the render node exists
(trusting the admin, skipping the source-decodability gate); "auto" (default) → VAAPI only when the
render node is present AND the iGPU can hardware-decode the source (else the full-GPU pipeline can't
feed itself → software libx264)."""
def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> tuple[bool, bool]:
"""Pick the transcode video pipeline for this session — returns `(hw, tonemap)`:
* `(False, False)` → software libx264 (the fallback: no render node, non-decodable source, or a
10-bit/HDR source with no OpenCL runtime to tonemap on the GPU).
* `(True, False)` → full-GPU VAAPI (`scale_vaapi=format=nv12` + `h264_vaapi`) — an 8-bit SDR source.
* `(True, True)` → VAAPI decode → OpenCL tonemap → VAAPI encode — a true-HDR (PQ/HLG) source, when
the OpenCL runtime is present (Gen 9.5 VPP can't tonemap natively).
A 10-bit *SDR* source is deliberately NOT a `(True, True)` case: it needs a plain 10→8-bit downconvert,
not the HDR tone curve (which would visibly darken/flatten an SDR picture), so it drops to software.
Honours the admin `plex_transcode_hw` policy: "off" → always software; "on" → HW wherever the render
node exists (trusting the admin, skipping the decodability gate — the runtime HW→SW fallback still
backstops a session that produces nothing — but still routing a true-HDR source through the tonemap
graph so it doesn't hit the scale_vaapi wall); "auto" (default) → HW only when the render node is
present AND the iGPU can hardware-decode the source, with tonemap chosen only for a true-HDR (PQ/HLG)
source that has an OpenCL runtime, while a 10-bit SDR source drops to software."""
if not transcode:
return False
return (False, False)
mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
if mode == "off" or not _render_node_present():
return False
return (False, False)
if mode == "on":
return True
return (item.codec_video or "").lower() in _HW_DECODABLE
# Trust the admin: HW wherever the render node exists, skipping the decodability gate (the runtime
# backstop catches a miss). Only a true-HDR (PQ/HLG) source needs the tonemap graph — a 10-bit SDR
# source takes the plain scale path here, NOT the HDR tone curve. Probe only when OpenCL exists
# (short-circuit), so "on" stays probe-free where tonemap can't apply anyway.
tonemap = _opencl_present() and _probe_video_kind(src) == _VK_HDR
return (True, tonemap)
# auto
if (item.codec_video or "").lower() not in _HW_DECODABLE:
return (False, False)
kind = _probe_video_kind(src)
if kind == _VK_HWSAFE:
return (True, False) # 8-bit SDR → full-GPU scale path
if kind == _VK_HDR:
# PQ/HLG: HW OpenCL tonemap when the runtime is present, else software libx264.
return (True, True) if _opencl_present() else (False, False)
# _VK_SDR10 — 10-bit SDR: a plain 10→8-bit downconvert, done exactly on the CPU. Never the HDR tone
# curve (it would alter an SDR picture), and off the unverified scale_vaapi 10-bit path — software.
return (False, False)
def _want_hw(db: DbSession, item: PlexItem, transcode: bool, src: Path) -> bool:
"""The HW gate on its own (the `hw` half of `_select_encode`). A readability shim for the tests that
assert only whether a session runs on the GPU (production decides via `_select_encode` directly)."""
return _select_encode(db, item, transcode, src)[0]
# CPU-nice increment for the SOFTWARE transcode child. The Plex ffmpeg is a Popen in the long-lived API
@@ -288,15 +442,17 @@ def _ffmpeg_cmd(
audio_langs: list[str] | None = None,
transcode: bool = False,
hw: bool = False,
tonemap: bool = False,
preset: str = "veryfast",
crf: int = 20,
max_height: int = 0,
) -> list[str]:
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the
# hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
# identical to remux.
venc = _venc_opts(transcode, hw, preset, crf, max_height)
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw)]
# identical to remux. `tonemap` is the HDR/10-bit HW variant (OpenCL tonemap between VAAPI decode and
# encode) — it needs matching decode-side (named devices) and encode-side (the OpenCL filter graph) opts.
venc = _venc_opts(transcode, hw, tonemap, preset, crf, max_height)
args = ["ffmpeg", "-nostdin", "-loglevel", "error", *_hwaccel_in_opts(hw, tonemap)]
# Keep ORIGINAL timestamps on the output so the first segment carries the true absolute PTS of the
# keyframe ffmpeg actually seeked to. hls.js still zero-bases playback to that PTS; we read it back
# (see _probe_first_pts) to learn the real content offset. Without -copyts the segment PTS is
@@ -425,67 +581,104 @@ def start_session(
return None
key = item.rating_key
start_s = max(0.0, float(start_s))
hw = _want_hw(db, item, transcode)
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and remux
# ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0
if transcode and not hw:
preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast"
crf = sysconfig.get_int(db, "plex_transcode_crf")
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
audio_langs = _probe_audio_langs(src) if multi else []
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode can't
# starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a platform
# without fork (the API always runs in a Linux container in prod) it's simply omitted.
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
with _lock:
old = _sessions.pop(key, None)
if old is not None:
_kill(old)
tag = "multi" if is_multi else str(audio_ord)
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
shutil.rmtree(directory, ignore_errors=True)
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(
src,
start_s,
directory,
audio_ord,
aoff,
audio_langs if is_multi else None,
transcode=transcode,
hw=hw,
preset=preset,
crf=crf,
max_height=max_height,
),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
preexec_fn=preexec,
)
s = HlsSession(
key,
directory,
proc,
start_s,
"master.m3u8" if is_multi else "index.m3u8",
transcode=transcode,
hw=hw,
)
_sessions[key] = s
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
# at least 1 so playback can't be capped to zero.
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
tag = "multi" if is_multi else str(audio_ord)
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
# Wait for the first video segment, then warm up a few more — all outside the lock (this blocks on
# ffmpeg producing them, and we must not hold up other sessions).
def _seg(i: int) -> Path:
# multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts
return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts")
if wait_for(_seg(0), timeout=25.0):
def _spawn(
hw: bool, tonemap: bool = False, *, replace_only: "HlsSession | None" = None
) -> "HlsSession | None":
# `replace_only` makes the supersede identity-aware for the runtime fallback: the whole
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
# seek-restart) installed between the fallback's decision and here is never clobbered. When the
# registered session is no longer `replace_only`, we abort and return None (the caller yields to
# whoever is registered now). The INITIAL spawn passes replace_only=None and always supersedes —
# that is the seek-restart's intended last-writer-wins.
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0
if transcode and not hw:
preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast"
crf = sysconfig.get_int(db, "plex_transcode_crf")
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode
# can't starve the co-located API/playback. preexec_fn runs in the forked child (POSIX); on a
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
with _lock:
if replace_only is not None and _sessions.get(key) is not replace_only:
return None # superseded/culled while we waited out of lock — don't clobber the newer one
old = _sessions.pop(key, None)
if old is not None:
_kill(old)
shutil.rmtree(directory, ignore_errors=True)
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(
src,
start_s,
directory,
audio_ord,
aoff,
audio_langs if is_multi else None,
transcode=transcode,
hw=hw,
tonemap=tonemap,
preset=preset,
crf=crf,
max_height=max_height,
),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
preexec_fn=preexec,
)
s = HlsSession(
key,
directory,
proc,
start_s,
"master.m3u8" if is_multi else "index.m3u8",
transcode=transcode,
hw=hw,
tonemap=tonemap,
)
_sessions[key] = s
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
# at least 1 so playback can't be capped to zero.
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
return s
s = _spawn(*_select_encode(db, item, transcode, src))
# Wait for the first video segment outside the lock (this blocks on ffmpeg, and we must not hold up
# other sessions). Pass the proc so a HW session that dies at VAAPI init fails fast (not after 25s).
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
if not ready and s.hw:
# We waited on _seg(0) OUTSIDE _lock, so `s.proc` dying is ambiguous: it may have failed on its
# own (VAProfile/init/tonemap refusal), OR a concurrent same-key _spawn (a rapid seek-restart) or
# _enforce_cap may have killed it to install/keep a newer session. Respawn on libx264 (no HW, no
# tonemap) so a genuinely-failed GPU session never strands playback — but pass `replace_only=s` so
# the respawn is a no-op if we've been superseded: the identity check and the pop+replace share
# ONE lock in _spawn, so we can never tear down the newer session. s.hw is now False, so the route
# honestly reports "CPU" and the player's Preparing-stream state stays correct.
log.warning(
"plex transcode: HW session produced no segments — falling back to software key=%s", key
)
respawned = _spawn(False, False, replace_only=s)
if respawned is None:
# Superseded/culled mid-fallback — yield whoever is registered now (a newer seek-restart), or
# our own now-dead session if the key was cap-culled. Never None here: the POST /session route
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
# "Not available yet") the client re-requests.
return current_session(key) or s
s = respawned
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
if ready:
# For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS,
# so the client's absolute clock + subtitle shift key off the true keyframe, not the requested
# offset. On probe failure we keep the provisional requested offset (old behaviour, small drift).
@@ -497,17 +690,18 @@ def start_session(
# at the first segment that isn't ready in time — ffmpeg produces them faster than realtime once
# warm, so a miss means something slow and we proceed rather than block the request further.
for i in range(1, _WARMUP_SEGMENTS):
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S):
if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S, proc=s.proc):
break
log.info(
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s",
"plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s transcode=%s hw=%s tonemap=%s",
key,
start_s,
s.media_start_s,
is_multi,
audio_ord,
transcode,
hw,
s.hw,
s.tonemap,
)
return s
@@ -520,10 +714,14 @@ def current_session(key: str) -> HlsSession | None:
return s
def wait_for(path: Path, timeout: float = 20.0) -> bool:
def wait_for(path: Path, timeout: float = 20.0, proc: subprocess.Popen | None = None) -> bool:
"""Wait until a session file (playlist / segment) exists and is non-empty. Segments are
produced ~faster than realtime, so a segment just ahead of playback appears quickly; a segment
far beyond the generated region won't (the frontend restarts the session at a seek instead)."""
far beyond the generated region won't (the frontend restarts the session at a seek instead).
With `proc` given, also stop as soon as that ffmpeg exits: a VAAPI session that fails to init
(10-bit/HDR VAProfile refusal) dies within ~1s producing nothing, and without this we'd block the
whole `timeout` before the HW→SW fallback could kick in — turning the fix into a stall."""
end = time.time() + timeout
while time.time() < end:
try:
@@ -531,6 +729,12 @@ def wait_for(path: Path, timeout: float = 20.0) -> bool:
return True
except OSError:
pass
if proc is not None and proc.poll() is not None:
# the producer exited — no more segments are coming; one last check then give up.
try:
return path.exists() and path.stat().st_size > 0
except OSError:
return False
time.sleep(0.15)
return path.exists()
+130 -3
View File
@@ -18,7 +18,7 @@ item-detail/player path (P2), not here.
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import sysconfig
@@ -58,6 +58,57 @@ def classify_playable(vcodec: str | None, acodec: str | None, container: str | N
return "direct"
# Video codecs the prod iGPU (UHD 630 / Quick Sync Gen 9.5) can hardware-DECODE — the precondition for
# the full-GPU VAAPI transcode pipeline. THE single source of this set: plex.stream imports it here so
# the runtime encoder choice (`_want_hw`) and this catalog facet can never disagree by codec. A source
# outside it (AV1, MPEG-4 ASP/XviD…) is software-only even on the iGPU (it can't decode it to feed its
# own encoder).
HW_DECODABLE_CODECS = frozenset({"h264", "hevc", "h265", "vp9", "vc1", "mpeg2video"})
# Color-transfer values marking an HDR source (PQ / HLG). A 10-bit/HDR source breaks the VAAPI
# scale=nv12 path on Gen 9.5 (see plex.stream) → software transcode, so it counts as NOT HW-capable.
_HDR_TRANSFERS = frozenset({"smpte2084", "arib-std-b67"})
def transcode_hw_capable(
playable: str | None,
vcodec: str | None,
bit_depth: int | None = None,
hdr: bool = False,
) -> bool | None:
"""The stored `PlexItem.transcode_hw` facet: for a `playable="transcode"` item, whether the prod
iGPU can HARDWARE-transcode it (else software libx264 only); None for a non-transcode item. HW needs
a HW-decodable codec AND an 8-bit SDR source — a 10-bit/HDR source (bit_depth>8 or an HDR transfer)
breaks the VAAPI nv12 pipeline and falls back to software. `bit_depth`/`hdr` are unknown at sync
time (the cheap listing carries no Stream), so they default to 8-bit SDR (codec-only) and the
bit-depth backfill refines a 10-bit/HDR HEVC to False later."""
if playable != "transcode":
return None
if (vcodec or "").lower() not in HW_DECODABLE_CODECS:
return False
if bit_depth is not None and bit_depth > 8:
return False
return not hdr
def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
"""(bit_depth, is_hdr) from a FULL Plex metadata dict's video Stream (Media→Part→Stream, streamType
1). Only the rich metadata call carries Stream; the cheap listing does not — so this is read by the
bit-depth backfill, not the listing sync. (None, False) when absent/unparseable."""
for m in meta.get("Media") or []:
for part in m.get("Part") or []:
for s in part.get("Stream") or []:
if s.get("streamType") != 1: # 1 = video
continue
bd = s.get("bitDepth")
try:
depth = int(bd) if bd else None
except (TypeError, ValueError):
depth = None
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
return depth, hdr
return None, False
def _epoch(v) -> datetime | None:
try:
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
@@ -217,6 +268,13 @@ def sync(db: Session) -> dict:
stats["people_enriched"] = _enrich_people_batch(db, plex)
except Exception as e: # best-effort — never fail the sync over the backfill
log.warning("Plex people enrich batch failed: %s", e)
# Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the
# playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
try:
stats["media_probed"] = _probe_media_batch(db, plex)
except Exception as e: # best-effort — never fail the sync over the backfill
log.warning("Plex media probe batch failed: %s", e)
except PlexNotConfigured as e:
return {"skipped": str(e)}
except PlexError as e:
@@ -236,6 +294,49 @@ def sync(db: Session) -> dict:
# How many titles to rich-fetch per sync run for the cast backfill — bounds the extra Plex load; the
# whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
_ENRICH_BATCH = 60
# Same bounded-per-run pattern for the bit-depth probe. Only HW-decodable-codec transcode items are
# probed (typically a small minority — the bulk of transcodes are old codecs that are SW by codec), so
# a modest batch covers the work-list in a run or two; a stuck item retries next run (watermark unset).
_PROBE_BATCH = 60
def _probe_media_batch(db: Session, plex: PlexClient, limit: int = _PROBE_BATCH) -> int:
"""Rich-fetch up to `limit` not-yet-probed HW-decodable transcode items, read the video Stream's
bit-depth + HDR, and store `video_bit_depth` + the refined `transcode_hw` (a 10-bit/HDR HEVC →
False/software). Stamps `media_probed_at` on every attempt (success OR a Plex miss with no Stream),
so an item is probed once and not re-fetched every sync; a transient fetch FAILURE leaves the
watermark unset to retry. Oldest-id first. Returns how many items were probed this run."""
rows = (
db.execute(
select(PlexItem)
.where(
PlexItem.playable == "transcode",
func.lower(PlexItem.codec_video).in_(HW_DECODABLE_CODECS),
PlexItem.media_probed_at.is_(None),
)
.order_by(PlexItem.id)
.limit(limit)
)
.scalars()
.all()
)
now = datetime.now(timezone.utc)
n = 0
for row in rows:
try:
meta = plex.metadata(row.rating_key) or {}
except Exception as e: # transient — leave media_probed_at NULL so it retries next run
log.debug("Plex media probe fetch failed for %s: %s", row.rating_key, e)
continue
bit_depth, hdr = _video_stream_props(meta)
row.video_bit_depth = bit_depth
row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, bit_depth, hdr)
row.media_probed_at = (
now # probed (even if Plex carried no Stream — don't re-fetch forever)
)
n += 1
db.commit()
return n
def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BATCH) -> int:
@@ -369,15 +470,41 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
row.added_at = _epoch(meta.get("addedAt"))
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
mf = _media_facts(meta)
new_vcodec = mf.get("codec_video")
new_part_key = mf.get("part_key")
# The physical file was replaced when its video codec changes OR — for a SINGLE-version item — when its
# Plex Part key changes (a re-encode / quality upgrade gets a new part id). Either way any probed
# bit-depth is stale, so reset the probe watermark and re-derive transcode_hw from codec alone until the
# backfill re-probes. Codec alone is NOT enough: a same-codec bit-depth re-encode (8-bit→10-bit HEVC)
# keeps the codec `hevc` but flips the HW class, and only the part-key move catches it.
#
# The part-key check is gated on single-version because `_media_facts` reads media[0]/part[0], and for a
# MULTI-version item (an original + a Plex "Optimized" copy) that order isn't guaranteed stable across
# listings — an unguarded part-key compare would then reset+re-probe every sync and oscillate the
# transcode_hw bucket between the two versions. A multi-version same-codec re-encode not self-healing is
# an acceptable rare edge; a per-sync re-probe loop is not.
single_version = len(meta.get("Media") or []) <= 1
codec_changed = (row.codec_video or "").lower() != (new_vcodec or "").lower()
if codec_changed or (single_version and row.part_key != new_part_key):
row.video_bit_depth = None
row.media_probed_at = None
row.file_path = mf.get("file_path")
row.part_key = mf.get("part_key")
row.part_key = new_part_key
row.container = mf.get("container")
row.codec_video = mf.get("codec_video")
row.codec_video = new_vcodec
row.codec_audio = mf.get("codec_audio")
row.size_bytes = mf.get("size_bytes")
row.playable = classify_playable(
mf.get("codec_video"), mf.get("codec_audio"), mf.get("container")
)
# transcode_hw: keep the bit-depth backfill's authoritative value (it folds in bit-depth + HDR) once
# probed; otherwise (re)derive the provisional codec-only class. A non-transcode item is always None,
# even if it had been probed as a transcode before (an audio/container flip to remux without a codec
# change wouldn't reset the watermark, so pin it here).
if row.playable != "transcode":
row.transcode_hw = None
elif row.media_probed_at is None:
row.transcode_hw = transcode_hw_capable("transcode", row.codec_video, row.video_bit_depth)
def _prune_stale(
+7
View File
@@ -18,6 +18,13 @@ class RateLimiter:
self._hits: dict[str, list[float]] = {}
self._lock = threading.Lock()
def reset(self) -> None:
"""Forget every recorded attempt. Each limiter is a module-level singleton, so a test suite
sharing one process would otherwise pool all of its attempts into a single window and
eventually trip an unrelated test with a 429."""
with self._lock:
self._hits.clear()
def allow(self, key: str) -> bool:
"""Record an attempt for ``key`` and return whether it is within the limit.
+394 -19
View File
@@ -1,20 +1,26 @@
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
from collections.abc import Iterable
from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app import audit
from app import email as email_mod
from app import mailtext
from app.audit import AuditAction
from app.auth import (
admin_user,
can_activate,
count_admins,
env_allowed_canonicals,
find_user_by_email,
get_or_create_demo_user,
is_allowed,
operator_contact,
purge_user,
)
@@ -25,13 +31,42 @@ from app.models import (
Invite,
User,
)
from app.routes.auth import revoke_preset_password_if_unverified
from app.utils import iso, valid_email
from app.routes._common import get_or_404
from app.routes.auth import APPROVAL_VERIFY_TTL, issue_verification_link, send_verification_link
from app.utils import canonical_email, iso, valid_email
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _serialize(inv: Invite) -> dict:
def _accounts_for(db: Session, emails: Iterable[str]) -> set[str]:
"""Which of these addresses already have an account, as a set of CANONICAL forms. Batched, so
listing the invites costs one extra query. An invite whose address has an account must not be
deletable: `is_allowed` reads this row, so removing it would silently revoke a live user's access.
Canonical on both sides, because `is_allowed` matches that way: an invite for `x.y@gmail.com`
whose account registered as `xy@gmail.com` would otherwise look account-less, offer the admin a
delete button, and take away a live user's access — precisely the accident this guard exists to
prevent."""
wanted = {canonical_email(e) for e in emails if e}
if not wanted:
return set()
rows = db.execute(
select(User.email_canonical).where(User.email_canonical.in_(wanted))
).scalars()
return {e for e in rows if e}
def _env_allowed(email: str) -> bool:
"""Whether the env bootstrap lists still let this address in. `is_allowed` consults them only
when no Invite row exists, so this is exactly what deleting the row would fall back to — and the
admin has to know that BEFORE they confirm, not from a toast afterwards.
Canonical, because `is_allowed` is: a warning that disagrees with the gate it is warning about is
worse than no warning."""
return canonical_email(email or "") in env_allowed_canonicals()
def _serialize(inv: Invite, *, has_account: bool = False) -> dict:
return {
"id": inv.id,
"email": inv.email,
@@ -40,6 +75,19 @@ def _serialize(inv: Invite) -> dict:
"requested_at": iso(inv.requested_at),
"decided_at": iso(inv.decided_at),
"decided_by": inv.decided_by,
# When (if ever) the admin sent this person an invitation. Adding an address is silent, so
# without this the admin has no way to answer "did I already write to them?" — and the only
# failure mode is writing to a real person twice.
"invited_at": iso(inv.invited_at),
# Drives the delete affordance: an entry backed by an account is not removable here.
"has_account": has_account,
# …and whether removing it would actually take access away, or leave the env allow-list
# granting it anyway (for an ADMIN_EMAILS address, as an admin).
"still_allowed_by_env": _env_allowed(inv.email),
# The language this person asked us in, or null when nobody ever told us (a hand-added
# whitelist row). The invitation dialog preselects it, and falls back to the admin's own UI
# language — a guess, but a visible one the admin can correct before sending.
"lang": inv.lang,
}
@@ -56,18 +104,65 @@ def list_invites(
rows = db.execute(stmt.order_by(Invite.requested_at.desc())).scalars().all()
order = {"pending": 0, "approved": 1, "denied": 2}
rows.sort(key=lambda i: order.get(i.status, 9))
return [_serialize(i) for i in rows]
accounts = _accounts_for(db, (i.email for i in rows))
return [_serialize(i, has_account=canonical_email(i.email) in accounts) for i in rows]
def _welcome_mail_parts(db: Session, email: str) -> tuple[str | None, bool, int]:
"""What a welcome or invitation mail should tell this address to do next: a confirmation link (if
there is an unconfirmed account behind it), whether an account exists at all, and how long the
link lasts.
This is the C option's whole mechanic. In the common ordering — the address is approved and the
person signs up either side of that — they would otherwise have to find and open TWO messages:
one saying they are in, another asking them to confirm. Carrying the link inside the mail that is
already going out makes it one mail and one click.
Resolved through `find_user_by_email`, so an account registered under another spelling of the
same Gmail mailbox is still recognised — and, deliberately, so several candidates resolve to
"no single answer" rather than a guess: an invitation that carried the wrong account's link would
be worse than one that simply pointed at the sign-up page.
"""
hours = int(APPROVAL_VERIFY_TTL.total_seconds() // 3600)
user = find_user_by_email(db, email)
if user is None or user.is_demo:
return None, False, hours
if user.email_verified or not email_mod.email_enabled(db):
return None, True, hours
return issue_verification_link(db, user, APPROVAL_VERIFY_TTL), True, hours
def _activate_user_by_email(db: Session, email: str) -> None:
"""Approving access activates a matching (e.g. pending password-registration) account so
it can sign in. No-op if no such user exists yet (a Google user is created at first login)."""
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
it can sign in. No-op if no such user exists yet (a Google user is created at first login).
E9.5 S1: this path deliberately does NOT revoke a preset password. Revoking here destroyed the
password a legitimate registrant had just chosen and made "your password was cleared for
security" the DEFAULT experience, because approve-before-verify is the normal ordering.
S1 justified that by saying `password_login` refuses an unverified account BEFORE it looks at
`is_active`, so a pre-emptor could not sign in "approved or not". A review of the finished epic
showed that argument only holds while the address stays unverified — and S6's approval mail
hands the real owner a confirmation link precisely so they will end that state. Nothing replaced
the revoke, and that is deliberate: round 2 of the same review tried "the newest registration
owns an unverified account" and reverted it, because the rule is symmetric — an attacker can
always register last — and it handed anonymous callers a write onto a real account's credential.
The owner's recourse is password-reset, which proves the mailbox, rewrites the hash and bumps
the session epoch. The residual is logged as SEC-16. The revoke also stays on the Google-adoption path
(auth.revoke_preset_password_if_unverified), where ownership is actually attested."""
# Canonical, with the literal spelling preferred when both exist: approving
# `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`,
# since Gmail delivers both to one mailbox and the admin decided about the mailbox.
email = email.lower()
candidates = (
db.execute(
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
)
.scalars()
.all()
)
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
if user is not None and not user.is_active:
# SEC-10: same guard as the Google-adoption path — approving an account whose email was never
# verified must not hand a working login to a password set on it by whoever registered the
# address (a pre-emption). Shared single-source helper so the two paths can't drift.
revoke_preset_password_if_unverified(user)
user.is_active = True
db.commit()
@@ -106,8 +201,13 @@ def approve_invite(
) -> dict:
inv = _decide(db, invite_id, admin, approved=True)
_activate_user_by_email(db, inv.email)
background.add_task(email_mod.send_access_approved, inv.email)
return _serialize(inv)
# Computed here, not in the background task: issuing the token needs this request's session, and
# the task must not outlive it. Approval answers a request the person MADE, so unlike the
# invitation below it is sent automatically — a reply is owed, not a nudge.
link, has_account, hours = _welcome_mail_parts(db, inv.email)
lang = mailtext.lang_for_email(db, inv.email)
background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours)
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
@router.post("/invites/{invite_id}/deny")
@@ -120,8 +220,10 @@ def deny_invite(
# Symmetry with approve: the requester hears back either way, instead of a silent limbo. No
# account is activated, so this is an email only (the requester has no in-app inbox yet).
inv = _decide(db, invite_id, admin, approved=False)
background.add_task(email_mod.send_access_denied, inv.email)
return _serialize(inv)
background.add_task(
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
)
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
class AddInviteIn(BaseModel):
@@ -138,7 +240,16 @@ def add_invite(
email = body.email.strip().lower()
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
# Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries
# exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@),
# which is how an admin ends up approving the same human twice and still seeing them bounce.
canon = canonical_email(email)
rows = (
db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id))
.scalars()
.all()
)
inv = next((r for r in rows if r.email == email), None) or next(iter(rows), None)
if inv is None:
inv = Invite(email=email)
db.add(inv)
@@ -150,7 +261,126 @@ def add_invite(
)
db.commit()
_activate_user_by_email(db, email)
return _serialize(inv)
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
class InviteSendIn(BaseModel):
# Which language to write this person in. Optional so an existing caller (and a test) can post
# an empty body, but the admin UI always sends it: this is the one recipient the app has no
# signal about, because the admin typed the address in themselves.
lang: str | None = None
@router.post("/invites/{invite_id}/invite")
def send_invitation(
invite_id: int,
body: InviteSendIn | None = None,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Tell someone their address is whitelisted — only when the admin asks for it.
Whitelisting stays SILENT on purpose. The operator opens a door as a possibility, usually having
already agreed it with the person, and an address can sit approved for weeks with nobody
registering; a mail fired automatically would be nudging someone who never asked to be nudged.
So the send is its own act, on its own button, and `invited_at` records it so the admin can see
they have already written rather than writing twice.
Only an APPROVED row can be invited: there is nothing to invite someone to while their request is
undecided, and inviting a denied address would contradict the decision."""
inv = get_or_404(db, Invite, invite_id, detail="No such invite")
if inv.status != "approved":
raise HTTPException(
status_code=409,
detail={"code": "invite_send", "reason": "not_approved", "email": inv.email},
)
if not email_mod.email_enabled(db):
# Same reasoning as the admin-side verification resend: mail is fail-soft everywhere else,
# but a button the admin pressed must not answer with a success toast for a mail that was
# never sent.
raise HTTPException(status_code=409, detail={"code": "invite_send", "reason": "email_off"})
link, has_account, hours = _welcome_mail_parts(db, inv.email)
# An explicit pick from the dialog is recorded, so a later mail to this address matches the one
# the admin already sent. Only the explicit pick — writing the fallback back would turn a column
# that means "what this person told us" into "what we last guessed".
picked = body.lang if body else None
if picked in mailtext.SUPPORTED:
lang = inv.lang = picked
else:
lang = mailtext.lang_for_email(db, inv.email)
# Sent INLINE, so `invited_at` records the send rather than the intent. Every other mail here is
# fail-soft in the background, which is right when nobody is waiting on it — but this column
# exists to answer "did I already write to them?", and the UI dims the button and warns about a
# second mail on the strength of it. Stamping it before a send that then failed makes the one
# question it answers a lie, and talks the admin out of the retry that would fix it.
#
# One thing a failed send does leave behind: the confirmation token `_welcome_mail_parts` minted
# to render the link, which commits inside `_issue_token`. Accepted rather than chased — the
# token is single-use, expires on its own, and was mailed nowhere; deferring it would mean
# rewriting a helper three callers share so the handler owns its commit.
if not email_mod.send_access_invitation(inv.email, lang, link, has_account, hours):
raise HTTPException(
status_code=422, detail={"code": "invite_send", "reason": "send_failed"}
)
# Only after the mail is actually out. Same as the other two paths that decide for an address
# (`approve_invite`, `add_invite`): an approved row means this mailbox may come in, so finish it,
# or the mail can promise "your account is ready — just sign in" to someone `password_login` will
# refuse for `is_active`. Doing it BEFORE the send would mean a request the UI reports as failed
# had quietly activated an account behind the admin's back.
_activate_user_by_email(db, inv.email)
inv.invited_at = datetime.now(timezone.utc)
audit.record(
db,
admin.id,
AuditAction.INVITE_SEND,
target_type="invite",
target_id=inv.id,
summary=inv.email,
)
db.commit()
return _serialize(inv, has_account=has_account)
@router.delete("/invites/{invite_id}")
def delete_invite(
invite_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Remove a whitelist/request entry outright — the counterpart to add_invite, for an address that
never became an account (an approval added by mistake, or one that is no longer wanted). Deny
records a decision and keeps the row for the audit trail; delete forgets the address. Neither
stops the person asking again: `upsert_pending_invite` reopens a denied row, and a deleted one
simply comes back as a fresh pending request.
Refused once an account exists for the address, whatever its state: this row is the account's
approval gate, and orphaning the account from it would leave a person who can neither sign in nor
be approved. Delete the USER instead — purge_user removes the invite along with it.
Reports `still_allowed_by_env`, because deleting the row is NOT always a revoke: `is_allowed`
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
inv = db.get(Invite, invite_id)
if inv is None:
raise HTTPException(status_code=404, detail="No such invite")
if _accounts_for(db, [inv.email]):
raise HTTPException(
status_code=409,
detail="That address already has an account — delete the account instead.",
)
still_allowed_by_env = _env_allowed(inv.email)
audit.record(
db,
admin.id,
AuditAction.INVITE_DELETE,
target_type="invite",
target_id=inv.id,
summary=inv.email,
)
db.delete(inv)
db.commit()
return {"deleted": invite_id, "still_allowed_by_env": still_allowed_by_env}
# --- Users & roles --------------------------------------------------------------------
@@ -224,7 +454,13 @@ def set_user_role(
target.role = role
db.commit()
if changed:
background.add_task(email_mod.send_role_changed, target.email, role, operator_contact())
background.add_task(
email_mod.send_role_changed,
target.email,
mailtext.lang_for_user(target),
role,
operator_contact(),
)
return _serialize_user(target)
@@ -273,7 +509,12 @@ def set_user_suspended(
target.is_suspended = suspended
db.commit()
if was_suspended and not suspended:
background.add_task(email_mod.send_account_unsuspended, target.email, operator_contact())
background.add_task(
email_mod.send_account_unsuspended,
target.email,
mailtext.lang_for_user(target),
operator_contact(),
)
return _serialize_user(target)
@@ -318,6 +559,140 @@ def admin_delete_user(
# --- Demo account: email whitelist + state reset -------------------------------------
# --- Stuck sign-ups: the admin lifeline (E9.5 S5, roadmap UX-25) ---------------------------------
def _pending_access_rows(db: Session) -> list[User]:
"""Accounts that have signed up but still cannot sign in, newest first.
Two different dead ends share this list, because from the admin's chair they look the same — a
person who registered and is still outside:
· `is_active=False` — approved BEFORE they signed up, so nothing ever landed in the
Access-requests queue and its Approve button doesn't apply (`_decide` only takes a pending
row). This is roadmap UX-25, and it is why the list exists.
· `email_verified=False` — the confirmation mail hasn't been acted on. Not the admin's to
decide, but theirs to help with: on 2026-09-03 the first mail to a fresh Gmail landed in
Spam, so "send it again" is a real move.
A suspended account is deliberately excluded: it cannot sign in either, but that is a decision
already made, not a person stuck waiting on one.
"""
return list(
db.execute(
select(User)
.where(
User.is_demo.is_(False),
User.is_suspended.is_(False),
or_(User.is_active.is_(False), User.email_verified.is_(False)),
)
.order_by(User.created_at.desc())
)
.scalars()
.all()
)
@router.get("/pending-access")
def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
"""The sign-ups that are still outside, with which gate is shut for each. `can_activate` cannot
be derived on the client — it needs the invite/allow-list lookup — so it is answered here.
One `is_allowed` query per row rather than a batched re-implementation of it: the row count is
the admin's own backlog of stuck sign-ups (single digits on any real instance), and a second,
subtly different copy of the "may this address sign in" rule is exactly the drift this codebase
keeps getting bitten by."""
out = []
for u in _pending_access_rows(db):
# The whole rule in one line: this address MAY sign in, and this account still can't. An
# undecided request fails it (nothing is allowed yet) and so does a denied one — both belong
# to the queue above, which owns that decision. Without this, every fresh registration
# appeared in both lists at once, which UAT caught on 2026-09-05.
if not is_allowed(db, u.email):
continue
out.append(_serialize_user(u) | {"can_activate": can_activate(db, u)})
return out
@router.post("/users/{user_id}/activate")
def activate_user(
user_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Let in a registrant whose address was approved before they signed up (roadmap UX-25).
Admin-side ONLY, and that is the whole design. Two earlier attempts at this were reverted after
review, and both moved authority rather than visibility: auto-activating on registration turns a
standing whitelist entry into a bearer grant for whoever registers the address first, and
reopening the approved invite to `pending` lets an anonymous sign-up REVOKE a standing approval.
This endpoint changes nothing about what an unauthenticated caller can cause — the admin could
already do exactly this by re-entering the address in the whitelist box (`add_invite` →
`_activate_user_by_email`). What was missing was any way to know they had to."""
target = get_or_404(db, User, user_id, detail="No such user")
if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be activated.")
if target.is_active:
return _serialize_user(target) # idempotent: a double click is not an error
if not is_allowed(db, target.email):
raise HTTPException(
status_code=409,
detail={"code": "activate", "reason": "not_allowed", "email": target.email},
)
audit.record(
db,
admin.id,
AuditAction.USER_ACTIVATE,
target_type="user",
target_id=target.id,
summary=target.email,
before={"is_active": False},
after={"is_active": True},
)
target.is_active = True
db.commit()
return _serialize_user(target)
@router.post("/users/{user_id}/resend-verification")
def resend_user_verification(
user_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Re-send an account's confirmation link from the admin side.
The public /auth/verify/resend answers uniformly and reveals nothing, which is right for an
anonymous caller but leaves the admin unable to tell a delivered mail from a dropped one. Here
the caller is already authenticated as an admin, so this one says plainly what happened.
Not rate-limited: an admin is not a threat model to enumerate against, and the deliberate silence
of the public endpoint is exactly what this exists to escape."""
target = get_or_404(db, User, user_id, detail="No such user")
if target.is_demo or target.email_verified:
raise HTTPException(
status_code=409,
detail={"code": "resend", "reason": "not_applicable", "email": target.email},
)
if not email_mod.email_enabled(db):
# Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to
# fall back on. An admin pressing a button deserves the failure, not a success toast.
raise HTTPException(status_code=409, detail={"code": "resend", "reason": "email_off"})
if not send_verification_link(db, target):
# Same rule as the invitation above: a button an admin pressed must not answer with a
# success for a mail that was dropped. Mail is fail-soft everywhere else because nobody is
# waiting on the answer; here somebody is.
raise HTTPException(status_code=422, detail={"code": "resend", "reason": "send_failed"})
audit.record(
db,
admin.id,
AuditAction.USER_VERIFY_RESEND,
target_type="user",
target_id=target.id,
summary=target.email,
)
db.commit()
return {"ok": True, "email": target.email}
def _serialize_demo(row: DemoWhitelist) -> dict:
return {
"id": row.id,
+461 -105
View File
@@ -6,6 +6,8 @@ helpers) stays in app/auth.py, imported below."""
import ipaddress
import secrets
from datetime import datetime, timedelta, timezone
from typing import NamedTuple
from urllib.parse import urlencode
from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
@@ -16,6 +18,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app import email as email_mod
from app import mailtext
from app import sysconfig
from app.auth import (
_YOUTUBE_SCOPES,
@@ -25,6 +28,8 @@ from app.auth import (
READ_SCOPES,
WRITE_SCOPES,
current_user,
env_admin_canonicals,
find_user_by_email,
get_or_create_demo_user,
google_enabled,
is_allowed,
@@ -37,9 +42,15 @@ from app.db import SessionLocal, get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.ratelimit import RateLimiter
from app.security import encrypt, hash_password, hash_token, verify_password
from app.utils import valid_email
from app.utils import canonical_email, valid_email
VERIFY_TTL = timedelta(hours=24)
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
# waiting for. Nobody sits refreshing their inbox for an approval — it can easily be read on Monday
# after a weekend, and if it has expired by then the one-mail-one-click promise is spent and they are
# back to two messages. The risk is small: this token only ever confirms an address. It signs nobody
# in and sets no password.
APPROVAL_VERIFY_TTL = timedelta(days=7)
RESET_TTL = timedelta(hours=1)
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
@@ -47,17 +58,27 @@ _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
# Separate from _reset_limiter on purpose: opening a link must not eat the budget for REQUESTING one.
_reset_check_limiter = RateLimiter(max_events=10, window_seconds=300)
_verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300)
_request_access_limiter = RateLimiter(max_events=5, window_seconds=300)
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
def _notify_suspended(background: BackgroundTasks, email: str) -> None:
def _notify_suspended(background: BackgroundTasks, user: User) -> None:
"""Schedule the one-per-hour 'account suspended' email to a user whose (otherwise valid)
sign-in was just blocked. Gated by verified credentials at the call site, so this can't be
triggered by someone who doesn't control the account."""
if _suspend_email_limiter.allow(email):
background.add_task(email_mod.send_account_suspended, email, operator_contact())
triggered by someone who doesn't control the account.
Takes the user rather than the address because the mail needs their language, and the send runs
as a background task — after this request's session, and the row, are out of reach."""
if _suspend_email_limiter.allow(user.email):
background.add_task(
email_mod.send_account_suspended,
user.email,
mailtext.lang_for_user(user),
operator_contact(),
)
_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
@@ -90,18 +111,43 @@ def google_oauth(db: Session):
return _oauth.google
def admin_notify_emails(db: Session) -> list[str]:
"""Who to email about admin events (e.g. a new access request). The ACTUAL admins — every active,
non-suspended `role=admin` user — UNIONED with the env ADMIN_EMAILS bootstrap list. Using the DB
roles means an admin promoted through the UI is notified too (env alone would miss them, yet they
CAN approve requests — the approve UI is role-gated, so the notify set must match); the env union
keeps a freshly-seeded instance working before any DB admin exists."""
db_admins = db.execute(
select(User.email).where(
def admin_notify_groups(db: Session) -> dict[str, list[str]]:
"""Who to email about admin events (e.g. a new access request), grouped by the language each of
them reads. The ACTUAL admins — every active, non-suspended `role=admin` user — UNIONED with the
env ADMIN_EMAILS bootstrap list. Using the DB roles means an admin promoted through the UI is
notified too (env alone would miss them, yet they CAN approve requests — the approve UI is
role-gated, so the notify set must match); the env union keeps a freshly-seeded instance working
before any DB admin exists.
Grouped rather than one blast to everyone, because these recipients are people with their own UI
language: a single message would have to pick one of them and be wrong for the rest. An env
address with no admin account still gets looked up, so an operator listed in ADMIN_EMAILS whose
account is (say) suspended is still written to in the language they chose."""
rows = db.execute(
select(User.email, User.preferences).where(
User.role == "admin", User.is_active.is_(True), User.is_suspended.is_(False)
)
).scalars()
return sorted({e.lower() for e in db_admins if e} | settings.admin_email_set)
).all()
by_email = {e.lower(): mailtext.normalize((p or {}).get("language")) for e, p in rows if e}
for email in settings.admin_email_set:
if email not in by_email:
by_email[email] = mailtext.lang_for_email(db, email)
groups: dict[str, list[str]] = {}
for email in sorted(by_email):
groups.setdefault(by_email[email], []).append(email)
return groups
def notify_admins_of_request(
db: Session, background: BackgroundTasks | None, requester: str
) -> None:
"""Tell every admin that someone is waiting — one mail per language group. Sends inline when
there is no BackgroundTasks to hand (the registration path already runs outside a response)."""
for lang, recipients in admin_notify_groups(db).items():
if background is None:
email_mod.send_admin_new_request(recipients, lang, requester)
else:
background.add_task(email_mod.send_admin_new_request, recipients, lang, requester)
def is_demo_allowed(db: Session, email: str) -> bool:
@@ -147,14 +193,64 @@ def _client_ip(request: Request) -> str:
return peer
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
"""Idempotently record an access request. Returns the Invite if a *new* pending row
was created (so the caller can notify admins), else None for already-known emails."""
class InviteRequest(NamedTuple):
"""What an access request settled on.
`invite` is the row the request now lives under — which is NOT always the address that was just
typed: one Gmail mailbox spelled two ways settles on whichever row already exists, and that row's
address is where mail will go. Callers that tell the user what happened need it (E9.5 S4).
`is_new` is the old boolean return in a name: true only when a row was created or a denied one
reopened, i.e. exactly when admins should hear about it. A repeat request must not re-spam them.
"""
invite: Invite | None
is_new: bool
def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> InviteRequest:
"""Idempotently record an access request, and the language it was made in.
"Already known" is decided on the CANONICAL form: `npeter83.temu@` and `npeter83temu@` are one
Gmail mailbox, and treating them as two is what put a second, silent access request in the admin
queue on 2026-09-03 for an already-approved user. An approved sibling wins — re-requesting must
not reopen a decision that has already been made for that mailbox. The row is still STORED under
the address as typed, because that is where mail has to go.
`lang` is the language of the surface the request came from — the sign-in page, or the locale
Google reported for a refused sign-in. The latest one wins: someone who asks again from a
Hungarian page is telling us something newer than last time. Anything unsupported is ignored
rather than stored, so a forged body cannot put junk in the column.
The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely
on that — `_register_account` persists its own flushed user precisely because a no-op here
commits nothing (E9.5 S6).
"""
email = (email or "").lower()
if not valid_email(email):
return None
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
return InviteRequest(None, False)
lang = lang if lang in mailtext.SUPPORTED else None
canon = canonical_email(email)
rows = (
db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id))
.scalars()
.all()
)
# An approved row wins, so a denied duplicate can't mask an approval. Then a row that is not
# DENIED, preferring the exact spelling — a denied sibling must never be picked while a pending
# row exists, or the reopen branch below would fire and put one person in the admin's queue
# twice. Only when every row is denied does the spelling decide which one reopens.
undecided = [r for r in rows if r.status != "denied"]
inv = (
next((r for r in rows if r.status == "approved"), None)
or next((r for r in undecided if r.email == email), None)
or next(iter(undecided), None)
or next((r for r in rows if r.email == email), None)
or next(iter(rows), None)
)
if inv is not None:
relearned = lang is not None and inv.lang != lang
if relearned:
inv.lang = lang
# Let a previously-denied person ask again; don't disturb approved/pending rows.
if inv.status == "denied":
inv.status = "pending"
@@ -162,12 +258,39 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
inv.decided_at = None
inv.decided_by = None
db.commit()
return inv
return None
inv = Invite(email=email, status="pending")
return InviteRequest(inv, True)
if relearned:
db.commit()
return InviteRequest(inv, False)
inv = Invite(email=email, status="pending", lang=lang)
db.add(inv)
db.commit()
return inv
return InviteRequest(inv, True)
def _access_notice_url(kind: str, email: str = "", inv: Invite | None = None) -> str:
"""The landing URL for a Google sign-in that did not reach an account: `requested` (we recorded
the request) or `denied` (nothing to record).
It rides the FRAGMENT, not the query string, for the same reason the verify/reset tokens do
(SB3): a fragment never reaches the server, a proxy access log or a Referer header — and this
notice now carries the user's address. Naming it is the point of E9.5 S4: on 2026-09-03 a
registration as `npeter83.temu@` met an OAuth claim of `npeter83temu@`, and a screen that said
"request recorded" without saying FOR WHAT hid the mismatch for a day.
`linked` names the address the request actually lives under, and ONLY when that differs from the
one Google just returned — the honest version of "we didn't open a second request". Same
spelling twice is just a repeat, and saying anything there would be noise.
urlencode (not an f-string): a `+tag` address must arrive as %2B, or the SPA's URLSearchParams
would read it back as a space and mangle exactly the address this epic exists to protect.
"""
params = {"access": kind}
if email:
params["email"] = email
if inv is not None and inv.email != email:
params["linked"] = inv.email
return "/#" + urlencode(params)
_DEV_OAUTH_HOSTS = frozenset(
@@ -201,6 +324,12 @@ class EmailIn(BaseModel):
class EmailPasswordIn(BaseModel):
email: str | None = None
password: str | None = None
# The language the sign-up page was in. Captured at registration because it is the ONLY moment
# the choice exists anywhere the server can see: it lives in the browser until an account does,
# and a preference nobody recorded cannot be backfilled later (the lesson `google_email` taught
# in E9.5 S3 — those rows still read NULL until each user signs in again). Nothing reads it yet;
# the bilingual mail templates are E9.5 S7.
lang: str | None = None
class TokenIn(BaseModel):
@@ -283,26 +412,37 @@ async def callback(
# A denied Google login doubles as an access request: record it for the admin and
# land the user on a friendly "request received" screen instead of a raw 403.
if email:
inv = upsert_pending_invite(db, email)
admins = admin_notify_emails(db)
if inv is not None and admins:
background.add_task(email_mod.send_admin_new_request, admins, email)
return RedirectResponse(url="/?access=requested")
return RedirectResponse(url="/?access=denied")
# Google just told us this person's locale — the only language signal that will ever
# exist for an address with no account, and the decision mail is written from it.
req = upsert_pending_invite(db, email, (userinfo.get("locale") or "").split("-")[0])
if req.is_new:
notify_admins_of_request(db, background, email)
return RedirectResponse(url=_access_notice_url("requested", email, req.invite))
return RedirectResponse(url=_access_notice_url("denied"))
user = db.query(User).filter(User.google_sub == sub).one_or_none()
if user is None:
# No account carries this Google identity yet. If one already exists for this email —
# No account carries this Google identity yet. If one already exists for this MAILBOX —
# e.g. an email+password registration — adopt the Google identity onto it instead of
# inserting a duplicate (which would collide on the unique email and 500). Google has
# verified the address, so this safely unifies password + Google sign-in on one account.
existing = db.query(User).filter(User.email == email).one_or_none()
# inserting a duplicate. Google has verified the address, so this safely unifies password +
# Google sign-in on one account.
#
# Matched canonically, and that is not cosmetic: on 2026-09-03 a registration as
# `npeter83.temu@gmail.com` met an OAuth claim of `npeter83temu@gmail.com`, the literal
# lookup found nothing, and prod ended up with TWO active user rows for one Gmail mailbox
# (ids 13 and 14). Exact spelling still wins when both exist, so an account that already
# matches literally is never passed over for a canonical sibling.
canon = canonical_email(email)
candidates = db.query(User).filter(User.email_canonical == canon).order_by(User.id).all()
existing = next((u for u in candidates if u.email == email), None) or next(
iter(candidates), None
)
if existing is not None:
if existing.google_sub is not None and existing.google_sub != sub:
# A different Google identity is already bound to this email — refuse rather than
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
log.warning("Google login email collision (different sub): %s", email)
return RedirectResponse(url="/?access=denied")
return RedirectResponse(url=_access_notice_url("denied"))
if not userinfo.get("email_verified", False):
# Adopting (and activating) a pre-existing password account requires Google to
# actually attest the address — otherwise an unverified Google email that merely
@@ -311,7 +451,7 @@ async def callback(
"Google login: unverified email, refusing to adopt account: %s",
email,
)
return RedirectResponse(url="/?access=denied")
return RedirectResponse(url=_access_notice_url("denied"))
user = existing
user.google_sub = sub
# SEC-10: drop any password pre-set on this account while its email was still unverified,
@@ -328,22 +468,23 @@ async def callback(
# tell the (verified) owner why — a successful Google auth proves they control this account.
if user.is_suspended:
log.info("Suspended account blocked at Google login: %s", email)
_notify_suspended(background, user.email)
_notify_suspended(background, user)
return RedirectResponse(url="/?login=suspended")
# Keep the email in sync with Google, but never overwrite it with one another account already
# owns — that would hit the unique constraint and 500, wedging this user's sign-in (rare: a
# Google-side email change to a value that collides with a different local account).
if user.email != email:
clash = db.query(User).filter(User.email == email, User.id != user.id).one_or_none()
if clash is None:
user.email = email
# Record what Google actually answers with, beside the address the user registered with. The two
# are the same string for most people; where they differ (a deliberate `+tag`, a different dot
# pattern) the UI shows both, so nobody has to guess which one signs them in.
if email:
user.google_email = email
_sync_google_email(db, user, email)
user.display_name = userinfo.get("name")
user.avatar_url = userinfo.get("picture")
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
# routine login must NOT reset it (that would clobber a UI promotion/demotion). Only seed a
# default for a brand-new account (its role attribute is still unset before flush).
if email in settings.admin_email_set:
# Canonical, like every other read of the env lists: a dotted or `+tag`ged ADMIN_EMAILS entry
# would otherwise never bootstrap its admin, because Google answers with the plain primary.
if canonical_email(email) in env_admin_canonicals():
user.role = "admin"
elif not user.role:
user.role = "user"
@@ -355,8 +496,7 @@ async def callback(
# picked one yet); unsupported locales fall back to English.
prefs = dict(user.preferences or {})
if not prefs.get("language"):
loc = (userinfo.get("locale") or "").split("-")[0].lower()
prefs["language"] = loc if loc in {"en", "hu"} else "en"
prefs["language"] = mailtext.normalize((userinfo.get("locale") or "").split("-")[0].lower())
user.preferences = prefs
db.flush()
@@ -375,6 +515,30 @@ async def callback(
return RedirectResponse(url="/")
def _sync_google_email(db: Session, user: User, email: str) -> None:
"""Follow a genuine Google-side email change — and ONLY that.
A different SPELLING of the same mailbox is the user's own choice and must survive. People
register as `me+siftlode@gmail.com` precisely so a later leak from this site is identifiable, and
silently rewriting that to `me@gmail.com` destroys the thing it was for. It also locked them out
of the spelling they chose, because sign-in matches the stored address first. Google returns the
account's PRIMARY address, which need not be what they registered with — and can never contain a
`+` at all — so without this guard every plus-tagged registration lost its tag on the first Google
sign-in. (E9.5 S3 made that reachable by matching adoption across spellings; the rewrite itself is
older.)
Never overwrite with an address another account already owns either: that would hit the unique
constraint and 500, wedging this user's sign-in.
"""
if not email or user.email == email:
return
if canonical_email(user.email) == canonical_email(email):
return # same mailbox, their spelling — leave it alone
clash = db.query(User).filter(User.email == email, User.id != user.id).one_or_none()
if clash is None:
user.email = email
def _store_token(db: Session, user: User, token: dict) -> None:
"""Persist the OAuth grant on the user's single token row, creating it if absent. Google
only returns a refresh_token on (re)consent, so the previous one is kept otherwise.
@@ -435,6 +599,9 @@ def _complete_link(
if user.google_sub is not None and user.google_sub != sub:
return RedirectResponse(url="/?link=mismatch")
user.google_sub = sub
linked_email = (userinfo.get("email") or "").strip().lower()
if linked_email:
user.google_email = linked_email # see the sign-in callback: the other half of the identity
if not user.display_name:
user.display_name = userinfo.get("name")
if not user.avatar_url:
@@ -468,15 +635,23 @@ async def logout(request: Request):
return JSONResponse({"ok": True, "switched": False})
class AccessRequestIn(EmailIn):
# The language of the page this request was made from. Same reasoning as registration's `lang`
# (and the same shape): it is the ONLY moment this preference exists anywhere the server can
# see, since the requester has no account to carry it, and it decides what language the approval
# or denial mail is written in.
lang: str | None = None
@router.post("/request-access")
async def request_access(
body: EmailIn,
body: AccessRequestIn,
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict:
"""Public: ask for access. Idempotent — repeat requests for the same email don't
duplicate or re-spam admins (upsert returns None for an already-pending row).
duplicate or re-spam admins (upsert answers `is_new=False` for an already-pending row).
Answers a uniform ``"pending"`` for EVERY outcome — already-allowed, newly-pending,
already-pending, and rate-limited alike. The old ``"approved"`` reply for an allow-listed
@@ -491,10 +666,9 @@ async def request_access(
return {"status": "pending"}
if is_allowed(db, email):
return {"status": "pending"} # already allowed — don't reveal it
inv = upsert_pending_invite(db, email)
admins = admin_notify_emails(db)
if inv is not None and admins:
background.add_task(email_mod.send_admin_new_request, admins, email)
req = upsert_pending_invite(db, email, body.lang)
if req.is_new:
notify_admins_of_request(db, background, email)
return {"status": "pending"}
@@ -551,6 +725,29 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
return raw
def _peek_token(db: Session, raw: str | None, kind: str) -> User | None:
"""Is this token still usable — WITHOUT spending it. Same four conditions as _consume_token, and
the same "does its user still exist" answer, so the two can never disagree about a given token.
Deliberately NOT the read half of a read-check-write: _consume_token stays one atomic UPDATE, and
this never feeds it. A peek is only ever an early warning — the confirm endpoint remains the
authority, because a token can expire, be spent, or lose its account between the two calls.
"""
if not raw:
return None
row = db.execute(
select(AuthToken).where(
AuthToken.token_hash == hash_token(raw),
AuthToken.kind == kind,
AuthToken.used_at.is_(None),
AuthToken.expires_at >= datetime.now(timezone.utc),
)
).scalar_one_or_none()
# db.get, exactly as _consume_token does: a deleted account cascades its tokens away, so this is
# belt-and-braces — but the two must agree, and agreeing by construction beats agreeing by luck.
return db.get(User, row.user_id) if row is not None else None
def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
"""Validate + burn a token atomically. Returns its user, or None if missing/expired/used/wrong
kind. The burn is a single conditional UPDATE (…WHERE used_at IS NULL…RETURNING) rather than a
@@ -606,17 +803,59 @@ def register(
# session). The create path hashes the password + writes several rows + schedules emails; doing it
# inline would make a NEW email respond measurably slower than an already-registered one (which
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
background.add_task(_register_account, email, password)
background.add_task(_register_account, email, password, body.lang)
return {"status": "ok"}
def _register_account(email: str, password: str) -> None:
def _register_account(email: str, password: str, lang: str | None = None) -> None:
"""Background worker for /register: create the pending account (+ verification email + admin
notice) for a genuinely new email; no-op for an already-registered one. Runs after the response
with its own DB session, so the request's timing never reveals whether the email already exists."""
with SessionLocal() as db:
if db.execute(select(User).where(User.email == email)).scalar_one_or_none() is not None:
return # already registered — nothing to do (the uniform "ok" was already returned)
# Canonical, so a second spelling of one Gmail mailbox cannot open a SECOND account. Prod
# already carries such a pair (user ids 13 and 14, both for npeter83temu@gmail.com); this is
# the end that stops new ones appearing, while the Google callback copes with the existing.
candidates = (
db.execute(
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
)
.scalars()
.all()
)
# Exact spelling wins when a mailbox carries two rows, same as every other canonical lookup
# in this epic (`find_user_by_email`, `_activate_user_by_email`, the Google callback). Without
# it `.first()` handed back whichever row Postgres felt like, so a re-registration of the
# dotted spelling could resolve to the undotted, already-confirmed twin and send nothing.
existing = next((u for u in candidates if u.email == email), None) or next(
iter(candidates), None
)
if existing is not None:
# Already registered. This used to return in silence, and that silence is the most
# likely explanation for the 2026-09-03 report that a confirmation mail "never arrived":
# nothing was ever SENT. The person re-registered, got the same cheerful "check your
# inbox", and waited for a message that did not exist.
#
# Re-sending the link instead leaks nothing new. The response and its timing are
# unchanged (uniform "ok", decided off the response path), the mail goes to the address
# ON THE ACCOUNT, and this is exactly what the public /auth/verify/resend already does
# for the same input — so it is not a new capability, just the end of a dead end. An
# account that is already confirmed still gets nothing: there is nothing to confirm.
#
# And a re-send is ALL it does: the submitted password is discarded. E9.5's closing
# review tried "while the address is unverified, the newest registration owns it"
# (replace the hash, kill the outstanding verify tokens) as a fix for address
# pre-emption, and round 2 showed it was worse than the problem — DO NOT REPEAT IT.
# The rule is symmetric, and an attacker can always be last: it hands an unauthenticated
# caller the power to overwrite a real registrant's chosen password and delete the
# confirmation link already in their inbox, and on a SEC-10-stripped row (password_hash
# NULL, password_revoked_at set) it would install a password where the whole point was
# that there is none. The owner's real recourse already exists and needs no new
# primitive: password-reset accepts this account, proves the mailbox, rewrites the hash
# and bumps the session epoch. The residual is logged as SEC-16.
user = existing
if not user.email_verified and not user.is_demo and email_mod.email_enabled():
send_verification_link(db, user)
return
# Without working SMTP we can't deliver a verification link, so email ownership can't gate
# sign-in. Admin approval stays the real gate (is_active=False); mark the account verified so
# the flow still completes on a no-SMTP self-host. See email.email_enabled().
@@ -626,6 +865,8 @@ def _register_account(email: str, password: str) -> None:
password_hash=hash_password(password),
is_active=False,
email_verified=not email_ok,
# Only a supported code is stored, so a forged body can't put junk in preferences.
preferences={"language": lang} if lang in mailtext.SUPPORTED else {},
)
db.add(user)
try:
@@ -636,28 +877,81 @@ def _register_account(email: str, password: str) -> None:
# no-op as the pre-check hit — roll back and stop.
db.rollback()
return
upsert_pending_invite(db, email) # admin-approval gate (commits)
upsert_pending_invite(db, email) # admin-approval gate
# Persist the account ourselves. upsert_pending_invite commits only when it actually writes an
# invite row — not when this address already has a pending or approved one — and _issue_token
# below only runs with SMTP on. So for an address the admin had already approved (or one whose
# request was still pending), on an instance with no working SMTP, NOTHING along this path
# committed and the flushed user was silently rolled back when the session closed: the
# would-be user got the uniform "ok" and no account, and re-registering did the same again.
# Commit unconditionally; it is a no-op when upsert already committed the same transaction.
db.commit()
if email_ok:
raw = _issue_token(db, user, "verify", VERIFY_TTL)
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer (SB3).
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
admins = admin_notify_emails(db)
if admins:
email_mod.send_admin_new_request(admins, email)
send_verification_link(db, user)
# Fired unconditionally, unlike the other two callers which gate on a fresh invite row: for
# an address the admin already approved, nothing lands in the Access-requests queue, so this
# is the only place the account's existence is announced at all. Delivery is a separate
# matter — with no working SMTP `_send` drops it, which is why the pre-approved registrant
# still needs the admin to notice them (roadmap UX-25).
notify_admins_of_request(db, None, email)
def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str:
"""A fresh confirmation link for `user`, returned rather than sent — for a mail that carries the
link inside a larger message (the branching approval/invitation mails, E9.5 S6).
Same token machinery and the same fragment (SB3) as the standalone confirmation mail, so the two
kinds of link cannot drift apart in shape or in how they are consumed."""
raw = _issue_token(db, user, "verify", ttl or VERIFY_TTL)
return f"{_app_base()}/#verify={raw}"
def send_verification_link(db: Session, user: User) -> bool:
"""Issue a fresh verification token for `user` and mail them the link.
Always to the address ON THE ACCOUNT, never a spelling typed into some form: the account's
address is the one its owner chose to be reachable at, and a `+tag` there is usually deliberate.
The token rides the URL FRAGMENT (#) so it can never reach a proxy/access log or a Referer (SB3).
One definition, three callers — registration, the self-service resend, and the admin-side resend
(E9.5 S5). The link's shape and TTL must not drift between them; a verification mail that differs
by who triggered it is a bug waiting to be discovered by exactly one user.
Returns whether the mail actually went out, so a caller with a human waiting on the answer (the
admin-side resend) can say so instead of reporting success for a message that was dropped.
"""
return email_mod.send_verify_email(
user.email,
mailtext.lang_for_user(user),
issue_verification_link(db, user),
hours=int(VERIFY_TTL.total_seconds() // 3600),
)
@router.post("/verify")
def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
"""Confirm an email-verification token the SPA read from the URL fragment (SB3). Uniform 400
on any invalid/expired/used token."""
on any invalid/expired/used token.
`password_reset_required` tells the SPA to send the user straight to the reset flow: SEC-10 had
dropped the password this account registered with, so sign-in would answer with the deliberately
uniform "Invalid email or password" — true, but unreadable to the person who just proved they own
the mailbox. Saying so leaks nothing: holding a valid single-use verify token for this account
already proves that much. Since E9.5 S1 the common source of that state is gone (admin approval no
longer revokes), leaving accounts stripped before that change and the Google-adoption path, so this
flag is rare now — but it must stay, because those users have no other way back in."""
user = _consume_token(db, body.token, "verify")
if user is None:
raise HTTPException(
status_code=400, detail="This verification link is invalid or has expired."
)
raise HTTPException(status_code=400, detail={"code": "verify", "reason": "invalid"})
user.email_verified = True
needs_password = user.password_hash is None and user.password_revoked_at is not None
email = user.email
db.commit()
return {"ok": True}
# Naming the address lets the SPA say WHICH mailbox it just confirmed — the same reasoning as
# password_reset_required above: holding a valid single-use verify token for this account already
# proves that much, so echoing it back leaks nothing. It matters in a browser that holds a session
# for a different account, where an unqualified "email confirmed" would be actively misleading.
return {"ok": True, "password_reset_required": needs_password, "email": email}
@router.post("/verify/resend")
@@ -687,22 +981,36 @@ def _resend_verify_if_eligible(email: str) -> None:
if not email_mod.email_enabled():
return
with SessionLocal() as db:
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
user = find_user_by_email(db, email)
if user is not None and not user.email_verified and not user.is_demo:
raw = _issue_token(db, user, "verify", VERIFY_TTL)
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
send_verification_link(db, user)
@router.get("/verify")
def verify_email(token: str, db: Session = Depends(get_db)):
"""Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the
fragment + POST flow above; this bounces to a friendly status for older links until they expire."""
user = _consume_token(db, token, "verify")
if user is None:
return RedirectResponse(url="/?verify=invalid")
user.email_verified = True
db.commit()
return RedirectResponse(url="/?verify=ok")
def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException:
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
shape so the SPA can say it in the user's own language (the old bare English strings were
unreadable under a bilingual UI, and unusable as anything but a one-line error).
The account-shaped reasons carry the address ON THE ACCOUNT plus both gate flags. That is what
turns a flat 403 into an honest screen — it names the mailbox and says which of the two gates is
still shut. It is not an enumeration leak: this branch is only reachable once the password has
been proven, so the caller already knows the account exists. The two reasons that answer BEFORE
that proof (`invalid`, `rate_limited`) carry nothing at all, so they stay byte-identical for a
real account and an unknown one.
Since E9.5 S1 the two gates are order-independent, so `approved` really can be true while
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
rather than assuming a sequence.
"""
detail: dict = {"code": "login", "reason": reason}
if user is not None:
detail |= {
"email": user.email,
"email_verified": user.email_verified,
"approved": user.is_active,
}
detail |= {k: v for k, v in extra.items() if v is not None}
return HTTPException(status_code=status, detail=detail)
@router.post("/password-login")
@@ -716,29 +1024,23 @@ def password_login(
Once the password is proven correct, the owner gets a specific reason if their account is
still pending or suspended — that's not an enumeration leak (they already hold the password)."""
if not _login_limiter.allow(_client_ip(request)):
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
raise _login_refused(429, "rate_limited")
email = (body.email or "").strip().lower()
password = body.password or ""
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
user = find_user_by_email(db, email)
# Always run the KDF (against a decoy hash for an unknown / passwordless / demo account) so the
# response time is the same whether or not the email exists — no timing-based enumeration.
candidate_hash = user.password_hash if (user and not user.is_demo) else None
password_ok = verify_password(password, candidate_hash or _DECOY_PASSWORD_HASH)
if user is None or user.is_demo or not password_ok:
raise HTTPException(status_code=401, detail="Invalid email or password.")
raise _login_refused(401, "invalid")
if user.is_suspended:
_notify_suspended(background, user.email)
op = operator_contact()
detail = "Your account has been suspended."
if op:
detail += f" If you have questions, contact the operator at {op}."
raise HTTPException(status_code=403, detail=detail)
_notify_suspended(background, user)
raise _login_refused(403, "suspended", user, operator=operator_contact() or None)
if not user.email_verified:
raise HTTPException(
status_code=403, detail="Please verify your email first (check your inbox)."
)
raise _login_refused(403, "email_unverified", user)
if not user.is_active:
raise HTTPException(status_code=403, detail="Your account is awaiting admin approval.")
raise _login_refused(403, "pending_approval", user)
_establish_session(request, user)
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
return {"ok": True}
@@ -767,12 +1069,43 @@ def _send_reset_if_eligible(email: str) -> None:
"""Background worker for password_reset_request: issue a reset token + email the link, but ONLY
for a real (non-demo) password account. Runs after the response with its own DB session, so the
request's timing never reveals whether the account exists. Token rides the URL FRAGMENT (#) so it
can't leak into proxy/access logs or a Referer (SB3)."""
can't leak into proxy/access logs or a Referer (SB3).
"Password account" is `password_hash OR password_revoked_at` — an account SEC-10 stripped had a
password and must be able to get a new one, or it is locked out permanently (no password to sign in
with, and formerly no reset link either). A Google-only account that never had a password stays
ineligible on purpose: its owner adds one from Settings (/auth/set-password) while signed in, which
proves identity through the session rather than by mail."""
with SessionLocal() as db:
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is not None and user.password_hash and not user.is_demo:
user = find_user_by_email(db, email)
if user is None or user.is_demo:
return
if user.password_hash or user.password_revoked_at:
raw = _issue_token(db, user, "reset", RESET_TTL)
email_mod.send_password_reset(email, f"{_app_base()}/#reset={raw}")
# The account's own address, as above — never the spelling that was typed into the form.
email_mod.send_password_reset(
user.email, mailtext.lang_for_user(user), f"{_app_base()}/#reset={raw}"
)
@router.post("/password-reset/check")
def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(get_db)) -> dict:
"""Is the reset link the SPA just opened still good? Answers without spending the token.
Why this exists: the card used to render "set a new password" purely because a `#reset=` fragment
was present, never asking whether it could still succeed. Reported on 2026-09-05 after deleting an
account and then opening its reset link — the user types a new password into a form that answers
400. Opening a dead reset link is not an edge case: the token lives one hour, mail is slow or lands
in spam, and whoever got two mails usually clicks the older one.
Not an enumeration oracle: probing requires already holding a 43-character random, and the answer
is exactly what submitting it would reveal. Throttled per IP anyway — and a throttled caller is
told `true`, never `false`: this check may only ever save a user a wasted form, so when it cannot
answer it must degrade to the old behaviour instead of declaring a live link dead.
"""
if not _reset_check_limiter.allow(_client_ip(request)):
return {"ok": True}
return {"ok": _peek_token(db, body.token, "reset") is not None}
@router.post("/password-reset/confirm")
@@ -783,12 +1116,13 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
if len(new_password) < PASSWORD_MIN_LEN:
raise HTTPException(
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN},
)
user = _consume_token(db, token, "reset")
if user is None:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"})
user.password_hash = hash_password(new_password)
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
# email — this rescues an account that registered but never clicked the verification link (it
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
@@ -828,12 +1162,33 @@ def bump_session_epoch(user: User) -> None:
def revoke_preset_password_if_unverified(user: User) -> None:
"""SEC-10 (account pre-emption): a password set on an account whose email was NEVER verified is
untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. When
ownership is finally attested (Google adoption, or admin approval), drop that password and bump the
epoch to revoke any session it minted; the owner re-sets one via password-reset. No-op for an
already-verified account, so a legit no-SMTP registration is unaffected. The single source for this
rule — both the Google-callback and admin-activation paths call it so they can't drift."""
the real owner attests ownership through Google, drop that password and bump the epoch to revoke any
session it minted; the owner re-sets one via password-reset. No-op for an already-verified account,
so a legit no-SMTP registration is unaffected.
The Google-adoption callback is the ONLY caller (E9.5 S1). Admin approval used to call this too, and
dropping it was right for the reason S1 gave — the revoke destroyed the password a legitimate
registrant had just chosen, and since the admin normally decides before the user reads their mail,
that made the "your password was cleared" detour the DEFAULT path rather than an edge case.
S1 also claimed the revoke was REDUNDANT there, because `password_login` refuses an unverified
account before it reads `is_active`. That part was wrong, and the epic's own review caught it: the
gate holds only while the address is unverified, and S6's approval mail exists to get the real
owner to end that state — at which point a pre-emptor's password works. Nothing replaced the
revoke, and that is deliberate. The review tried "the newest registration owns an unverified
account" and reverted it: the rule is symmetric, so an attacker can simply register last, and it
would have handed anonymous callers a write onto a real account's credential. What the owner
actually has is password-reset, which proves the mailbox, rewrites the hash and bumps the epoch.
The residual is logged as SEC-16; re-adding a caller here would put "your password was cleared"
back in front of every legitimate registrant, which is the whole reason S1 removed it.
`password_revoked_at` is what makes "the owner re-sets one via password-reset" actually true. The
revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and
_send_reset_if_eligible keyed off exactly that, so a revoked account silently got no reset link and
was locked out for good (it could not sign in either). The stamp separates the two cases."""
if not user.email_verified:
user.password_hash = None
user.password_revoked_at = datetime.now(timezone.utc)
bump_session_epoch(user)
@@ -903,6 +1258,7 @@ def set_password(
if not verify_password(body.current_password or "", user.password_hash):
raise HTTPException(status_code=403, detail="Current password is incorrect.")
user.password_hash = hash_password(new_password)
user.password_revoked_at = None # same as the reset path: a password exists again
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
# the person making the change stays signed in here. Commit BEFORE re-stamping so a failed commit
# can't leave the cookie at a newer epoch than the DB (which would wrongly 401 THIS session).
+2 -1
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app import audit
from app import email as email_mod
from app import mailtext
from app import sysconfig
from app.audit import AuditAction
from app.auth import admin_user
@@ -115,7 +116,7 @@ def send_test_email(
lets the admin verify the settings actually work."""
if not email_mod.email_enabled():
raise HTTPException(status_code=400, detail="SMTP is not configured.")
sent = email_mod.send_test(user.email)
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
if not sent:
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.")
return {"sent": True, "to": user.email}
+22
View File
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from app.auth import (
count_admins,
count_pending_activations,
current_user,
google_enabled,
has_read_scope,
@@ -13,6 +14,7 @@ from app.auth import (
optional_current_user,
purge_user,
)
from app import email as email_mod
from app import sysconfig
from app.db import get_db
from app.models import Invite, User
@@ -98,11 +100,21 @@ def get_me(
if user is None:
return {"authenticated": False}
pending_invites = 0
pending_activations = 0
# Admin-only, because it drives an operator warning: email is fail-soft, so a half-filled SMTP
# config costs verification links, password resets and access-request notices with nothing
# visible anywhere. Surfacing it here puts the warning on the screens where onboarding happens.
email_configured = True
if user.role == "admin":
pending_invites = (
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
or 0
)
# Counted separately from pending_invites rather than folded into it: the two are different
# facts (a request awaiting a decision vs a decided address whose sign-up never got let in),
# and quietly widening one number would make an existing field mean something new.
pending_activations = count_pending_activations(db)
email_configured = email_mod.email_enabled(db) # this request's session; no second connect
return {
"authenticated": True,
"id": user.id,
@@ -112,6 +124,12 @@ def get_me(
"role": user.role,
"is_demo": user.is_demo,
"has_google": user.google_sub is not None,
# The address Google answers with, when it DIFFERS from the account's own. Same string for
# most people, and then this is None so no surface has to decide whether to show it — the
# comparison lives here, once, instead of in every consumer.
"google_email": (
user.google_email if user.google_email and user.google_email != user.email else None
),
"has_password": user.password_hash is not None,
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
# YouTube-access affordances and the onboarding nudge when it isn't configured).
@@ -121,6 +139,10 @@ def get_me(
"can_read": has_read_scope(user),
"can_write": has_write_scope(user),
"pending_invites": pending_invites,
# Sign-ups an admin still has to let in (roadmap UX-25): approved BEFORE they registered, so
# nothing ever reached the Access-requests queue and the badge used to read zero.
"pending_activations": pending_activations,
"email_configured": email_configured,
"preferences": user.preferences or {},
}
+42
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import (
Boolean,
Integer,
String,
and_,
@@ -27,6 +28,7 @@ from app.plex import rollup
from app.plex.cards import episode_card
from app.plex.query import (
FIELD_COLUMNS,
PLAYBACK_CLASSES,
TS_CONFIG,
LibraryFilters,
apply_meta_filters,
@@ -36,6 +38,8 @@ from app.plex.query import (
genres_col,
meta_filter_conds,
movie_status_case,
playable_conds,
playback_class_expr,
show_status_case,
split_csv,
summary_col,
@@ -135,6 +139,7 @@ def unified_library(
PlexItem.originally_available_at.label("rel"),
PlexItem.duration_s.label("duration_s"),
PlexItem.playable.label("playable"),
PlexItem.transcode_hw.label("transcode_hw"),
cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
@@ -156,6 +161,8 @@ def unified_library(
mq = mq.where(PlexItem.duration_s >= f.duration_min)
if f.duration_max is not None:
mq = mq.where(PlexItem.duration_s <= f.duration_max)
# Playback-class filter (movie-only — a show is an aggregate with no single class).
mq = mq.where(*playable_conds(split_csv(f.playable)))
if tsq is not None:
mq = mq.where(vector_match(PlexItem, lang, tsq))
if recheck is not None:
@@ -179,6 +186,7 @@ def unified_library(
PlexShow.originally_available_at.label("rel"),
cast(null(), Integer).label("duration_s"),
cast(null(), String).label("playable"),
cast(null(), Boolean).label("transcode_hw"),
PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"),
s_status.label("status"),
@@ -237,6 +245,7 @@ def unified_library(
"duration_seconds": r.duration_s,
"thumb": f"/api/plex/image/{r.rk}",
"playable": r.playable,
"transcode_hw": r.transcode_hw,
"status": r.status,
"position_seconds": r.position_seconds,
}
@@ -312,6 +321,7 @@ def facets(
"genres": [],
"content_ratings": [],
"studios": [],
"playable": [],
"year_min": None,
"year_max": None,
"rating_max": None,
@@ -338,6 +348,9 @@ def facets(
query = query.where(model.duration_s >= pp["duration_min"])
if with_dur and pp.get("duration_max") is not None:
query = query.where(model.duration_s <= pp["duration_max"])
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
st = aliased(PlexState)
@@ -356,6 +369,7 @@ def facets(
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
studio_counts: dict[str, int] = {}
playable_counts: dict[str, int] = {}
years: list[int] = []
ratings: list[float] = []
durs: list[int] = []
@@ -420,6 +434,23 @@ def facets(
)
for s, c in db.execute(stq).all():
studio_counts[s] = studio_counts.get(s, 0) + c
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
if kind_movie:
pcls = playback_class_expr()
pcq = (
wsq(
select(pcls.label("cls"), func.count()),
model,
ids,
{**p, "playable": None},
kind_movie,
True,
)
.where(pcls.isnot(None))
.group_by(pcls)
)
for cls, c in db.execute(pcq).all():
playable_counts[cls] = playable_counts.get(cls, 0) + c
# Year bounds — exclude the year filter.
by = db.execute(
wsq(
@@ -477,6 +508,11 @@ def facets(
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
for s in split_csv(f.studios):
studio_counts.setdefault(s, 0)
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
for c in split_csv(f.playable):
if c in PLAYBACK_CLASSES:
playable_counts.setdefault(c, 0)
return {
"genres": [
{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])
@@ -488,6 +524,12 @@ def facets(
{"value": s, "count": c}
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
],
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
"playable": [
{"value": c, "count": playable_counts[c]}
for c in PLAYBACK_CLASSES
if c in playable_counts
],
"year_min": min(years) if years else None,
"year_max": max(years) if years else None,
"rating_max": max(ratings) if ratings else None,
+4
View File
@@ -411,6 +411,10 @@ def item_detail(
"year": it.year,
"duration_seconds": it.duration_s,
"playable": it.playable,
# Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows.
"transcode_hw": it.transcode_hw,
"codec_video": it.codec_video,
"video_bit_depth": it.video_bit_depth,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": f"/api/plex/image/{it.rating_key}?variant=art",
"library": _lib_key(db, it.library_id),
+4 -2
View File
@@ -554,14 +554,16 @@ def stream_session(
raise HTTPException(status_code=404, detail="Media file not found on disk")
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
# `transcode`/`hw` let the player show an honest cold-start ("Preparing stream…") and surface the
# active mode + encoder in the stats overlay; both segment streams are otherwise identical.
# `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and
# surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both
# segment streams are otherwise identical.
return {
"mode": "hls",
"start_s": s.media_start_s,
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
"transcode": s.transcode,
"hw": s.hw,
"tonemap": s.tonemap,
}
+5 -1
View File
@@ -14,6 +14,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app import email as email_mod
from app import mailtext
from app import state, sysconfig
from app.auth import PASSWORD_MIN_LEN
from app.db import get_db
@@ -103,6 +104,9 @@ def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_set
class SetupTestEmailIn(BaseModel):
to: str = ""
# The wizard's page language. No account exists yet on a fresh instance, so this is the only
# thing that can say what language to write in; anything unsupported falls back to English.
lang: str | None = None
@router.post("/test-email")
@@ -111,7 +115,7 @@ def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup
to = body.to.strip()
if "@" not in to:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if not email_mod.send_test(to):
if not email_mod.send_test(to, mailtext.normalize(body.lang)):
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.")
return {"ok": True}
+33
View File
@@ -22,6 +22,39 @@ def valid_email(value: str | None) -> bool:
return bool(value and _EMAIL_RE.match(value))
# Gmail (and its googlemail.com alias) delivers to one mailbox regardless of dots in the local part
# and of any `+tag`; nobody else is guaranteed to. So the canonical form below strips those for
# Gmail only, and merely lowercases everything else.
_GMAIL_DOMAINS = {"gmail.com", "googlemail.com"}
def canonical_email(value: str | None) -> str:
"""The form used to decide whether two addresses are the SAME MAILBOX. Never used for sending —
mail always goes to the address as the user typed it.
Why this exists (observed on prod, 2026-09-03): someone registered as `npeter83.temu@gmail.com`
and was approved, but Google's OAuth `email` claim came back as the account's stored primary,
`npeter83temu@gmail.com`. The invite gate only lowercased, found no approved row for that literal
string, and turned an approved user's sign-in into a silent SECOND access request — plus a second
User row. The `+tag` case is worse and fails every time: a Google account cannot contain `+`, so
OAuth can only ever return the base address, meaning anyone who registers with
`user+siftlode@gmail.com` (a common throwaway habit) is guaranteed to be denied after approval.
Scope is deliberately narrow. The dot rule does NOT hold for Google Workspace custom domains
unless the admin turned it on, and dots are significant on most providers, so anything outside
gmail.com/googlemail.com is only lowercased. Approving one Gmail spelling always did mean
approving that mailbox, so collapsing them is the honest reading of an invite, not a widening
of it.
"""
email = (value or "").strip().lower()
local, sep, domain = email.partition("@")
if not sep or domain not in _GMAIL_DOMAINS:
return email
local = local.partition("+")[0].replace(".", "")
# Normalise the alias domain too, so googlemail and gmail spellings meet.
return f"{local}@gmail.com" if local else email
def now_utc() -> datetime:
"""Current time as a timezone-aware UTC datetime."""
return datetime.now(timezone.utc)
+21
View File
@@ -189,3 +189,24 @@ def as_user(db, override_dep, _configured):
return TestClient(app, headers={"x-test-user": str(user.id)})
return _as
@pytest.fixture(autouse=True)
def _reset_rate_limiters():
"""Every RateLimiter is a module-level singleton keyed by client IP, and TestClient always
presents the same host — so without this the whole suite's login/register attempts pool into one
300s window and, past the cap, some unrelated test starts failing with a 429 depending on order.
Cleared before each test; a test that wants to exercise throttling can still exhaust one itself.
Sweeps every loaded `app.*` module rather than a hand-kept list, so a limiter added in a new
module is covered the day it lands instead of silently pooling until something fails."""
import sys
from app.ratelimit import RateLimiter
for name, mod in list(sys.modules.items()):
if name != "app" and not name.startswith("app."):
continue
for obj in vars(mod).values():
if isinstance(obj, RateLimiter):
obj.reset()
+170
View File
@@ -0,0 +1,170 @@
"""Deleting a whitelist / access-request entry (admin).
Deny records an auditable decision and keeps the row; delete forgets the address outright, for an
approval added by mistake or one that is no longer wanted. Neither stops the person asking again —
`upsert_pending_invite` reopens a denied row as pending, and a deleted one comes back as a fresh
request. The one hard rule is that an entry backed by an account is NOT deletable here: this row is
that account's approval gate, and orphaning it would strand someone who could then neither sign in
nor be approved.
"""
from sqlalchemy import select
from app.models import Invite, User
def _invites(client):
r = client.get("/api/admin/invites")
assert r.status_code == 200
return {i["email"]: i for i in r.json()}
def test_admin_deletes_a_whitelist_entry_that_never_became_an_account(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="mistake@example.com", status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
listed = _invites(client)["mistake@example.com"]
assert listed["has_account"] is False # drives the delete affordance in the UI
r = client.delete(f"/api/admin/invites/{listed['id']}")
assert r.status_code == 200 and r.json()["deleted"] == listed["id"]
db.expire_all()
assert (
db.execute(select(Invite).where(Invite.email == "mistake@example.com")).scalar_one_or_none()
is None
)
def test_deleting_an_entry_that_has_an_account_is_refused(db, make_user, as_user):
# The guard that matters: this row is what is_allowed() reads, so deleting it would revoke the
# user's access without touching the account or saying so.
admin = make_user(email="admin@example.com", role="admin")
member = make_user(email="member@example.com")
db.add(Invite(email=member.email, status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
listed = _invites(client)[member.email]
assert listed["has_account"] is True
r = client.delete(f"/api/admin/invites/{listed['id']}")
assert r.status_code == 409
db.expire_all()
assert (
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none()
is not None
)
def test_a_pending_request_from_a_registration_is_not_deletable(db, make_user, as_user):
# The shape the ordinary sign-up produces: _register_account writes the User (inactive) AND the
# pending Invite together. The invite is that account's approval gate, so deleting it would
# strand the person — they could neither sign in nor be approved, and re-registering is a silent
# no-op. Managed from Users & roles instead, which is what the UI and the 409 both say.
admin = make_user(email="admin@example.com", role="admin")
make_user(email="signup@example.com", is_active=False, email_verified=False)
db.add(Invite(email="signup@example.com", status="pending"))
db.commit()
client = as_user(admin)
listed = _invites(client)["signup@example.com"]
assert listed["has_account"] is True # the UI shows a disabled control, not a missing one
r = client.delete(f"/api/admin/invites/{listed['id']}")
assert r.status_code == 409
db.expire_all()
assert (
db.execute(select(Invite).where(Invite.email == "signup@example.com")).scalar_one_or_none()
is not None
)
def test_deleting_an_env_allow_listed_address_reports_that_it_kept_access(
db, make_user, as_user, monkeypatch
):
# Removing the row is NOT always a revoke: is_allowed falls back to the env bootstrap lists once
# no invite exists. The admin has to be told, or they think they removed access and did not.
from app.config import settings
# Patch the raw env field, not the derived property: that keeps the split/strip/lower parsing
# under test, and matches how the rest of the suite stubs settings.
monkeypatch.setattr(settings, "allowed_emails", " OPS@Example.com , someone@else.test ")
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="ops@example.com", status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
listed = _invites(client)["ops@example.com"]
# The LIST carries it too, so the confirm dialog can warn before the delete happens.
assert listed["still_allowed_by_env"] is True
r = client.delete(f"/api/admin/invites/{listed['id']}")
assert r.status_code == 200
assert r.json()["still_allowed_by_env"] is True
def test_an_admin_emails_address_is_also_reported(db, make_user, as_user, monkeypatch):
# ADMIN_EMAILS is the sharper case: deleting the row not only keeps access, the next Google
# sign-in re-promotes the address to admin.
from app.config import settings
monkeypatch.setattr(settings, "admin_emails", "boss@example.com")
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="boss@example.com", status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
assert _invites(client)["boss@example.com"]["still_allowed_by_env"] is True
def test_deleting_a_pending_request_forgets_it(db, make_user, as_user):
# A pending row is deletable too — "drop this without recording a decision" is a different
# choice from Deny, which stores a decision (status, decided_by) and mails the requester.
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="asker@example.com", status="pending"))
db.commit()
client = as_user(admin)
r = client.delete(f"/api/admin/invites/{_invites(client)['asker@example.com']['id']}")
assert r.status_code == 200
assert r.json()["still_allowed_by_env"] is False # nothing in the env allow-lists
assert "asker@example.com" not in _invites(client)
def test_deleting_a_missing_invite_is_404(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
assert as_user(admin).delete("/api/admin/invites/9999").status_code == 404
def test_a_regular_user_cannot_delete_an_invite(db, make_user, as_user):
make_user(email="admin@example.com", role="admin")
plain = make_user(email="plain@example.com")
db.add(Invite(email="mistake@example.com", status="approved"))
db.commit()
inv_id = db.execute(select(Invite.id).where(Invite.email == "mistake@example.com")).scalar_one()
assert as_user(plain).delete(f"/api/admin/invites/{inv_id}").status_code == 403
db.expire_all()
assert db.get(Invite, inv_id) is not None
def test_deleting_the_user_also_clears_the_way_to_re_invite(db, make_user, as_user):
# The refusal above tells the admin to delete the account instead; purge_user takes the invite
# with it, so the address really is forgotten and a fresh request starts over as pending.
admin = make_user(email="admin@example.com", role="admin")
member = make_user(email="member@example.com")
db.add(Invite(email=member.email, status="approved", decided_by=admin.email))
db.commit()
client = as_user(admin)
assert client.delete(f"/api/admin/users/{member.id}").status_code == 200
db.expire_all()
assert db.execute(select(User).where(User.email == member.email)).scalar_one_or_none() is None
assert (
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none() is None
)
+203
View File
@@ -0,0 +1,203 @@
"""The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25), DB-lane.
Approve an address BEFORE the person registers and their sign-up produces no queue row at all —
`_decide` only takes a still-pending invite — so the Access-requests screen stayed empty while they
sat locked out, and the badge read zero. The only working move was re-typing the address into the
whitelist box, which nobody would guess.
Two earlier attempts at this were reverted after review, and both moved AUTHORITY rather than
visibility: auto-activating on registration, and reopening the approved invite to pending. So the
tests here pin the boundary as much as the feature — an admin click, gated on an address that is
already allowed, and nothing an unauthenticated caller can reach.
"""
from sqlalchemy import select
from app.models import AuditLog, AuthToken, Invite, User
_PW = "correct horse battery"
def _pending_access(client):
r = client.get("/api/admin/pending-access")
assert r.status_code == 200
return {u["email"]: u for u in r.json()}
def _stuck_registrant(db, make_user, admin):
"""The UX-25 shape: the admin approved the address first, the account arrived afterwards."""
db.add(Invite(email="later@example.com", status="approved", decided_by=admin.email))
db.commit()
return make_user(email="later@example.com", password=_PW, is_active=False)
def test_a_pre_approved_registrant_is_listed_and_offers_activation(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
_stuck_registrant(db, make_user, admin)
row = _pending_access(as_user(admin))["later@example.com"]
assert row["is_active"] is False
assert (
row["can_activate"] is True
) # the address is already allowed — the click just finishes it
def test_an_ordinary_registrant_awaiting_a_decision_is_not_listed_twice(db, make_user, as_user):
# They already have a pending invite with Approve on it in the queue above, and their address is
# not allowed YET — the decision is the queue's to own. Caught in UAT on 2026-09-05: an earlier
# filter keyed on "unconfirmed" put every fresh registration in both lists at once.
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="normal@example.com", status="pending"))
db.commit()
make_user(email="normal@example.com", password=_PW, is_active=False)
assert "normal@example.com" not in _pending_access(as_user(admin))
def test_a_fresh_registration_stays_in_the_queue_and_out_of_this_list(db, make_user, as_user):
# The realistic version of the case above: a brand-new sign-up is inactive AND unconfirmed. It
# still belongs only to the queue — the admin's next act is the decision, not a re-send.
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="fresh@example.com", status="pending"))
db.commit()
make_user(email="fresh@example.com", password=_PW, is_active=False, email_verified=False)
assert "fresh@example.com" not in _pending_access(as_user(admin))
def test_activating_lets_them_in_and_is_recorded(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
stuck = _stuck_registrant(db, make_user, admin)
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/activate")
assert r.status_code == 200 and r.json()["is_active"] is True
db.expire_all()
assert db.get(User, stuck.id).is_active is True
actions = db.execute(select(AuditLog.action)).scalars().all()
assert (
"user_activate" in actions
) # the admin acted on someone's access; it is not a silent read
def test_activating_an_address_that_is_not_allowed_is_refused(db, make_user, as_user):
# The guard that keeps this button from manufacturing an inconsistent account: password sign-in
# only checks is_active and would let them in, while their Google sign-in would be turned away
# and silently reopened as a fresh access request. Whitelisting is the move that says "let them
# in"; this one only finishes a decision already made.
admin = make_user(email="admin@example.com", role="admin")
nobody = make_user(email="nobody@example.com", password=_PW, is_active=False)
r = as_user(admin).post(f"/api/admin/users/{nobody.id}/activate")
assert r.status_code == 409
assert r.json()["detail"]["reason"] == "not_allowed"
db.expire_all()
assert db.get(User, nobody.id).is_active is False
def test_activating_twice_is_not_an_error(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
stuck = _stuck_registrant(db, make_user, admin)
client = as_user(admin)
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
assert client.post(f"/api/admin/users/{stuck.id}/activate").status_code == 200
def test_a_suspended_account_is_not_offered_as_stuck(db, make_user, as_user):
# It cannot sign in either, but that is a decision already made — not a person waiting on one.
admin = make_user(email="admin@example.com", role="admin")
db.add(Invite(email="banned@example.com", status="approved", decided_by=admin.email))
db.commit()
make_user(email="banned@example.com", password=_PW, is_active=False, is_suspended=True)
assert "banned@example.com" not in _pending_access(as_user(admin))
# --- the confirmation resend ---------------------------------------------------------------------
def test_an_unconfirmed_account_is_listed_and_can_be_sent_a_fresh_link(
db, make_user, as_user, monkeypatch
):
from app.routes import admin as admin_routes
from app.routes import auth as auth_routes
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: True)
sent: list[tuple[str, str]] = []
monkeypatch.setattr(
auth_routes.email_mod,
"send_verify_email",
lambda to, lang, link, **kw: sent.append((to, link)) is None,
)
admin = make_user(email="admin@example.com", role="admin")
# Active but unconfirmed — a real state since E9.5 S1 made the two gates order-independent. It is
# not the admin's to decide, but the mail may simply never have arrived, so it is theirs to help
# with. The approved invite is what puts them on this screen at all: the list is "the address may
# sign in, this account still can't", so an undecided request stays with the queue that owns it.
db.add(Invite(email="unconfirmed@example.com", status="approved", decided_by=admin.email))
db.commit()
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
assert "unconfirmed@example.com" in _pending_access(as_user(admin))
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
assert r.status_code == 200 and r.json()["email"] == "unconfirmed@example.com"
assert [to for to, _ in sent] == ["unconfirmed@example.com"]
tokens = db.execute(select(AuthToken).where(AuthToken.user_id == u.id)).scalars().all()
assert [t.kind for t in tokens] == ["verify"]
def test_the_resend_says_so_when_the_instance_cannot_send_mail(db, make_user, as_user, monkeypatch):
from app.routes import admin as admin_routes
monkeypatch.setattr(admin_routes.email_mod, "email_enabled", lambda db=None: False)
admin = make_user(email="admin@example.com", role="admin")
u = make_user(email="unconfirmed@example.com", password=_PW, email_verified=False)
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
# Mail is fail-soft everywhere else, which is exactly what left the 0.78.5 sign-ups with nothing
# to fall back on. An admin pressing a button gets the failure, not a success toast.
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
def test_resending_to_an_already_confirmed_account_is_refused(db, make_user, as_user):
admin = make_user(email="admin@example.com", role="admin")
u = make_user(email="fine@example.com", password=_PW)
r = as_user(admin).post(f"/api/admin/users/{u.id}/resend-verification")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_applicable"
# --- the badge -----------------------------------------------------------------------------------
def test_the_badge_counts_stuck_sign_ups_but_not_ones_the_queue_already_shows(
db, make_user, client
):
# Signed in for real, not via the as_user override: /api/me resolves its user through
# optional_current_user, which that override does not cover (see test_me_email_flag).
admin = make_user(email="admin@example.com", password=_PW, role="admin")
_stuck_registrant(db, make_user, admin) # approved first → counts
db.add(Invite(email="normal@example.com", status="pending")) # already in the queue → doesn't
db.commit()
make_user(email="normal@example.com", password=_PW, is_active=False)
make_user(email="unconfirmed@example.com", password=_PW, email_verified=False) # not ours
assert (
client.post(
"/auth/password-login", json={"email": admin.email, "password": _PW}
).status_code
== 200
)
me = client.get("/api/me").json()
assert me["authenticated"] is True
# An unconfirmed account waits on its OWNER, not on the admin — listing it is help, badging it
# would be nagging about something the admin did not cause.
assert me["pending_activations"] == 1
assert me["pending_invites"] == 1 # unchanged meaning: still just the undecided requests
+442 -14
View File
@@ -97,28 +97,85 @@ def test_reset_confirm_is_single_use_and_kills_existing_sessions(client, make_us
assert replay.status_code == 400
def test_activating_an_unverified_account_drops_its_preset_password(db, make_user):
# SEC-10 (account pre-emption): an attacker registers victim@x with a password before the approved
# owner arrives. The address was never verified, so admin approval must NOT hand that pre-set
# password a working login — it is dropped and any session it minted is revoked.
def test_approving_an_unverified_account_keeps_its_password(db, make_user):
# E9.5 S1, inverting an earlier rule. Approval used to drop a password set while the email was
# unverified (SEC-10, account pre-emption). It was dropped because it destroyed the password a
# legitimate registrant had just chosen, in what is the NORMAL ordering — the admin decides
# before the user reads their mail — making "your password was cleared for security" the default
# experience.
#
# S1 ALSO argued the revoke was redundant, because password_login refuses an unverified account
# before it reads is_active. The epic's closing review refuted that: the gate holds only while
# the address is unverified, and S6's approval mail exists to get the real owner to end that
# state. Do not restore that reasoning from this comment — the residual is SEC-16, and round 2
# of the review tried and reverted a guard for it (see test_review_round.py).
from app.routes.admin import _activate_user_by_email
victim = make_user(
email="victim@example.com",
password="attacker-chosen",
user = make_user(
email="approved-first@example.com",
password="chosen at registration",
email_verified=False,
is_active=False,
)
epoch0 = victim.session_epoch or 0
_activate_user_by_email(db, "victim@example.com")
assert victim.is_active is True
assert victim.password_hash is None
assert (victim.session_epoch or 0) == epoch0 + 1
epoch0 = user.session_epoch or 0
_activate_user_by_email(db, "approved-first@example.com")
assert user.is_active is True
assert user.password_hash is not None
assert user.password_revoked_at is None
assert (user.session_epoch or 0) == epoch0 # nothing to revoke, so no epoch bump either
def test_an_approved_but_unverified_account_still_cannot_sign_in(client, make_user):
# The gate that stops a pre-emptor BEFORE the address is confirmed, whatever the admin decided.
# It is not the whole story — confirming the address opens it, which is SEC-16 — but it is the
# invariant this test owns, and a change that let an unverified account sign in would be a
# straightforwardly worse bug than the residual.
make_user(
email="notyet@example.com",
password="attacker-chosen",
email_verified=False,
is_active=True,
)
r = _login(client, "notyet@example.com", "attacker-chosen")
assert r.status_code == 403
assert r.json()["detail"]["reason"] == "email_unverified"
def test_verifying_afterwards_lets_the_original_password_in(client, db, make_user):
# The payoff for the legitimate registrant: the password chosen at registration still works once
# the mailbox is proven — no "your password was cleared for security" detour, no reset round-trip.
from app.routes.auth import _issue_token
user = make_user(
email="ordering@example.com",
password="chosen at registration",
email_verified=False,
is_active=False,
)
from app.routes.admin import _activate_user_by_email
_activate_user_by_email(db, "ordering@example.com") # admin decides FIRST — the common ordering
raw = _issue_token(db, user, "verify", timedelta(hours=1))
db.commit()
verified = client.post("/auth/verify", json={"token": raw})
assert verified.status_code == 200
assert verified.json() == {
"ok": True,
"password_reset_required": False,
"email": "ordering@example.com",
}
assert _login(client, "ordering@example.com", "chosen at registration").status_code == 200
def test_activating_a_verified_account_keeps_its_password(db, make_user):
# A legit account whose email IS verified (e.g. a no-SMTP registration) keeps its password on
# approval — the guard must not break the normal flow.
# The no-SMTP shape: registration marks such an account verified up front, so it was never in
# scope for the SEC-10 guard even before E9.5 S1. Kept as the other half of the pair.
from app.routes.admin import _activate_user_by_email
user = make_user(
@@ -127,3 +184,374 @@ def test_activating_a_verified_account_keeps_its_password(db, make_user):
_activate_user_by_email(db, "legit@example.com")
assert user.is_active is True
assert user.password_hash is not None
def _register_and_fetch(db, email: str):
"""Run the /register background worker, then read the users/invites rows back through a FRESH
query. The worker owns its own session, so anything it failed to commit is simply not there."""
from sqlalchemy import select as _select
from app.models import Invite, User
from app.routes.auth import _register_account
_register_account(email, _PW)
db.expire_all()
user = db.execute(_select(User).where(User.email == email)).scalar_one_or_none()
invite = db.execute(_select(Invite).where(Invite.email == email)).scalar_one_or_none()
return user, invite
def _no_smtp(monkeypatch):
"""Put the worker on the no-SMTP path and capture the admin notice instead of sending it."""
from app.routes import auth as auth_mod
notified: list[str] = []
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: False)
monkeypatch.setattr(
auth_mod.email_mod,
"send_admin_new_request",
lambda admins, lang, to: notified.append(to) or True,
)
return notified
def test_registration_of_a_pre_approved_email_persists_the_account_without_smtp(
db, make_user, monkeypatch
):
# THE regression. An admin whitelists an address, the person registers, and the account silently
# never existed: upsert_pending_invite leaves an already-approved row alone and returns WITHOUT
# committing, and the only other commit on this path (_issue_token) is gated on SMTP — so with no
# working mail the flushed user was rolled back at session close. The sign-up answered "ok",
# created nothing, and re-registering did the same again.
from app.models import Invite
notified = _no_smtp(monkeypatch)
make_user(email="admin@example.com", role="admin")
db.add(Invite(email="roland@example.com", status="approved", decided_by="admin@example.com"))
db.commit()
user, invite = _register_and_fetch(db, "roland@example.com")
assert user is not None, "pre-approved registration was rolled back — the account is gone"
assert user.password_hash is not None
assert user.email_verified is True # no SMTP => ownership can't be proven by link
assert (
user.is_active is False
) # approval is bound to the account by an admin, not by signing up
# The admin's standing decision is left exactly as it was — is_allowed() still reads "approved",
# so their Google sign-in keeps working and an anonymous sign-up cannot revoke access.
assert invite.status == "approved"
assert invite.decided_by == "admin@example.com"
# The notify path still fires for an already-approved address — nothing lands in the queue, so
# it is the only announcement there is. (Whether it is DELIVERED is separate: on a real no-SMTP
# instance `_send` drops it, which is the open half of the problem, roadmap UX-25.)
assert notified == ["roland@example.com"]
def test_registration_with_an_already_pending_request_persists_the_account(
db, make_user, monkeypatch
):
# The same rollback, reached the other way: someone uses Request access, then registers before
# the admin has decided. upsert_pending_invite leaves the pending row alone and does not commit.
from app.models import Invite
notified = _no_smtp(monkeypatch)
make_user(email="admin@example.com", role="admin")
db.add(Invite(email="pia@example.com", status="pending"))
db.commit()
user, invite = _register_and_fetch(db, "pia@example.com")
assert user is not None, "registration was rolled back — the account is gone"
assert user.is_active is False
assert invite.status == "pending"
assert notified == ["pia@example.com"]
def test_an_admin_can_activate_a_pre_approved_registrant(db, monkeypatch):
# The recovery path for the account above, pinned so it cannot quietly break: re-entering the
# address in the Access-requests whitelist box (POST /api/admin/invites) runs
# _activate_user_by_email.
from app.models import Invite
from app.routes.admin import _activate_user_by_email
_no_smtp(monkeypatch)
db.add(Invite(email="roland@example.com", status="approved", decided_by="admin@example.com"))
db.commit()
user, _ = _register_and_fetch(db, "roland@example.com")
_activate_user_by_email(db, "roland@example.com")
db.expire_all()
assert user.is_active is True
assert user.password_hash is not None # approval never touches the password (E9.5 S1)
def test_registration_of_an_unknown_email_still_persists_account_and_pending_invite(
db, make_user, monkeypatch
):
# Guard, not a regression test: this branch always worked (upsert INSERTs the invite and commits
# the same transaction). It must keep working — and it is the case that DOES notify the admin.
notified = _no_smtp(monkeypatch)
make_user(email="admin@example.com", role="admin")
user, invite = _register_and_fetch(db, "stranger@example.com")
assert user is not None
assert user.is_active is False
assert invite is not None and invite.status == "pending"
assert notified == ["stranger@example.com"]
def test_registration_of_a_previously_denied_email_revives_the_request(db, make_user, monkeypatch):
# Guard: a denied person may ask again — the invite flips back to pending (and commits) so the
# admin sees a fresh request. The stale decision must not leak into the new account's state.
from app.models import Invite
notified = _no_smtp(monkeypatch)
make_user(email="admin@example.com", role="admin")
db.add(Invite(email="denis@example.com", status="denied", decided_by="admin@example.com"))
db.commit()
user, invite = _register_and_fetch(db, "denis@example.com")
assert user is not None
assert user.is_active is False
assert invite.status == "pending"
assert invite.decided_at is None and invite.decided_by is None
assert notified == ["denis@example.com"]
def test_registration_of_a_pre_approved_email_persists_the_account_with_smtp(db, monkeypatch):
# Guard, not a regression test: with SMTP on, _issue_token's own commit already persisted the
# user, so this branch was never broken and must stay unbroken.
from app.models import Invite
from app.routes import auth as auth_mod
sent: list[str] = []
monkeypatch.setattr(auth_mod.email_mod, "email_enabled", lambda: True)
monkeypatch.setattr(
auth_mod.email_mod, "send_verify_email", lambda to, lang, link, **kw: sent.append(to)
)
monkeypatch.setattr(auth_mod.email_mod, "send_admin_new_request", lambda admins, lang, to: True)
db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com"))
db.commit()
user, invite = _register_and_fetch(db, "rita@example.com")
assert user is not None
assert user.email_verified is False # must confirm the address
assert user.is_active is False
assert invite.status == "approved"
assert sent == ["rita@example.com"]
def test_verifying_an_email_never_grants_access_on_its_own(client, db, make_user):
# Verification attests the address; it does not decide access. The two gates stay separate.
from app.models import Invite
from app.routes.auth import _issue_token
user = make_user(email="rita@example.com", password=_PW, email_verified=False, is_active=False)
db.add(Invite(email="rita@example.com", status="approved", decided_by="admin@example.com"))
db.commit()
raw = _issue_token(db, user, "verify", timedelta(hours=1))
assert client.post("/auth/verify", json={"token": raw}).status_code == 200
db.expire_all()
assert user.email_verified is True
assert user.is_active is False
assert _login(client, "rita@example.com", _PW).status_code == 403
def test_register_route_creates_the_account_end_to_end(client, db, monkeypatch):
# The tests above call _register_account directly, so nothing covered the ROUTE: its
# allow_registration gate, its rate limiter and the background.add_task wiring that makes the
# whole flow reachable at all. TestClient runs BackgroundTasks after the response, so a single
# POST exercises the real path end to end.
from sqlalchemy import select as _select
from app.models import User
_no_smtp(monkeypatch)
r = client.post("/auth/register", json={"email": "newbie@example.com", "password": _PW})
assert r.status_code == 200 and r.json() == {"status": "ok"}
db.expire_all()
user = db.execute(_select(User).where(User.email == "newbie@example.com")).scalar_one_or_none()
assert user is not None, "POST /auth/register answered ok but created nothing"
assert user.password_hash is not None
def test_register_route_is_refused_when_registration_is_closed(client, db, monkeypatch):
# The gate is an explicit 403 with nothing created — not a uniform "ok" that silently drops it.
from sqlalchemy import select as _select
from app import sysconfig
from app.models import User
_no_smtp(monkeypatch)
sysconfig.set_value(db, "allow_registration", False)
r = client.post("/auth/register", json={"email": "closed@example.com", "password": _PW})
assert r.status_code == 403
db.expire_all()
assert (
db.execute(_select(User).where(User.email == "closed@example.com")).scalar_one_or_none()
is None
)
# --- SEC-10 lockout: a revoked preset password must stay RECOVERABLE ---------------------------
#
# Regression pack for the prod incident of 2026-09-02. An account registered with a password, then
# stripped of it by the SEC-10 guard, ended up with password_hash = NULL. That is also what a
# Google-only account looks like, and reset eligibility keyed off `password_hash` alone — so the
# account could neither sign in (no password) nor obtain a reset link, and every response on the way
# was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two apart;
# these pin each step of the way back in.
#
# E9.5 S1 removed the admin-approval caller, so Google adoption is the only path that still produces
# this state — plus every account stripped on prod before that change, which is why the whole recovery
# route below must keep working. These tests manufacture the state through the surviving producer.
def _revoke_as_google_adoption(db, user) -> None:
"""Put an account into the SEC-10-revoked state the way the ONLY remaining producer does: the
Google callback adopting an account whose email was never verified."""
from app.routes.auth import revoke_preset_password_if_unverified
revoke_preset_password_if_unverified(user)
db.commit()
def _capture_reset_links(monkeypatch) -> list[str]:
"""Patch the reset mailer and collect the recipients it is asked to write to."""
from app.routes import auth as auth_mod
sent: list[str] = []
monkeypatch.setattr(
auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)
)
return sent
def test_the_google_adoption_revoke_stamps_password_revoked_at(db, make_user):
# The guard still fires where it earns its keep: the real owner has just proven ownership through
# Google, so a password set on the address while it was unverified must not survive.
victim = make_user(
email="stamped@example.com",
password="attacker-chosen",
email_verified=False,
is_active=False,
)
assert victim.password_revoked_at is None
epoch0 = victim.session_epoch or 0
_revoke_as_google_adoption(db, victim)
assert victim.password_hash is None
assert victim.password_revoked_at is not None # …so the reset flow can tell it HAD a password
assert (victim.session_epoch or 0) == epoch0 + 1 # any session that password minted is killed
def test_an_account_whose_password_was_revoked_can_still_get_a_reset_link(
db, make_user, monkeypatch
):
# THE lockout bug: without the stamp this sent nothing, and the account was unrecoverable.
from app.routes.auth import _send_reset_if_eligible
victim = make_user(
email="lockedout@example.com",
password="chosen at registration",
email_verified=False,
is_active=False,
)
_revoke_as_google_adoption(db, victim)
sent = _capture_reset_links(monkeypatch)
_send_reset_if_eligible("lockedout@example.com")
assert sent == ["lockedout@example.com"]
def test_a_google_only_account_is_still_not_reset_eligible(db, make_user, monkeypatch):
# The other side of the rule: an account that NEVER had a password gets no reset link — its owner
# adds one from Settings while signed in (/auth/set-password), which proves identity by session.
from app.routes.auth import _send_reset_if_eligible
make_user(email="googler@example.com", password=None, google_sub="sub-123")
sent = _capture_reset_links(monkeypatch)
_send_reset_if_eligible("googler@example.com")
assert sent == []
def test_completing_a_reset_clears_the_revocation_stamp(client, db, make_user):
from app.routes.auth import _issue_token
user = make_user(
email="recovered@example.com",
password="chosen at registration",
email_verified=False,
is_active=True, # already approved; reset-confirm verifies the email but never activates
)
_revoke_as_google_adoption(db, user)
raw = _issue_token(db, user, "reset", timedelta(hours=1))
db.commit()
ok = client.post(
"/auth/password-reset/confirm", json={"token": raw, "password": "a brand new one"}
)
assert ok.status_code == 200
db.expire_all()
assert user.password_hash is not None
assert user.password_revoked_at is None # spent — not a permanently reset-eligible account
assert _login(client, "recovered@example.com", "a brand new one").status_code == 200
def test_verify_tells_a_revoked_account_that_it_needs_a_new_password(client, db, make_user):
# The banner's input. Saying so leaks nothing: the caller just proved mailbox control by holding a
# valid single-use verify token. Without it the user meets only the uniform "Invalid email or
# password" and has no way to learn why the password they chose stopped working.
from app.routes.auth import _issue_token
user = make_user(
email="needspw@example.com",
password="chosen at registration",
email_verified=False,
is_active=False,
)
_revoke_as_google_adoption(db, user)
raw = _issue_token(db, user, "verify", timedelta(hours=1))
db.commit()
r = client.post("/auth/verify", json={"token": raw})
assert r.status_code == 200
assert r.json() == {
"ok": True,
"password_reset_required": True,
"email": "needspw@example.com",
}
def test_verify_reports_no_reset_needed_for_an_intact_account(client, db, make_user):
from app.routes.auth import _issue_token
user = make_user(email="intact@example.com", email_verified=False, is_active=False)
raw = _issue_token(db, user, "verify", timedelta(hours=1))
db.commit()
r = client.post("/auth/verify", json={"token": raw})
assert r.status_code == 200
assert r.json() == {
"ok": True,
"password_reset_required": False,
"email": "intact@example.com",
}
+1 -1
View File
@@ -144,7 +144,7 @@ class TestCodecsFromStreams:
class TestRunFfprobeJson:
"""The single ffprobe-JSON invocation point (shared by probe_codecs + upload._probe). A failed
"""The single ffprobe-JSON invocation point (shared by probe_codecs + probe.probe_media). A failed
probe or unparseable output must degrade to None, not raise — the malformed-input safety the old
parse_probe_streams string parser used to own now lives here."""
+149
View File
@@ -0,0 +1,149 @@
"""An undelivered email leaves a trace in the admins' inbox.
Sending is deliberately fail-soft — a missing SMTP setting or a rejecting relay logs and returns
False so nothing user-facing breaks. The cost of that used to be total silence: an instance whose
SMTP was never filled in swallowed verification links, password resets and access-request notices,
and the operator's first clue was somebody saying nothing arrived.
"""
import smtplib
import pytest
from sqlalchemy import select
from app import email as email_mod
from app.models import Notification
@pytest.fixture(autouse=True)
def _no_smtp_config(monkeypatch):
"""Every test here drives the real `_send`, so make sure it never reaches a socket."""
monkeypatch.setattr(
email_mod,
"_smtp",
lambda: {"host": "", "port": 587, "user": "", "password": "", "from": ""},
)
def _notifications(db, user_id: int) -> list[Notification]:
return list(
db.execute(
select(Notification)
.where(Notification.user_id == user_id, Notification.type == "email_undelivered")
.order_by(Notification.id)
)
.scalars()
.all()
)
def test_a_skipped_email_notifies_every_admin(db, make_user):
admin_a = make_user(email="admin-a@example.com", role="admin")
admin_b = make_user(email="admin-b@example.com", role="admin")
plain = make_user(email="plain@example.com")
assert email_mod.send_test("someone@example.com", "en") is False # fail-soft, as before
db.expire_all()
for admin in (admin_a, admin_b):
(n,) = _notifications(db, admin.id)
assert n.data["reason"] == "not_configured"
assert n.data["subject"] == "Siftlode SMTP test"
assert n.data["recipients"] == ["someone@example.com"]
assert _notifications(db, plain.id) == [] # not an operator's problem to see
def test_the_notice_is_throttled_so_a_dead_relay_cannot_flood_the_inbox(db, make_user):
admin = make_user(email="admin@example.com", role="admin")
for _ in range(5):
email_mod.send_test("someone@example.com", "en")
db.expire_all()
assert len(_notifications(db, admin.id)) == 1
def test_a_send_failure_is_reported_separately_from_a_missing_config(db, make_user, monkeypatch):
# A configured-but-rejecting relay is a different operator problem from an empty config, and the
# inbox copy differs — so the reason has to survive to the notification.
admin = make_user(email="admin@example.com", role="admin")
monkeypatch.setattr(
email_mod,
"_smtp",
lambda: {
"host": "smtp.example.com",
"port": 587,
"user": "u",
"password": "p",
"from": "Siftlode <u@example.com>",
},
)
def _boom(*a, **kw):
raise smtplib.SMTPException("nope")
monkeypatch.setattr(email_mod.smtplib, "SMTP", _boom)
assert email_mod.send_test("someone@example.com", "en") is False
db.expire_all()
(n,) = _notifications(db, admin.id)
assert n.data["reason"] == "send_failed"
def test_no_admins_means_no_notification_and_no_error(db, make_user):
# A fresh instance can have no admin yet (the wizard has not run). The alert must stay silent
# rather than blowing up inside the fail-soft path it is supposed to make visible.
plain = make_user(email="plain@example.com")
assert email_mod.send_test("someone@example.com", "en") is False
db.expire_all()
assert _notifications(db, plain.id) == []
def test_a_drop_with_nobody_to_tell_does_not_burn_the_hour(db, make_user):
# The throttle window must only be spent on a notice that was actually written. Otherwise a drop
# during setup — before any admin exists — silences the next hour for the admin created a minute
# later, which is exactly the blind spot this alert exists to remove.
email_mod.send_test("someone@example.com", "en") # no admins yet
admin = make_user(email="admin@example.com", role="admin")
assert email_mod.send_test("someone@example.com", "en") is False
db.expire_all()
assert len(_notifications(db, admin.id)) == 1
def test_a_failed_alert_does_not_burn_the_hour_either(db, make_user, monkeypatch):
admin = make_user(email="admin@example.com", role="admin")
boom = {"on": True}
def _maybe_boom(*a, **kw):
if boom["on"]:
raise RuntimeError("notification store is down")
from app.notifications import create_notification as real
return real(*a, **kw)
monkeypatch.setattr(email_mod, "create_notification", _maybe_boom)
email_mod.send_test("someone@example.com", "en") # the write blows up; nothing recorded
boom["on"] = False
assert email_mod.send_test("someone@example.com", "en") is False
db.expire_all()
assert len(_notifications(db, admin.id)) == 1
def test_a_broken_alert_never_breaks_the_send(db, make_user, monkeypatch):
# The alert is the fallback path; if IT fails, the caller must still get the ordinary fail-soft
# False rather than an exception surfacing from a background task.
make_user(email="admin@example.com", role="admin")
def _boom(*a, **kw):
raise RuntimeError("notification store is down")
monkeypatch.setattr(email_mod, "create_notification", _boom)
assert email_mod.send_test("someone@example.com", "en") is False
+347
View File
@@ -0,0 +1,347 @@
"""One mailbox, many spellings — the canonical-email identity rule (E9.5 S3).
Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE inbox. The invite gate
used to compare literal strings, so on 2026-09-03 an approved user's Google sign-in became a silent
SECOND access request: they had registered as `npeter83.temu@gmail.com`, and OAuth returned the
account's stored primary `npeter83temu@gmail.com`. The `+tag` case fails every time — a Google account
cannot contain `+`, so OAuth can only ever answer with the base address.
The rule is narrow on purpose: dots are significant on most providers, and the Gmail dot rule does not
hold for Google Workspace custom domains unless their admin enabled it.
"""
import pytest
from sqlalchemy import select, text
_UAT_PW = "correct horse battery"
from app.auth import is_allowed
from app.models import Invite, User
from app.utils import canonical_email
# (input, expected canonical) — the matrix the sprint promised, plus the shapes that must NOT fold.
CASES = [
# Gmail: dots and +tags are noise…
("npeter83.temu@gmail.com", "npeter83temu@gmail.com"),
("npeter83temu@gmail.com", "npeter83temu@gmail.com"),
("n.p.e.t.e.r@gmail.com", "npeter@gmail.com"),
("user+siftlode@gmail.com", "user@gmail.com"),
("u.ser+a+b@gmail.com", "user@gmail.com"),
("USer.Name@GMail.COM", "username@gmail.com"),
# …and googlemail is the same mailbox as gmail.
("user.name@googlemail.com", "username@gmail.com"),
# Everyone else: lowercase ONLY. Dots stay significant.
("first.last@example.com", "first.last@example.com"),
("First.Last@Example.COM", "first.last@example.com"),
("first+tag@example.com", "first+tag@example.com"),
# A Workspace domain is not Gmail, however Gmail-ish it looks.
("first.last@gmail.company.com", "first.last@gmail.company.com"),
# Degenerate input must not explode or invent an address.
("", ""),
(None, ""),
("not-an-email", "not-an-email"),
(".@gmail.com", ".@gmail.com"),
]
@pytest.mark.parametrize("raw,expected", CASES)
def test_canonical_email(raw, expected):
assert canonical_email(raw) == expected
def test_the_migration_sql_agrees_with_the_helper(db):
"""The 0079 backfill reimplements canonical_email in SQL (a migration must not import app code
that keeps changing). That duplication is only safe while the two agree, so pin them together —
a drift here means rows backfilled into a form the application will never look up."""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0079_email_canonical.py"
spec = importlib.util.spec_from_file_location("m0079", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
sql = module._CANON_SQL.format(col=":addr")
for raw, expected in CASES:
if raw is None:
continue # the column is NOT NULL; the backfill skips NULL emails explicitly
got = db.execute(text(f"SELECT {sql}"), {"addr": raw}).scalar_one()
assert got == expected, f"SQL and helper disagree on {raw!r}: {got!r} != {expected!r}"
def test_the_column_is_kept_in_sync_by_writing_the_email(db):
"""No write site has to remember: the model validator derives it. If this breaks, every lookup
below silently stops matching."""
user = User(email="Dotted.Name+tag@GoogleMail.com")
db.add(user)
db.commit()
assert user.email == "Dotted.Name+tag@GoogleMail.com" # stored AS TYPED — mail goes here
assert user.email_canonical == "dottedname@gmail.com" # …matched on this
user.email = "someone.else@example.com"
db.commit()
assert user.email_canonical == "someone.else@example.com"
def test_an_approval_covers_every_spelling_of_the_same_gmail_mailbox(db):
# THE 2026-09-03 lockout, in one assertion: approved under the dotted spelling, signing in with
# the primary Google returns.
db.add(Invite(email="npeter83.temu@gmail.com", status="approved"))
db.commit()
assert is_allowed(db, "npeter83temu@gmail.com") is True
assert is_allowed(db, "npeter83.temu@gmail.com") is True
assert is_allowed(db, "npeter83.temu+throwaway@gmail.com") is True
def test_a_plus_tagged_registration_is_not_locked_out_after_approval(db):
# The case that ALWAYS broke: a Google account cannot contain `+`, so OAuth can only return the
# base address, and a literal match could never find the invite.
db.add(Invite(email="someone+siftlode@gmail.com", status="approved"))
db.commit()
assert is_allowed(db, "someone@gmail.com") is True
def test_dots_stay_significant_outside_gmail(db):
db.add(Invite(email="first.last@example.com", status="approved"))
db.commit()
assert is_allowed(db, "first.last@example.com") is True
assert is_allowed(db, "firstlast@example.com") is False # a DIFFERENT mailbox elsewhere
def test_an_approved_row_wins_over_a_denied_sibling(db):
# Prod carries approved pairs for one mailbox (oldest 2026-06-16). The API cannot flip an approved
# invite to denied, so approved-beside-denied means the admin approved that mailbox under another
# spelling — not that they revoked it. Revoking is deleting the row or suspending the account.
db.add(Invite(email="two.spellings@gmail.com", status="denied"))
db.add(Invite(email="twospellings@gmail.com", status="approved"))
db.commit()
assert is_allowed(db, "twospellings@gmail.com") is True
def test_a_denied_mailbox_stays_denied(db):
db.add(Invite(email="no.thanks@gmail.com", status="denied"))
db.commit()
assert is_allowed(db, "nothanks@gmail.com") is False
def test_a_second_spelling_does_not_open_a_second_access_request(db):
"""The other half of the 2026-09-03 report: the admin queue got a duplicate request for a user
who was already approved."""
from app.routes.auth import upsert_pending_invite
db.add(Invite(email="already.approved@gmail.com", status="approved"))
db.commit()
req = upsert_pending_invite(db, "alreadyapproved@gmail.com")
assert req.is_new is False # nothing new to notify an admin about
assert req.invite is not None and req.invite.email == "already.approved@gmail.com"
assert len(db.execute(select(Invite)).scalars().all()) == 1
def test_manually_whitelisting_another_spelling_reuses_the_row(db):
"""How prod ended up with hartmann.peter1986@ AND hartmannpeter1986@ both approved on the same
day: the admin typed the other spelling and got a second row instead of a no-op."""
from app.routes.admin import add_invite, AddInviteIn
admin = User(email="admin@example.com", role="admin", is_active=True, email_verified=True)
db.add(admin)
db.add(Invite(email="hartmann.peter1986@gmail.com", status="approved"))
db.commit()
add_invite(AddInviteIn(email="hartmannpeter1986@gmail.com"), admin=admin, db=db)
rows = db.execute(select(Invite)).scalars().all()
assert len(rows) == 1
assert rows[0].email == "hartmann.peter1986@gmail.com" # the original spelling is preserved
def _capture_reset_links(monkeypatch) -> list[str]:
"""Patch the reset mailer and collect the recipients it is asked to write to."""
from app.routes import auth as auth_mod
sent: list[str] = []
monkeypatch.setattr(
auth_mod.email_mod, "send_password_reset", lambda to, lang, link: sent.append(to)
)
return sent
# --- signing in and recovering an account, whichever spelling of it the person types -------------
#
# The three flows that look an account up BY TYPED ADDRESS used to match literally, so a Gmail user
# who registered `x.y@` and typed `xy@` met "Invalid email or password" at sign-in — and, worse,
# total silence from password-reset and verify-resend, which answer uniformly by design.
def test_the_password_works_with_any_spelling_of_the_same_mailbox(db):
from app.auth import find_user_by_email
make = User(
email="t.e.st+tag@gmail.com", email_verified=True, is_active=True, password_hash="x"
)
db.add(make)
db.commit()
for typed in (
"t.e.st+tag@gmail.com", # as registered
"test@gmail.com", # the mailbox itself
"t.e.st@gmail.com", # dots only
"TEST@googlemail.com", # the alias domain
):
assert find_user_by_email(db, typed) is make, typed
def test_an_unrelated_address_still_finds_nothing(db):
from app.auth import find_user_by_email
db.add(User(email="someone@gmail.com", email_verified=True, is_active=True))
db.commit()
assert find_user_by_email(db, "someoneelse@gmail.com") is None
assert find_user_by_email(db, "some.one@example.com") is None # dots matter off Gmail
def test_the_exact_spelling_wins_when_two_accounts_share_a_mailbox(db):
# Prod carries such a pair (ids 13 and 14). Whoever types their own address in full must land on
# their own account, not on whichever row happens to come first.
from app.auth import find_user_by_email
dotted = User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True)
plain = User(email="npeter83temu@gmail.com", email_verified=True, is_active=True)
db.add_all([dotted, plain])
db.commit()
assert find_user_by_email(db, "npeter83.temu@gmail.com") is dotted
assert find_user_by_email(db, "npeter83temu@gmail.com") is plain
def test_an_ambiguous_spelling_refuses_rather_than_guessing(db):
# Neither spelling is exact, and both are the same mailbox. Handing a password sign-in one of
# them at random would let someone into the wrong account, so this answers "no such account" —
# the same uniform reply the caller already gives.
from app.auth import find_user_by_email
db.add(User(email="npeter83.temu@gmail.com", email_verified=True, is_active=True))
db.add(User(email="npeter83temu@gmail.com", email_verified=True, is_active=True))
db.commit()
assert find_user_by_email(db, "n.peter83.temu+x@gmail.com") is None
def test_a_reset_link_goes_to_the_address_on_the_account(db, monkeypatch):
"""Typing another spelling must still reach the owner — and the mail must go to the address they
registered with, tag and all, not to the one typed into the form."""
from app.routes.auth import _send_reset_if_eligible
db.add(
User(
email="me+siftlode@gmail.com",
email_verified=True,
is_active=True,
password_hash="x",
)
)
db.commit()
sent = _capture_reset_links(monkeypatch)
_send_reset_if_eligible("me@gmail.com")
assert sent == ["me+siftlode@gmail.com"]
# --- a Google sign-in must not rewrite the address its owner chose ------------------------------
def test_a_google_sign_in_keeps_a_plus_tagged_address(db):
"""People register as `me+siftlode@gmail.com` so a later leak from this site is identifiable.
Google returns the account's primary address, which can never contain `+`, so following it
blindly destroyed the tag on the first Google sign-in — and, since sign-in matches the stored
address first, stopped them signing in with the address they had chosen."""
from app.routes.auth import _sync_google_email
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
_sync_google_email(db, user, "me@gmail.com")
assert user.email == "me+siftlode@gmail.com"
def test_a_google_sign_in_follows_a_real_mailbox_change(db):
# The case the sync exists for: the Google account genuinely moved to a different mailbox.
from app.routes.auth import _sync_google_email
user = User(email="old.address@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
_sync_google_email(db, user, "brand-new@example.com")
assert user.email == "brand-new@example.com"
def test_a_mailbox_change_is_refused_when_another_account_owns_it(db):
from app.routes.auth import _sync_google_email
db.add(User(email="taken@example.com", email_verified=True, is_active=True))
user = User(email="mover@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
_sync_google_email(db, user, "taken@example.com")
assert user.email == "mover@gmail.com" # unique constraint would have 500'd the sign-in
# --- showing BOTH addresses, once there are two -------------------------------------------------
def test_the_google_address_is_recorded_beside_the_registered_one(db):
"""Since the sign-in no longer overwrites the registered address, the account has two true
addresses and only one was stored. This is the other half, for the UI to name."""
from app.routes.auth import _sync_google_email
user = User(email="me+siftlode@gmail.com", email_verified=True, is_active=True)
db.add(user)
db.commit()
user.google_email = "me@gmail.com"
_sync_google_email(db, user, "me@gmail.com")
db.commit()
assert user.email == "me+siftlode@gmail.com" # still theirs
assert user.google_email == "me@gmail.com" # …and now we know the other one
def test_me_reports_the_google_address_only_when_it_differs(client, db, make_user):
"""The comparison lives in one place so no surface has to repeat it — and so the account menu
stays exactly as it is for the majority, whose two addresses are the same string."""
same = make_user(email="plain@gmail.com", password=_UAT_PW)
same.google_email = "plain@gmail.com"
db.commit()
assert (
client.post(
"/auth/password-login", json={"email": "plain@gmail.com", "password": _UAT_PW}
).status_code
== 200
)
assert client.get("/api/me").json()["google_email"] is None
client.post("/auth/logout")
tagged = make_user(email="me+siftlode@gmail.com", password=_UAT_PW)
tagged.google_email = "me@gmail.com"
db.commit()
assert (
client.post(
"/auth/password-login", json={"email": "me+siftlode@gmail.com", "password": _UAT_PW}
).status_code
== 200
)
assert client.get("/api/me").json()["google_email"] == "me@gmail.com"
+121
View File
@@ -0,0 +1,121 @@
"""Every outbound mail carries an HTML alternative, so its link is actually clickable.
The bodies are plain text and stay that way — but a bare URL in a text/plain mail is only a link if
the CLIENT chooses to linkify it. Gmail/Outlook/Apple Mail do; lighter web clients render the body
verbatim and leave the verification or password-reset link as dead text, which is the entire point of
those two messages. So `_send` now attaches an HTML part generated from the same plain body.
These are pure-logic tests except the last one, which drives the real `_send` against a fake SMTP so
the wire format itself is pinned, not just the helper.
"""
import pytest
from app import email as email_mod
_SMTP_OK = {
"host": "smtp.example.com",
"port": 587,
"user": "u",
"password": "p",
"from": "Siftlode <u@example.com>",
}
def test_a_bare_url_becomes_a_real_link():
# THE regression: this is what a client that does not linkify plain text was leaving as dead text.
out = email_mod._html_body(
"Confirm your email:\n\nhttps://siftlode.example/#verify=tok123\n\n— Siftlode"
)
assert '<a href="https://siftlode.example/#verify=tok123">' in out
def test_interpolated_content_cannot_inject_markup():
# Bodies carry untrusted values — a requester's address in the admin notice, the operator contact.
# Escaping must run BEFORE linkification, or a crafted address becomes markup in every admin's mail.
out = email_mod._html_body('A request from <script>alert("x")</script> & co')
assert "<script>" not in out
assert "&lt;script&gt;" in out
assert "&amp; co" in out
def test_a_query_string_keeps_its_escaped_ampersand_inside_the_href():
# `&` is escaped to `&amp;` before the linkifier sees it, which is also what an HTML attribute
# wants — so the href must carry the escaped form and nothing may be trimmed off it.
out = email_mod._html_body("https://siftlode.example/x?a=1&b=2")
assert (
'<a href="https://siftlode.example/x?a=1&amp;b=2">https://siftlode.example/x?a=1&amp;b=2</a>'
in out
)
@pytest.mark.parametrize(
"body,href,trail",
[
("See https://siftlode.example/x.", "https://siftlode.example/x", "."),
("See https://siftlode.example/x, then sign in", "https://siftlode.example/x", ","),
("See (https://siftlode.example/x)", "https://siftlode.example/x", ")"),
],
)
def test_sentence_punctuation_stays_out_of_the_href(body, href, trail):
out = email_mod._html_body(body)
assert f'<a href="{href}">{href}</a>{trail}' in out
def test_a_token_ending_in_a_url_safe_character_is_not_trimmed():
# token_urlsafe output can end in `-` or `_`; trimming either would hand the user a dead link.
for tok in ("abc-", "abc_"):
out = email_mod._html_body(f"https://siftlode.example/#reset={tok}")
assert f'href="https://siftlode.example/#reset={tok}"' in out
def test_blank_lines_become_paragraphs_and_single_breaks_stay_inline():
out = email_mod._html_body("Hi,\n\nline one\nline two\n\n— Siftlode")
assert out.count("<p>") == 3
assert "<p>line one<br>line two</p>" in out
def test_the_sent_message_is_multipart_with_the_plain_body_intact(monkeypatch):
# The wire format, not just the helper: text/plain stays the primary part and the source of
# truth, with text/html alongside it.
monkeypatch.setattr(email_mod, "_smtp", lambda: _SMTP_OK)
sent = []
class _FakeSMTP:
def __init__(self, *a, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def starttls(self, *a, **kw):
pass
def login(self, *a, **kw):
pass
def send_message(self, msg):
sent.append(msg)
monkeypatch.setattr(email_mod.smtplib, "SMTP", _FakeSMTP)
assert (
email_mod.send_verify_email(
"someone@example.com", "en", "https://siftlode.example/#verify=tok"
)
is True
)
(msg,) = sent
assert msg.get_content_type() == "multipart/alternative"
plain = msg.get_body(preferencelist=("plain",))
rich = msg.get_body(preferencelist=("html",))
assert plain is not None and rich is not None
# The plain part must be byte-for-byte the template's own body — the HTML part is derived from
# it, never a second copy that could drift.
assert "https://siftlode.example/#verify=tok" in plain.get_content()
assert "<a href=" not in plain.get_content()
assert '<a href="https://siftlode.example/#verify=tok">' in rich.get_content()
+107
View File
@@ -0,0 +1,107 @@
"""What a refused sign-in SAYS (E9.5 S4), DB-lane.
The states behind a 403 were true and useless: "Your account is awaiting admin approval." never
revealed WHICH mailbox the account was on. On 2026-09-03 a registration as `npeter83.temu@gmail.com`
met a Google claim of `npeter83temu@gmail.com`, and that missing address hid the mismatch for a day.
So these pin the DETAIL, not just the status code — the address, and both gates, since E9.5 S1 made
the two order-independent and the screen must report whichever is actually outstanding.
The two refusals that answer BEFORE the password is proven carry nothing, and that is the security
half of the same contract: `invalid` must stay byte-identical for a real account and an unknown one.
"""
from app.config import settings
from app.models import Invite
from app.routes.auth import _access_notice_url, upsert_pending_invite
_PW = "correct horse battery"
def _login(client, email, password=_PW):
return client.post("/auth/password-login", json={"email": email, "password": password})
def test_pending_approval_names_the_address_the_account_is_on(client, make_user):
# The typed spelling and the stored one are the same mailbox; the screen must show the STORED
# one, because that is the mismatch a user cannot otherwise see.
make_user(email="np.temu@gmail.com", password=_PW, is_active=False)
detail = _login(client, "nptemu@gmail.com").json()["detail"]
assert detail["reason"] == "pending_approval"
assert detail["email"] == "np.temu@gmail.com"
assert detail["email_verified"] is True and detail["approved"] is False
def test_an_already_approved_account_still_reports_its_missing_verification(client, make_user):
# S1 made the gates order-independent, so this really happens: approved, not yet verified. A
# screen that assumed a sequence would tell this user to wait for an admin who already decided.
make_user(
email="approved.first@example.com", password=_PW, email_verified=False, is_active=True
)
detail = _login(client, "approved.first@example.com").json()["detail"]
assert detail["reason"] == "email_unverified"
assert detail["email_verified"] is False and detail["approved"] is True
def test_a_suspended_account_is_told_who_to_contact(client, make_user, monkeypatch):
monkeypatch.setattr(settings, "admin_emails", "ops@example.com")
make_user(email="banned@example.com", password=_PW, is_suspended=True)
r = _login(client, "banned@example.com")
assert r.status_code == 403
assert r.json()["detail"]["reason"] == "suspended"
assert r.json()["detail"]["operator"] == "ops@example.com"
def test_a_refusal_before_the_password_is_proven_carries_no_account_detail(client, make_user):
make_user(email="real@example.com", password=_PW, is_active=False)
wrong_pw = _login(client, "real@example.com", "not the password").json()["detail"]
unknown = _login(client, "nobody@example.com").json()["detail"]
# No address, no flags — the pending state above must not leak through the wrong-password door.
assert wrong_pw == unknown == {"code": "login", "reason": "invalid"}
# --- the access-request notice -------------------------------------------------------------------
def test_the_access_notice_names_the_address_and_rides_the_fragment():
url = _access_notice_url("requested", "someone@example.com")
# A fragment never reaches a proxy access log or a Referer header — the same reasoning that put
# the verify/reset tokens there (SB3), applied to an address.
assert url == "/#access=requested&email=someone%40example.com"
def test_a_plus_tagged_address_survives_the_round_trip():
# URLSearchParams reads a bare `+` in a fragment as a SPACE, so the encoder must emit %2B — or
# the very address family this epic exists to protect arrives mangled.
url = _access_notice_url("requested", "me+siftlode@gmail.com")
assert "me%2Bsiftlode%40gmail.com" in url and "+siftlode" not in url
def test_a_request_already_on_file_under_another_spelling_says_so(db):
db.add(Invite(email="hartmann.peter@gmail.com", status="pending"))
db.commit()
req = upsert_pending_invite(db, "hartmannpeter@gmail.com")
url = _access_notice_url("requested", "hartmannpeter@gmail.com", req.invite)
assert req.is_new is False # one mailbox, one request — no second row for the admin queue
assert "linked=hartmann.peter%40gmail.com" in url
def test_the_same_spelling_twice_is_just_a_repeat_and_says_nothing_extra(db):
db.add(Invite(email="same@example.com", status="pending"))
db.commit()
req = upsert_pending_invite(db, "same@example.com")
# Nothing to explain: telling this user we "linked" their request to itself would be noise.
assert "linked" not in _access_notice_url("requested", "same@example.com", req.invite)
+289
View File
@@ -0,0 +1,289 @@
"""E9.5 S7 — every outbound mail is written in the reader's language.
Two halves. The first is the catalog invariant: HU/EN parity with identical placeholders, enforced
here because a mail is the one user-facing surface nobody can re-render by switching the UI language
— it arrives once, in whatever language we picked. The second is the resolution chain: which
language a given recipient actually gets, and where that signal was captured, since for an address
with no account it exists only at the moment they asked us.
"""
import re
import string
import pytest
from sqlalchemy import select
from app import mailtext
from app.models import Invite, User
from app.routes import auth as auth_routes
_PW = "correct horse battery"
def _placeholders(template: str) -> set[str]:
return {name for _, name, _, _ in string.Formatter().parse(template) if name}
# --- the catalog itself ---------------------------------------------------------------------------
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
def test_every_mail_string_exists_in_both_languages(key):
assert set(mailtext.MAIL_TEXT[key]) == set(mailtext.SUPPORTED)
@pytest.mark.parametrize("key", sorted(mailtext.MAIL_TEXT))
def test_every_translation_takes_the_same_placeholders(key):
# The parity that actually bites: a translation that drops `{link}` renders a mail with no link
# in it, and a translation that invents one raises KeyError inside a background task nobody is
# watching. Both are silent in production and loud here.
variants = mailtext.MAIL_TEXT[key]
expected = _placeholders(variants[mailtext.DEFAULT])
for lang, template in variants.items():
assert _placeholders(template) == expected, f"{key} [{lang}]"
def _app_sources_without_the_catalog() -> str:
"""Every app source, with the catalog LITERAL cut out of mailtext.py.
Cutting it is the whole point: the dict spells each key as a quoted literal, so a scan that
included it reported every key as used no matter what the catalog held. Cutting the whole FILE
is too much, though — `link_expiry` legitimately asks for `duration.*`, and those keys would
then look orphaned."""
from pathlib import Path
app_dir = Path(__file__).resolve().parents[1] / "app"
out = []
for f in app_dir.rglob("*.py"):
src = f.read_text(encoding="utf-8")
if f.name == "mailtext.py":
src = src[: src.index("MAIL_TEXT: dict")]
out.append(src)
return "\n".join(out)
def test_no_catalog_entry_is_orphaned():
"""Every key is referenced from the app, so a mail that stopped being sent can't leave its text
behind to be 'kept up to date' by the next person who greps for it."""
sources = _app_sources_without_the_catalog()
assert [k for k in mailtext.MAIL_TEXT if f'"{k}"' not in sources] == []
def test_every_key_the_app_asks_for_exists():
"""The other direction, and the one that raises in production: `text()` looks the key up in a
dict, so a renamed or mistyped key is a KeyError inside a background task with no user in sight."""
asked = set(re.findall(r'text\(\s*"([a-z_]+\.[a-z_]+)"', _app_sources_without_the_catalog()))
assert asked, "the scan found no mailtext.text(...) call sites — the pattern has rotted"
assert sorted(asked - set(mailtext.MAIL_TEXT)) == []
def test_an_unknown_language_falls_back_to_english_instead_of_raising():
# `preferences.language` is free-form JSON written by older builds and by locales we may stop
# shipping. A send that raised on one would take out a background task with no user in sight.
assert mailtext.text("test.subject", "de") == mailtext.text("test.subject", "en")
assert mailtext.text("test.subject", None) == mailtext.text("test.subject", "en")
# --- who gets which language ----------------------------------------------------------------------
def test_a_users_own_preference_decides_their_language(make_user):
hu = make_user(email="hu@example.com", preferences={"language": "hu"})
en = make_user(email="en@example.com", preferences={"language": "en"})
none = make_user(email="none@example.com", preferences=None)
assert mailtext.lang_for_user(hu) == "hu"
assert mailtext.lang_for_user(en) == "en"
assert mailtext.lang_for_user(none) == "en"
def test_an_address_with_no_account_falls_back_to_the_language_it_asked_in(db):
db.add(Invite(email="asked@example.com", status="pending", lang="hu"))
db.commit()
assert mailtext.lang_for_email(db, "asked@example.com") == "hu"
def test_the_account_wins_over_the_invite_row(db, make_user):
make_user(email="both@example.com", preferences={"language": "en"})
db.add(Invite(email="both@example.com", status="approved", lang="hu"))
db.commit()
# The invite records how they asked once; the account records what they have since chosen.
assert mailtext.lang_for_email(db, "both@example.com") == "en"
def test_a_row_that_never_recorded_a_language_is_walked_past(db, make_user):
# Prod carries TWO accounts for one Gmail mailbox (the 2026-09-03 pair). Stopping at the first
# row would answer English for a person whose other account says Hungarian.
make_user(email="dup.one@gmail.com", preferences={})
make_user(email="dupone@gmail.com", preferences={"language": "hu"})
assert mailtext.lang_for_email(db, "dup.one@gmail.com") == "hu"
def test_the_lookup_is_canonical_like_every_other_identity_match(db, make_user):
make_user(email="canon.match@gmail.com", preferences={"language": "hu"})
assert mailtext.lang_for_email(db, "canonmatch+tag@gmail.com") == "hu"
# --- capturing the signal at the one moment it exists ---------------------------------------------
def test_registration_records_the_language_of_the_sign_up_page(db, client):
r = client.post(
"/auth/register", json={"email": "signup@example.com", "password": _PW, "lang": "hu"}
)
assert r.status_code == 200
user = db.execute(select(User).where(User.email == "signup@example.com")).scalar_one()
assert (user.preferences or {}).get("language") == "hu"
def test_an_access_request_records_the_language_it_was_made_in(db, client):
assert (
client.post(
"/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
).status_code
== 200
)
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
assert inv.lang == "hu"
def test_asking_again_from_another_language_updates_the_row(db, client):
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
db.expire_all()
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
# The newest thing they told us wins — and there is still exactly one row (S3's canonical upsert).
assert inv.lang == "hu"
assert db.execute(select(Invite)).scalars().all() == [inv]
def test_an_unsupported_language_is_ignored_rather_than_stored(db, client):
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"})
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
assert inv.lang is None
# --- the mails themselves -------------------------------------------------------------------------
def _bodies(monkeypatch):
"""Capture what `_send` would have put on the wire, rather than the arguments a caller passed —
the language only matters if it reaches the body."""
from app import email as email_mod
out: list[tuple[list[str], str, str]] = []
monkeypatch.setattr(
email_mod,
"_send",
lambda to, subject, body, reply_to=None: out.append((to, subject, body)) is None,
)
return out
def test_the_confirmation_mail_is_written_in_the_accounts_language(db, client, monkeypatch):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
client.post("/auth/register", json={"email": "hu@example.com", "password": _PW, "lang": "hu"})
(_, subject, body) = sent[0]
assert subject == mailtext.MAIL_TEXT["verify_email.subject"]["hu"]
assert "#verify=" in body
def test_the_approval_mail_answers_in_the_language_the_request_was_made_in(
db, client, make_user, as_user, monkeypatch
):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
sent.clear() # drop the admin notification; this test is about the reply to the requester
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
assert r.status_code == 200
(to, subject, _) = sent[0]
assert to == ["asking@example.com"]
assert subject == mailtext.MAIL_TEXT["access_approved.subject"]["hu"]
def test_a_denial_answers_in_that_language_too(db, client, make_user, as_user, monkeypatch):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "hu"})
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
sent.clear()
as_user(admin).post(f"/api/admin/invites/{inv.id}/deny")
assert sent[0][1] == mailtext.MAIL_TEXT["access_denied.subject"]["hu"]
def test_the_admins_pick_decides_an_invitation_and_is_remembered(
db, make_user, as_user, monkeypatch
):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
# Hand-whitelisted: nobody has ever told us anything about this mailbox, which is exactly why
# the send button asks.
inv = Invite(email="stranger@example.com", status="approved")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "hu"})
assert r.status_code == 200
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["hu"]
db.expire_all()
# Recorded, so a later mail to this address matches the one the admin already sent.
assert db.get(Invite, inv.id).lang == "hu"
def test_an_invitation_without_a_pick_still_goes_out(db, make_user, as_user, monkeypatch):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="stranger@example.com", status="approved")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
assert r.status_code == 200
assert sent[0][1] == mailtext.MAIL_TEXT["access_invitation.subject"]["en"]
db.expire_all()
# Nothing was picked, so nothing is remembered: the column means "what this person told us",
# not "what we last defaulted to".
assert db.get(Invite, inv.id).lang is None
def test_admins_are_notified_in_their_own_languages(db, make_user):
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
groups = auth_routes.admin_notify_groups(db)
# One mail per language, not one blast that has to be wrong for somebody.
assert groups == {"hu": ["hu.admin@example.com"], "en": ["en.admin@example.com"]}
+58
View File
@@ -0,0 +1,58 @@
"""/api/me carries whether this instance can actually send email.
It drives the operator warnings on the Configuration and Access-requests screens. Admin-only,
because it is an operator concern and a regular user has nothing to do with it.
"""
from app import email as email_mod
_PW = "correct horse battery"
def _signed_in(client, make_user, **kw):
"""/api/me resolves its user through optional_current_user, which the as_user override does not
cover — so sign in for real and read the bootstrap payload the SPA actually gets."""
user = make_user(password=_PW, **kw)
assert (
client.post("/auth/password-login", json={"email": user.email, "password": _PW}).status_code
== 200
)
r = client.get("/api/me")
assert r.status_code == 200 and r.json()["authenticated"] is True
return r.json()
def test_an_admin_is_told_when_email_is_not_configured(client, make_user, monkeypatch):
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
assert me["email_configured"] is False
def test_an_admin_sees_nothing_wrong_when_email_works(client, make_user, monkeypatch):
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
assert me["email_configured"] is True
def test_a_regular_user_is_never_warned_about_the_mail_setup(client, make_user, monkeypatch):
# Not their problem, and the flag must not become a way to probe the instance's configuration.
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
me = _signed_in(client, make_user, email="plain@example.com")
assert me["email_configured"] is True
def test_the_flag_reuses_the_request_session(client, make_user, monkeypatch):
# /api/me is polled once a minute per open tab; reading the SMTP settings decrypts a Fernet
# value, so it must not also check out a second pooled connection each time.
seen: list[object] = []
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: seen.append(db) or True)
_signed_in(client, make_user, email="admin@example.com", role="admin")
assert seen and seen[0] is not None
+495
View File
@@ -0,0 +1,495 @@
"""The playback-class filter (E7): filter the Plex feed by Direct Play / Direct Stream / Transcode·HW /
Transcode·SW. Covers the shared classifier + Stream parsing (unit), the sync derivation + codec-change
reset, the bit-depth backfill, and the /library filter + /facets counts (DB lane).
The HW/SW split is derived from the stored `transcode_hw` facet (codec at sync, refined by the bit-depth
backfill). These tests pin that a 10-bit/HDR HEVC lands in SW even though its codec is HW-decodable —
the exact case the runtime VAAPI path (plex.stream) also special-cases.
"""
import re
from pathlib import Path
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, User
from app.plex import sync
from fastapi.testclient import TestClient
class TestTranscodeHwCapable:
"""The single classifier both the catalog facet and (by codec) the runtime encoder choice key off."""
def test_non_transcode_items_are_none(self):
assert sync.transcode_hw_capable("direct", "h264") is None
assert sync.transcode_hw_capable("remux", "h264") is None
assert sync.transcode_hw_capable(None, "hevc") is None
def test_hw_decodable_8bit_sdr_is_hw(self):
assert sync.transcode_hw_capable("transcode", "hevc") is True
assert sync.transcode_hw_capable("transcode", "vp9", bit_depth=8) is True
def test_non_decodable_codec_is_sw(self):
# AV1 / MPEG-4 ASP (XviD) can't be HW-decoded on Gen 9.5 → software regardless of bit depth.
assert sync.transcode_hw_capable("transcode", "av1") is False
assert sync.transcode_hw_capable("transcode", "mpeg4", bit_depth=8) is False
def test_10bit_or_hdr_hevc_is_sw(self):
# A HW-decodable codec that is 10-bit or HDR still breaks the VAAPI nv12 pipeline → software.
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=10) is False
assert sync.transcode_hw_capable("transcode", "hevc", bit_depth=8, hdr=True) is False
class TestVideoStreamProps:
def test_reads_bit_depth_and_hdr_from_the_video_stream(self):
meta = {
"Media": [
{
"Part": [
{
"Stream": [
{"streamType": 2, "codec": "eac3"}, # audio — skipped
{"streamType": 1, "bitDepth": 10, "colorTrc": "smpte2084"},
]
}
]
}
]
}
assert sync._video_stream_props(meta) == (10, True)
def test_8bit_sdr_stream(self):
meta = {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
assert sync._video_stream_props(meta) == (8, False)
def test_absent_stream_is_none_false(self):
assert sync._video_stream_props({}) == (None, False)
assert sync._video_stream_props({"Media": [{"Part": [{}]}]}) == (None, False)
class TestSyncDerivation:
"""`_apply_item` sets transcode_hw codec-only at sync (bit depth unknown from the listing), and a
changed video codec resets the probe watermark so a stale bit-depth can't linger."""
def _meta(self, vcodec, *, key="/parts/1/file.mkv", file="/m.mkv"):
return {
"title": "X",
"Media": [{"videoCodec": vcodec, "Part": [{"file": file, "key": key}]}],
}
def test_codec_only_derivation_at_sync(self):
row = PlexItem(rating_key="x", kind="movie")
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
assert row.playable == "transcode" and row.transcode_hw is True # decodable, 8-bit assumed
row2 = PlexItem(rating_key="y", kind="movie")
sync._apply_item(row2, lib_id=1, kind="movie", meta=self._meta("av1"))
assert row2.transcode_hw is False # av1 not HW-decodable
def test_probed_value_survives_resync_when_codec_unchanged(self):
row = PlexItem(rating_key="x", kind="movie")
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
# Simulate the backfill downgrading this 10-bit HEVC to software.
row.transcode_hw = False
row.video_bit_depth = 10
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
assert row.transcode_hw is False # NOT re-derived back to True
assert row.video_bit_depth == 10 and row.media_probed_at is not None
def test_codec_change_resets_the_probe(self):
row = PlexItem(rating_key="x", kind="movie")
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc"))
row.transcode_hw = False
row.video_bit_depth = 10
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
# The file was replaced with an h264 remux — the stale 10-bit probe must be cleared.
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("h264"))
assert row.video_bit_depth is None and row.media_probed_at is None
def test_same_codec_reencode_with_a_new_part_resets_the_probe(self):
# A same-codec bit-depth re-encode (e.g. 8-bit→10-bit HEVC) keeps codec 'hevc' but replaces the
# physical file, so its Plex Part key changes. Codec-only reset would miss this and keep the stale
# 8-bit/HW verdict forever (the backfill only re-probes media_probed_at IS NULL rows) — the
# part-key move must reset the watermark so the new bit depth is re-probed.
row = PlexItem(rating_key="x", kind="movie")
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/1/f.mkv"))
row.transcode_hw = True
row.video_bit_depth = 8
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
# Re-encoded in place: same codec 'hevc', but a new Part key (new file).
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/2/f.mkv"))
assert row.video_bit_depth is None and row.media_probed_at is None # re-probe scheduled
def test_migration_codec_set_matches_hw_decodable_codecs():
# Migration 0077 hardcodes the HW-decodable codec set in raw SQL and its comment mandates it stay
# identical to plex.sync.HW_DECODABLE_CODECS. A comment isn't enforcement (house rule) — pin the
# equality so adding/removing a codec from the frozenset without editing the migration fails here
# (else already-migrated prod rows keep a wrong transcode_hw the runtime derivation wouldn't catch).
mig = (
Path(__file__).resolve().parent.parent
/ "alembic"
/ "versions"
/ "0077_plex_playback_class.py"
)
m = re.search(r"lower\(codec_video\)\s+IN\s+\(([^)]*)\)", mig.read_text())
assert m, "could not find the codec IN(...) list in migration 0077"
codecs = {c.strip().strip("'\"").lower() for c in m.group(1).split(",")}
assert codecs == {c.lower() for c in sync.HW_DECODABLE_CODECS}
class _FakePlex:
"""Stands in for PlexClient.metadata — returns a canned full-metadata dict per rating_key."""
def __init__(self, by_key):
self.by_key = by_key
def metadata(self, rating_key, markers=False):
return self.by_key.get(rating_key)
def test_probe_media_batch_refines_hw_to_sw_for_10bit(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
hevc8 = PlexItem(
rating_key="h8",
library_id=lib.id,
kind="movie",
title="8bit",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
hevc10 = PlexItem(
rating_key="h10",
library_id=lib.id,
kind="movie",
title="10bit",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
xvid = PlexItem(
rating_key="xv",
library_id=lib.id,
kind="movie",
title="xvid",
playable="transcode",
codec_video="mpeg4",
transcode_hw=False,
)
db.add_all([hevc8, hevc10, xvid])
db.commit()
plex = _FakePlex(
{
"h8": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]},
"h10": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]},
}
)
probed = sync._probe_media_batch(db, plex)
db.refresh(hevc8)
db.refresh(hevc10)
db.refresh(xvid)
# Only the two HW-decodable HEVC items are probed; the XviD is SW-by-codec and never fetched.
assert probed == 2
assert hevc8.video_bit_depth == 8 and hevc8.transcode_hw is True and hevc8.media_probed_at
assert hevc10.video_bit_depth == 10 and hevc10.transcode_hw is False # downgraded to software
assert xvid.media_probed_at is None # not in the work-list
# A second run has nothing left to do (watermarks set).
assert sync._probe_media_batch(db, plex) == 0
def test_probe_stamps_even_when_plex_has_no_stream(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
item = PlexItem(
rating_key="ns",
library_id=lib.id,
kind="movie",
title="nostream",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
db.add(item)
db.commit()
plex = _FakePlex({"ns": {"Media": [{"Part": [{}]}]}}) # no Stream array
assert sync._probe_media_batch(db, plex) == 1
db.refresh(item)
# Probed (watermark set so it isn't re-fetched forever); bit depth unknown → stays codec-HW.
assert item.media_probed_at is not None and item.video_bit_depth is None
assert item.transcode_hw is True
def test_probe_media_batch_is_bounded_and_oldest_first(db):
# The per-run bound (limit) caps Plex load; oldest-id-first means successive runs cover the whole
# backlog without starving any item. Seed 3 eligible items, probe with limit=2.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
items = [
PlexItem(
rating_key=f"h{i}",
library_id=lib.id,
kind="movie",
title=f"m{i}",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
for i in range(3)
]
db.add_all(items) # inserted in order → ascending ids
db.commit()
plex = _FakePlex(
{
f"h{i}": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
for i in range(3)
}
)
assert sync._probe_media_batch(db, plex, limit=2) == 2 # exactly the bound
for it in items:
db.refresh(it)
# The two LOWEST-id rows were probed; the third waits for the next run.
assert items[0].media_probed_at is not None and items[1].media_probed_at is not None
assert items[2].media_probed_at is None
# The next run continues with the remaining item.
assert sync._probe_media_batch(db, plex, limit=2) == 1
db.refresh(items[2])
assert items[2].media_probed_at is not None
class _RaisingPlex:
"""metadata() raises for `bad_key`, returns a canned dict otherwise — to exercise the transient
fetch-failure branch (leave the watermark NULL so the item retries next run)."""
def __init__(self, by_key, bad_key):
self.by_key = by_key
self.bad_key = bad_key
def metadata(self, rating_key, markers=False):
if rating_key == self.bad_key:
raise RuntimeError("plex timeout")
return self.by_key.get(rating_key)
def test_probe_media_batch_leaves_a_transient_failure_unstamped_to_retry(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
good = PlexItem(
rating_key="g",
library_id=lib.id,
kind="movie",
title="good",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
bad = PlexItem(
rating_key="b",
library_id=lib.id,
kind="movie",
title="bad",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
)
db.add_all([good, bad])
db.commit()
plex = _RaisingPlex(
{"g": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]}}, bad_key="b"
)
# The raise on 'b' must not abort the batch: 'g' is still probed and counted; 'b' stays NULL.
assert sync._probe_media_batch(db, plex) == 1
db.refresh(good)
db.refresh(bad)
assert good.media_probed_at is not None and good.transcode_hw is False # 10-bit → SW
assert bad.media_probed_at is None # transient failure → retried next run, not stamped
def _seed_classes(db):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all(
[
PlexItem(
rating_key="d1", library_id=lib.id, kind="movie", title="Direct", playable="direct"
),
PlexItem(
rating_key="r1", library_id=lib.id, kind="movie", title="Remux", playable="remux"
),
PlexItem(
rating_key="thw",
library_id=lib.id,
kind="movie",
title="HW",
playable="transcode",
codec_video="hevc",
transcode_hw=True,
),
PlexItem(
rating_key="tsw",
library_id=lib.id,
kind="movie",
title="SW",
playable="transcode",
codec_video="mpeg4",
transcode_hw=False,
),
]
)
return lib
def _client(db, user, monkeypatch):
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
return TestClient(app)
def test_library_filters_by_playback_class(db, monkeypatch):
_seed_classes(db)
user = User(email="pclass@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
# Software-only transcodes.
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
assert sw.status_code == 200, sw.text
assert {i["id"] for i in sw.json()["items"]} == {"tsw"}
# HW transcode + Direct Play, OR-combined.
both = client.get("/api/plex/library?scope=movie&playable=transcode_hw,direct")
assert {i["id"] for i in both.json()["items"]} == {"thw", "d1"}
# The HW card carries the transcode_hw flag for the 4-way badge.
card = next(i for i in both.json()["items"] if i["id"] == "thw")
assert card["transcode_hw"] is True
# No filter → everything.
allm = client.get("/api/plex/library?scope=movie")
assert {i["id"] for i in allm.json()["items"]} == {"d1", "r1", "thw", "tsw"}
finally:
app.dependency_overrides.clear()
def test_facets_count_playback_classes_in_severity_order(db, monkeypatch):
_seed_classes(db)
user = User(email="pclassfacet@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
resp = client.get("/api/plex/facets?scope=movie")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
playable = resp.json()["playable"]
# Fixed good→heavy order (not by count), one per seeded class.
assert [p["value"] for p in playable] == ["direct", "remux", "transcode_hw", "transcode_sw"]
assert {p["value"]: p["count"] for p in playable} == {
"direct": 1,
"remux": 1,
"transcode_hw": 1,
"transcode_sw": 1,
}
def test_unprobed_transcode_folds_into_sw(db, monkeypatch):
# transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's
# CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with
# software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the
# item from the feed (an honest-state violation).
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(
PlexItem(
rating_key="tnull",
library_id=lib.id,
kind="movie",
title="Unprobed",
playable="transcode",
codec_video="hevc",
transcode_hw=None,
)
)
user = User(email="pnull@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
assert hw.json()["items"] == [] # never HW
facets = client.get("/api/plex/facets?scope=movie")
counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
finally:
app.dependency_overrides.clear()
def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch):
# A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion),
# otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting
# 'direct' must still return all four classes at their full, unnarrowed counts.
_seed_classes(db)
user = User(email="pself@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1}
def test_active_playable_narrows_the_other_facets(db, monkeypatch):
# The flip side of self-exclusion: an active playable class DOES narrow the OTHER facet groups (so
# e.g. the year range reflects only the selected class). Seed a Direct movie (2000) and a
# software-transcode movie (2020); selecting playable=direct must collapse the year facet to 2000.
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all(
[
PlexItem(
rating_key="d2000",
library_id=lib.id,
kind="movie",
title="Direct2000",
playable="direct",
year=2000,
),
PlexItem(
rating_key="s2020",
library_id=lib.id,
kind="movie",
title="SW2020",
playable="transcode",
codec_video="mpeg4",
transcode_hw=False,
year=2020,
),
]
)
user = User(email="pnarrow@example.com")
db.add(user)
db.commit()
client = _client(db, user, monkeypatch)
try:
wide = client.get("/api/plex/facets?scope=movie").json()
narrow = client.get("/api/plex/facets?scope=movie&playable=direct").json()
finally:
app.dependency_overrides.clear()
assert (wide["year_min"], wide["year_max"]) == (2000, 2020) # both classes span the range
assert (narrow["year_min"], narrow["year_max"]) == (2000, 2000) # narrowed to the Direct subset
+1 -1
View File
@@ -58,7 +58,7 @@ def _post(db, user, monkeypatch, rating_key, *, captured):
def fake_start(db_, it, start, audio, aoff, multi, transcode=False):
captured["transcode"] = transcode
return SimpleNamespace(
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False
media_start_s=start, entry="index.m3u8", transcode=transcode, hw=False, tonemap=False
)
monkeypatch.setattr(plex_stream, "start_session", fake_start)
+449 -24
View File
@@ -267,50 +267,206 @@ class TestTranscodeCommand:
)
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # not a software scale
def test_hdr_tonemap_uses_the_opencl_graph(self):
# The HDR/10-bit HW path: VAAPI decode → OpenCL tonemap → VAAPI encode. Gen 9.5 can't tonemap in
# the VAAPI VPP, so this is the only HW route for a 10-bit/HDR source.
cmd = stream._ffmpeg_cmd(
self.SRC, 0.0, self.OUT, None, transcode=True, hw=True, tonemap=True
)
assert _venc(cmd) == "h264_vaapi"
assert "libx264" not in cmd
# Named devices + an OpenCL device DERIVED from the VAAPI one, all before -i.
assert cmd[cmd.index("-init_hw_device") + 1] == f"vaapi=va:{stream._VAAPI_DEVICE}"
assert "opencl=ocl@va" in cmd
assert cmd[cmd.index("-hwaccel_device") + 1] == "va"
assert cmd.index("-init_hw_device") < cmd.index("-i")
# The filter graph runs on the derived OpenCL device and tone-maps HDR→SDR to 8-bit nv12.
assert cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
vf = cmd[cmd.index("-vf") + 1]
assert "hwmap=derive_device=opencl" in vf
assert "tonemap_opencl=" in vf and "format=nv12" in vf
assert "hwmap=derive_device=vaapi:reverse=1" in vf
assert "scale_vaapi" not in vf # the plain 8-bit path's filter must NOT be used here
assert (
cmd[cmd.index("-force_key_frames") + 1] == f"expr:gte(t,n_forced*{stream._SEG_SECONDS})"
)
class TestWantHw:
"""VAAPI is used only where the render node was passed in AND (in auto) the source is HW-decodable,
with an admin `plex_transcode_hw` policy (auto/on/off) on top."""
with an admin `plex_transcode_hw` policy (auto/on/off) on top. A HW session on a 10-bit/HDR source
tone-maps on the GPU via OpenCL (tonemap=True) when the OpenCL runtime is present, else drops to
software — see the `_select_encode` cases below."""
SRC = Path("/media/movie.mkv")
@staticmethod
def _policy(monkeypatch, mode):
monkeypatch.setattr(stream.sysconfig, "get_str", lambda db, k: mode)
@staticmethod
def _kind(monkeypatch, kind):
# Pin the source classifier so the tests don't shell out to ffprobe. `kind` is one of
# stream._VK_HWSAFE / _VK_HDR / _VK_SDR10.
monkeypatch.setattr(stream, "_probe_video_kind", lambda src: kind)
@staticmethod
def _hwsafe(monkeypatch, safe):
# Back-compat convenience for the hw-gate tests that only care about 8-bit-SDR vs not: True →
# _VK_HWSAFE, False → _VK_SDR10 (a non-HDR non-hwsafe class, so the hw half still resolves).
monkeypatch.setattr(
stream, "_probe_video_kind", lambda src: stream._VK_HWSAFE if safe else stream._VK_SDR10
)
@staticmethod
def _opencl(monkeypatch, present):
# Pin the OpenCL-runtime probe so the routing doesn't depend on the CI box's /etc/OpenCL.
monkeypatch.setattr(stream, "_opencl_present", lambda: present)
def test_no_hw_when_not_transcoding(self, monkeypatch):
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False) is False
assert (
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=False, src=self.SRC)
is False
)
def test_no_hw_without_a_render_node(self, monkeypatch):
self._policy(monkeypatch, "auto")
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
assert (
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
is False
)
def test_auto_uses_hw_for_a_decodable_source_with_the_render_node(self, monkeypatch):
def test_auto_uses_hw_for_a_decodable_8bit_source_with_the_render_node(self, monkeypatch):
self._policy(monkeypatch, "auto")
self._hwsafe(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is True
assert (
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
is True
)
def test_auto_falls_back_to_software_for_a_non_decodable_source(self, monkeypatch):
# AV1 / MPEG-4 ASP (XviD) aren't decodable on Gen 9.5 → the GPU can't feed its own encoder.
self._policy(monkeypatch, "auto")
self._hwsafe(monkeypatch, True) # decodability gate must fire BEFORE the probe
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is False
assert stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True) is False
assert (
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
is False
)
assert (
stream._want_hw(None, _FakeItem(codec_video="mpeg4"), transcode=True, src=self.SRC)
is False
)
def test_auto_hdr_source_falls_back_to_software_without_opencl(self, monkeypatch):
# A decodable HEVC that is true-HDR (PQ/HLG) can't use scale_vaapi=format=nv12 (Gen 9.5), and with
# no OpenCL runtime to tonemap on the GPU it drops to software libx264 (BUG 1's behaviour).
self._policy(monkeypatch, "auto")
self._kind(monkeypatch, stream._VK_HDR)
self._opencl(monkeypatch, False)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (False, False)
def test_auto_hdr_source_uses_hw_tonemap_with_opencl(self, monkeypatch):
# With the OpenCL runtime present, a true-HDR HEVC tone-maps on the GPU (VAAPI decode → OpenCL
# tonemap → VAAPI encode) instead of dropping to CPU — the HW HDR path.
self._policy(monkeypatch, "auto")
self._kind(monkeypatch, stream._VK_HDR)
self._opencl(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, True)
def test_auto_10bit_SDR_source_uses_software_not_tonemap(self, monkeypatch):
# F1 regression: a 10-bit *SDR* (bt709) source is NOT HDR, so it must NOT get the hable HDR tone
# curve even when the OpenCL runtime is present — that would visibly alter an SDR picture. It takes
# software libx264 (an exact 10→8-bit downconvert), never (True, True).
self._policy(monkeypatch, "auto")
self._kind(monkeypatch, stream._VK_SDR10)
self._opencl(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (False, False)
def test_auto_8bit_source_uses_plain_hw_not_tonemap(self, monkeypatch):
# An 8-bit SDR source takes the plain full-GPU scale path — never the tonemap graph — even where
# OpenCL is available.
self._policy(monkeypatch, "auto")
self._kind(monkeypatch, stream._VK_HWSAFE)
self._opencl(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, False)
def test_off_forces_software_even_for_a_decodable_source(self, monkeypatch):
self._policy(monkeypatch, "off")
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
assert (
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
is False
)
def test_on_forces_hw_even_for_a_non_decodable_source(self, monkeypatch):
# The admin override trusts the GPU to decode it; skips the decodability gate (render node still
# required — HW is impossible without it).
def test_on_forces_hw_and_skips_both_source_gates(self, monkeypatch):
# The admin override trusts the GPU; skips the decodability AND the bit-depth gate (the runtime
# HW→SW fallback still backstops it). Render node still required — HW is impossible without it.
self._policy(monkeypatch, "on")
self._hwsafe(monkeypatch, False) # would veto in auto; "on" ignores it
self._opencl(
monkeypatch, False
) # pin: tonemap-half is irrelevant here, keep it deterministic
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True) is True
assert (
stream._want_hw(None, _FakeItem(codec_video="av1"), transcode=True, src=self.SRC)
is True
)
monkeypatch.setattr(stream, "_render_node_present", lambda: False)
assert stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True) is False
assert (
stream._want_hw(None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC)
is False
)
def test_on_still_picks_the_tonemap_graph_for_an_hdr_source(self, monkeypatch):
# "on" trusts the GPU but must still route a true-HDR source through the tonemap graph (plain
# scale_vaapi would hit the VPP wall) when OpenCL is present.
self._policy(monkeypatch, "on")
self._kind(monkeypatch, stream._VK_HDR)
self._opencl(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, True)
def test_on_10bit_SDR_uses_plain_hw_not_tonemap(self, monkeypatch):
# F1 regression under "on": a 10-bit SDR source is NOT HDR, so even with OpenCL present "on" must
# pick plain HW (True, False) — the HDR tone curve must never touch an SDR picture.
self._policy(monkeypatch, "on")
self._kind(monkeypatch, stream._VK_SDR10)
self._opencl(monkeypatch, True)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, False)
def test_on_without_opencl_is_plain_hw(self, monkeypatch):
# "on" with no OpenCL runtime → plain HW, tonemap=False, regardless of source class (the runtime
# HW→SW fallback backstops a source the plain path can't actually handle). Pins the tonemap half
# of the "on" tuple, which test_on_forces_hw_and_skips_both_source_gates only checks via _want_hw.
self._policy(monkeypatch, "on")
self._kind(monkeypatch, stream._VK_HDR) # even HDR: no runtime → can't tonemap on the GPU
self._opencl(monkeypatch, False)
monkeypatch.setattr(stream, "_render_node_present", lambda: True)
assert stream._select_encode(
None, _FakeItem(codec_video="hevc"), transcode=True, src=self.SRC
) == (True, False)
class TestColdStartWarmup:
@@ -333,7 +489,7 @@ class TestColdStartWarmup:
def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch):
waited = []
def spy(path, timeout=20.0):
def spy(path, timeout=20.0, proc=None):
waited.append(path.name)
return True
@@ -346,7 +502,7 @@ class TestColdStartWarmup:
def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch):
waited = []
def spy(path, timeout=20.0):
def spy(path, timeout=20.0, proc=None):
waited.append(path.name)
return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there
@@ -360,7 +516,7 @@ class TestColdStartWarmup:
# still returned so the client's own segment requests can drive it, as before.
waited = []
def spy(path, timeout=20.0):
def spy(path, timeout=20.0, proc=None):
waited.append(path.name)
return False
@@ -398,7 +554,7 @@ class TestTranscodeStartSession:
"get_str",
lambda db, k: {"plex_transcode_preset": "slow"}.get(k, "auto"),
)
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0: True)
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0, proc=None: True)
_CapturingProc.calls = []
monkeypatch.setattr(stream.subprocess, "Popen", _CapturingProc)
return tmp_path
@@ -406,7 +562,9 @@ class TestTranscodeStartSession:
def test_software_transcode_threads_the_knobs_into_the_command(
self, capturing_env, monkeypatch
):
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="mpeg4"), start_s=0.0, transcode=True
)
@@ -421,25 +579,292 @@ class TestTranscodeStartSession:
assert kwargs.get("preexec_fn") is stream._sw_child_preexec
def test_hardware_transcode_uses_the_gpu_and_never_nices(self, capturing_env, monkeypatch):
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: True)
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
assert s is not None and s.transcode is True and s.hw is True
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is False
assert len(_CapturingProc.calls) == 1 # first segment arrived — no HW→SW fallback respawn
(cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "h264_vaapi"
assert "libx264" not in cmd
assert (
"-vf" not in cmd or cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12"
) # no SW cap
# Plain-HW path unconditionally emits the nv12 downconvert (a disjunction on "-vf not in cmd"
# would silently pass if the filter were dropped — assert it is actually present).
assert cmd[cmd.index("-vf") + 1] == "scale_vaapi=format=nv12" # no SW cap
assert kwargs.get("preexec_fn") is None # GPU encode is cheap — never niced
def test_hdr_tonemap_transcode_threads_the_opencl_graph_and_flags_the_session(
self, capturing_env, monkeypatch
):
# A HW tonemap session (10-bit/HDR + OpenCL) must build the OpenCL graph in the argv and carry
# tonemap=True on the session, still on the GPU (hw=True) and never niced.
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
assert s is not None and s.transcode is True and s.hw is True and s.tonemap is True
(cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "h264_vaapi"
assert "opencl=ocl@va" in cmd and cmd[cmd.index("-filter_hw_device") + 1] == "ocl"
assert "tonemap_opencl=" in cmd[cmd.index("-vf") + 1]
assert kwargs.get("preexec_fn") is None
def test_a_hw_session_that_produces_nothing_falls_back_to_software(
self, capturing_env, monkeypatch
):
# BUG 1 backstop: _want_hw picked HW but the VAAPI session emits no first segment (a VAProfile
# refusal the gates didn't catch). start_session must respawn on libx264 and report hw=False so
# the item still plays and the route/player honestly show CPU.
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
seen = {"n": 0}
def flaky_wait(path, timeout=20.0, proc=None):
seen["n"] += 1
return seen["n"] > 1 # the first seg-0 wait (HW) fails; every wait after succeeds (SW)
monkeypatch.setattr(stream, "wait_for", flaky_wait)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
assert (
s is not None and s.transcode is True and s.hw is False
) # honest CPU after the fallback
assert len(_CapturingProc.calls) == 2 # the HW attempt, then the SW respawn
(hw_cmd, *_), _ = _CapturingProc.calls[0]
(sw_cmd, *_), sw_kwargs = _CapturingProc.calls[1]
assert _venc(hw_cmd) == "h264_vaapi"
assert _venc(sw_cmd) == "libx264"
if os.name == "posix":
assert sw_kwargs.get("preexec_fn") is stream._sw_child_preexec # the SW child is niced
def test_a_hw_TONEMAP_session_that_produces_nothing_falls_back_to_software(
self, capturing_env, monkeypatch
):
# The fallback gate is `not ready and s.hw`, shared by plain-HW and tonemap sessions — but only
# the plain-HW variant was covered. Pin the tonemap variant too: a (True, True) session that
# emits nothing (an OpenCL/tonemap init refusal) must respawn on libx264 and end hw=False,
# tonemap=False, so an HDR item still plays on CPU.
monkeypatch.setattr(stream, "_select_encode", lambda db, item, transcode, src: (True, True))
seen = {"n": 0}
def flaky_wait(path, timeout=20.0, proc=None):
seen["n"] += 1
return (
seen["n"] > 1
) # the first seg-0 wait (HW tonemap) fails; every wait after succeeds
monkeypatch.setattr(stream, "wait_for", flaky_wait)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
assert s is not None and s.transcode is True and s.hw is False and s.tonemap is False
assert len(_CapturingProc.calls) == 2 # the HW tonemap attempt, then the SW respawn
(hw_cmd, *_), _ = _CapturingProc.calls[0]
(sw_cmd, *_), _ = _CapturingProc.calls[1]
assert (
"tonemap_opencl=" in hw_cmd[hw_cmd.index("-vf") + 1]
) # first attempt built the OpenCL graph
assert _venc(sw_cmd) == "libx264" # respawn is plain software, no tonemap
def test_a_superseded_hw_session_does_not_respawn_over_the_newer_one(
self, capturing_env, monkeypatch
):
# F2/race: start_session waits on seg_0 OUTSIDE _lock. If a concurrent same-key start (a rapid
# seek-restart) or _enforce_cap replaces _sessions[key] while we wait, our proc dying must NOT
# trigger the SW fallback — that would tear down the NEWER session and breach the transcode cap.
# The identity guard must detect it's been superseded and yield the currently-registered session.
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
class _Newer: # stands in for the session a concurrent _spawn registered
hw = True
tonemap = False
transcode = True
last_access = 0.0
newer = _Newer()
def wait_then_supersede(path, timeout=20.0, proc=None):
# Simulate the concurrent replacement happening during our out-of-lock wait, then report the
# (now-killed) HW proc produced nothing.
stream._sessions["12345"] = newer
return False
monkeypatch.setattr(stream, "wait_for", wait_then_supersede)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
# No SW respawn — only the initial HW _spawn ran; the newer session was left intact.
assert len(_CapturingProc.calls) == 1
assert s is newer # yielded the currently-registered session, did not clobber it
assert stream._sessions["12345"] is newer
def test_a_cap_culled_hw_session_does_not_respawn_and_is_not_reported_missing(
self, capturing_env, monkeypatch
):
# F2/race, the cull sub-case: while we wait on seg_0 out of lock, cross-key cap pressure lets
# _enforce_cap evict THIS session's key entirely (no replacement). The fallback must still not
# respawn (that would re-breach the cap the cull just enforced), and must NOT return None — a None
# is turned by the route into a dishonest "file not found" 404 for a file that exists. It yields
# our own (now-dead) session instead, so the client's segment requests degrade to the retryable
# "not available yet" path.
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (True, False)
)
captured = {}
def wait_then_cull(path, timeout=20.0, proc=None):
captured["s"] = stream._sessions[
"12345"
] # the HW session the initial _spawn just registered
del stream._sessions[
"12345"
] # simulate _enforce_cap dropping the LRU under cap pressure
return False
monkeypatch.setattr(stream, "wait_for", wait_then_cull)
s = stream.start_session(
db=None, item=_FakeItem(codec_video="hevc"), start_s=0.0, transcode=True
)
assert (
len(_CapturingProc.calls) == 1
) # no SW respawn — the cull is respected, cap not re-breached
assert (
s is captured["s"]
) # our own session yielded, NOT None (which the route would 404 as missing)
assert "12345" not in stream._sessions # the culled key stays gone
def test_remux_reads_no_transcode_knobs_and_stream_copies(self, capturing_env, monkeypatch):
# A remux start must stream-copy the video and spawn no niced child (the transcode knobs and
# the preexec nice are gated behind `transcode=True`).
monkeypatch.setattr(stream, "_want_hw", lambda db, item, transcode: False)
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
)
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) # transcode defaults False
assert s is not None and s.transcode is False
(cmd, *_), kwargs = _CapturingProc.calls[0]
assert _venc(cmd) == "copy"
assert kwargs.get("preexec_fn") is None
class TestProbeVideoKind:
"""The ffprobe-based source classifier that routes the transcode pipeline: 8-bit SDR → plain VAAPI;
true-HDR (PQ/HLG) → the OpenCL tonemap graph; 10-bit *SDR* → software (an exact downconvert, NEVER
the HDR tone curve — the F1 distinction). Exercises the REAL function body (not a monkeypatch)."""
@staticmethod
def _probe(monkeypatch, stdout):
class _R:
def __init__(self):
self.stdout = stdout
monkeypatch.setattr(stream.subprocess, "run", lambda *a, **k: _R())
def test_8bit_sdr_is_hwsafe(self, monkeypatch):
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=8\ncolor_transfer=bt709\n")
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
def test_10bit_sdr_pix_fmt_is_sdr10(self, monkeypatch):
# yuv420p10le with a non-HDR transfer (bt2020-10 is NOT PQ/HLG) = 10-bit SDR → software, not
# tonemap. Signalled by the pix_fmt suffix even when bits_per_raw_sample is absent.
self._probe(monkeypatch, "pix_fmt=yuv420p10le\ncolor_transfer=bt2020-10\n")
assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10
def test_bits_per_raw_sample_over_8_sdr_is_sdr10(self, monkeypatch):
self._probe(monkeypatch, "pix_fmt=yuv420p\nbits_per_raw_sample=10\ncolor_transfer=bt709\n")
assert stream._probe_video_kind(Path("/x")) == stream._VK_SDR10
def test_pq_transfer_is_hdr(self, monkeypatch):
# PQ (smpte2084) is true HDR — needs a real tonemap, even if only 8-bit-tagged.
self._probe(
monkeypatch, "pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=smpte2084\n"
)
assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR
def test_hlg_transfer_is_hdr(self, monkeypatch):
# HLG (arib-std-b67) is the other HDR transfer — previously untested; it must classify as HDR.
self._probe(
monkeypatch,
"pix_fmt=yuv420p10le\nbits_per_raw_sample=10\ncolor_transfer=arib-std-b67\n",
)
assert stream._probe_video_kind(Path("/x")) == stream._VK_HDR
def test_empty_probe_output_is_hwsafe(self, monkeypatch):
# A probe that returns nothing (no streams / stripped metadata) → assume hwsafe; the runtime
# HW→SW fallback backstops a wrong guess.
self._probe(monkeypatch, "")
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
def test_probe_failure_assumes_hwsafe(self, monkeypatch):
# ffprobe missing/erroring → assume hwsafe; the runtime HW→SW fallback is the real backstop, so a
# probe miss costs at most one wasted HW attempt, never a stranded item.
def boom(*a, **k):
raise OSError("no ffprobe")
monkeypatch.setattr(stream.subprocess, "run", boom)
assert stream._probe_video_kind(Path("/x")) == stream._VK_HWSAFE
class TestOpenclPresent:
"""The OpenCL-runtime probe (_opencl_present) — the precondition for the HW HDR tonemap graph. Drives
the REAL glob against a temp ICD dir instead of monkeypatching the function, so a regression to the
ICD path / glob / OSError guard would be caught (this routes every HDR source on prod)."""
def test_true_when_an_icd_file_is_registered(self, tmp_path, monkeypatch):
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path)
(tmp_path / "intel.icd").write_text("libintelocl.so\n")
assert stream._opencl_present() is True
def test_false_when_the_dir_is_empty(self, tmp_path, monkeypatch):
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path)
assert stream._opencl_present() is False
def test_false_and_no_raise_when_the_dir_is_missing(self, tmp_path, monkeypatch):
# A missing dir: Path.glob yields nothing (no raise) → False.
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", tmp_path / "nope")
assert stream._opencl_present() is False
def test_false_when_glob_raises_oserror(self, monkeypatch):
# The `except OSError` guard proper: an unreadable ICD dir (permission denied) makes glob RAISE,
# not return empty. That must be swallowed to False, never propagated up through _select_encode
# into start_session (which would crash HDR routing).
class _BoomDir:
def glob(self, pattern):
raise OSError("permission denied")
monkeypatch.setattr(stream, "_OPENCL_ICD_DIR", _BoomDir())
assert stream._opencl_present() is False
class TestWaitForProcFastFail:
"""wait_for(proc=…) must stop as soon as the ffmpeg proc exits (a VAAPI init refusal dies in ~1s
producing nothing) instead of blocking the whole timeout — that fast-fail is what keeps the HW→SW
fallback from turning into a 25s stall. Exercises the REAL wait_for (every other test monkeypatches
it wholesale, so this branch would otherwise never run)."""
class _DeadProc:
def poll(self):
return 1 # already exited non-zero
def test_returns_true_when_the_segment_exists_despite_a_dead_proc(self, tmp_path):
seg = tmp_path / "seg_0.ts"
seg.write_bytes(b"x")
# The proc exited but DID write a first segment → ready. (The final existence re-check.)
assert stream.wait_for(seg, timeout=5.0, proc=self._DeadProc()) is True
def test_returns_false_fast_when_a_dead_proc_wrote_nothing(self, tmp_path):
# Missing segment + dead proc → give up immediately, not after `timeout`. Assert it's fast by
# using a long timeout the test would visibly hang on if the fast-fail branch regressed.
seg = tmp_path / "seg_0.ts"
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
def test_empty_segment_from_a_dead_proc_is_not_ready(self, tmp_path):
# A zero-byte segment is not a real segment (size > 0 required).
seg = tmp_path / "seg_0.ts"
seg.write_bytes(b"")
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
+65
View File
@@ -0,0 +1,65 @@
"""The sliding-window limiter behind /auth/register, password-login, password-reset and demo.
Pure logic, no DB. `reset()` in particular is only ever called by a test fixture (conftest clears
the module-level limiters between tests), so without a test here a refactor could turn it into a
no-op and nothing would notice until an unrelated test started failing with a 429.
"""
from app import ratelimit
from app.ratelimit import RateLimiter
def test_allow_trips_at_the_cap_and_stays_tripped():
lim = RateLimiter(max_events=2, window_seconds=300)
assert lim.allow("ip") is True
assert lim.allow("ip") is True
assert lim.allow("ip") is False
assert lim.allow("ip") is False
def test_hammering_while_blocked_does_not_push_the_window_forward(monkeypatch):
# A blocked attempt must not be recorded, otherwise someone who keeps retrying holds their own
# window open forever — an indefinite lockout for anyone sharing that IP. Only observable across
# the window boundary, so drive the clock: two attempts fill the cap, ten blocked retries land
# just before it expires, and the caller must still be let back in on the far side.
clock = {"t": 1000.0}
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: clock["t"])
lim = RateLimiter(max_events=2, window_seconds=60)
assert lim.allow("ip") is True
assert lim.allow("ip") is True
for _ in range(10):
clock["t"] += 5 # still inside the window; every one of these is refused
assert lim.allow("ip") is False
clock["t"] = 1000.0 + 61 # past the window of the two attempts that actually counted
assert lim.allow("ip") is True
def test_keys_are_independent():
lim = RateLimiter(max_events=1, window_seconds=300)
assert lim.allow("a") is True
assert lim.allow("a") is False
assert lim.allow("b") is True
def test_reset_clears_every_key():
lim = RateLimiter(max_events=1, window_seconds=300)
lim.allow("a")
lim.allow("b")
assert lim.allow("a") is False
lim.reset()
assert lim.allow("a") is True
assert lim.allow("b") is True
def test_expired_attempts_fall_out_of_the_window():
# A zero-length window means every previous attempt is already outside it.
lim = RateLimiter(max_events=1, window_seconds=0)
assert lim.allow("ip") is True
assert lim.allow("ip") is True
+90
View File
@@ -0,0 +1,90 @@
"""What a dead email link says, and what it costs to ask (E9.5 S4 follow-up), DB-lane.
Reported 2026-09-05: delete an account, then open its password-reset link. The card drew a full
"set a new password" form — because a `#reset=` fragment was present, nothing more — and only after
the user typed a password did the server answer 400. No security hole (the account's tokens cascade
away with it, so nothing can be set on a deleted or a re-created account), but a dead end of exactly
the kind this epic exists to remove.
The fix is a check that does NOT spend the token, so the pre-flight can never cost the user their
one real attempt. That is the property most worth pinning here.
"""
from app.routes.auth import RESET_TTL, _issue_token
_PW = "correct horse battery"
_NEW = "brand new password"
def _issue(db, user):
raw = _issue_token(db, user, "reset", RESET_TTL)
db.commit()
return raw
def test_checking_a_live_link_does_not_spend_it(client, db, make_user):
raw = _issue(db, make_user(email="live@example.com", password=_PW))
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
# The whole point: after any number of checks the one real attempt is still there.
assert client.post(
"/auth/password-reset/confirm", json={"token": raw, "password": _NEW}
).json() == {"ok": True}
def test_a_link_whose_account_was_deleted_is_reported_dead(client, db, make_user):
user = make_user(email="gone@example.com", password=_PW)
raw = _issue(db, user)
db.delete(user)
db.commit()
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
# And the confirm agrees, in the structured shape the card renders as a named screen — the two
# must never disagree about one token.
r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW})
assert r.status_code == 400
assert r.json()["detail"] == {"code": "reset", "reason": "invalid"}
def test_a_spent_link_is_reported_dead(client, db, make_user):
raw = _issue(db, make_user(email="spent@example.com", password=_PW))
client.post("/auth/password-reset/confirm", json={"token": raw, "password": _NEW})
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
def test_a_too_short_password_does_not_burn_the_link(client, db, make_user):
raw = _issue(db, make_user(email="short@example.com", password=_PW))
r = client.post("/auth/password-reset/confirm", json={"token": raw, "password": "short"})
assert r.status_code == 400
assert r.json()["detail"]["reason"] == "weak_password"
# A mistyped password must not cost the link — the length check runs BEFORE the token is spent,
# and the card tells the two failures apart by `reason` so only one of them takes the form down.
assert client.post(
"/auth/password-reset/confirm", json={"token": raw, "password": _NEW}
).json() == {"ok": True}
def test_a_dead_verification_link_answers_in_the_same_structured_shape(client):
r = client.post("/auth/verify", json={"token": "no-such-token"})
assert r.status_code == 400
assert r.json()["detail"] == {"code": "verify", "reason": "invalid"}
def test_the_check_never_reports_a_live_link_dead_when_throttled(
client, db, make_user, monkeypatch
):
from app.routes import auth as auth_routes
raw = _issue(db, make_user(email="throttled@example.com", password=_PW))
monkeypatch.setattr(auth_routes._reset_check_limiter, "allow", lambda _ip: False)
# Fail OPEN. This check may only ever save a wasted form; a throttled probe that answered "dead"
# would lock someone out of a link that works.
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": True}
+350
View File
@@ -0,0 +1,350 @@
"""E9.5 review round — the invariants the epic's own review found unpinned.
Every test here corresponds to a defect a reviewer could still introduce with the whole suite green:
a mail whose body never renders in any test, an activation that only works for the exact spelling, a
"we already wrote to them" stamp for a mail that was dropped, an env allow-list that answers a
different question than the gate it backs, and the address pre-emption that E9.5 S1's argument
covered only while the address stayed unverified.
"""
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from app import auth as auth_core
from app import email as email_mod
from app import mailtext
from app.config import settings
from app.models import AuthToken, Invite, User
from app.routes import auth as auth_routes
_PW = "correct horse battery"
@pytest.fixture
def wire(monkeypatch):
"""Capture what `_send` would have put on the wire. Mails are the one surface where a body that
never renders in a test is a body that first renders in someone's inbox."""
out: list[tuple[list[str], str, str]] = []
monkeypatch.setattr(
email_mod,
"_send",
lambda to, subject, body, reply_to=None: out.append((to, subject, body)) is None,
)
return out
@pytest.fixture
def smtp_on(monkeypatch):
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
# --- every mail actually renders ------------------------------------------------------------------
# One call per `send_*`, with arguments of the right shape. The parity test compares HU against EN
# and can therefore never catch a template whose placeholders disagree with its CALLER — that
# mismatch is a KeyError inside a background task, i.e. a mail that silently never goes out. Three
# mailers this epic rewrote (`password_reset`, `role_changed`, `account_unsuspended`) had no test
# that executed their `.format()` at all.
_EVERY_MAIL = [
("send_access_approved", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
("send_access_approved", ("to@example.com", "LANG", None, True, 24)),
("send_access_approved", ("to@example.com", "LANG", None, False, 24)),
("send_access_invitation", ("to@example.com", "LANG", "https://x/#verify=t", True, 168)),
("send_access_invitation", ("to@example.com", "LANG", None, False, 24)),
("send_access_denied", ("to@example.com", "LANG")),
("send_role_changed", ("to@example.com", "LANG", "admin", "op@example.com")),
("send_role_changed", ("to@example.com", "LANG", "user", None)),
("send_account_unsuspended", ("to@example.com", "LANG", "op@example.com")),
("send_account_deleted", ("to@example.com", "LANG", None)),
("send_account_suspended", ("to@example.com", "LANG", "op@example.com")),
("send_account_suspended", ("to@example.com", "LANG", None)),
("send_admin_new_request", (["a@example.com"], "LANG", "who@example.com")),
("send_verify_email", ("to@example.com", "LANG", "https://x/#verify=t", 24)),
("send_password_reset", ("to@example.com", "LANG", "https://x/#reset=t")),
("send_test", ("to@example.com", "LANG")),
]
@pytest.mark.parametrize("lang", mailtext.SUPPORTED)
@pytest.mark.parametrize("name,args", _EVERY_MAIL, ids=[f"{n}{a[2:3]}" for n, a in _EVERY_MAIL])
def test_every_mail_renders_in_every_language(wire, name, args, lang):
getattr(email_mod, name)(*[lang if a == "LANG" else a for a in args])
(_, subject, body) = wire[0]
assert subject and body
# A template that lost its interpolation renders the brace text verbatim.
assert "{" not in subject and "{" not in body
def test_a_weeks_link_is_described_in_days_not_hours(wire):
# The S6 approval token lives seven days, and the mail used to say "expires in 168 hours".
email_mod.send_access_approved("to@example.com", "en", "https://x/#verify=t", True, 168)
assert "7 days" in wire[0][2] and "168" not in wire[0][2]
email_mod.send_verify_email("to@example.com", "hu", "https://x/#verify=t", 24)
assert "24 óra" in wire[1][2]
# --- what a second registration may and may not do -------------------------------------------------
def test_a_later_registration_never_touches_an_existing_password(db, client, smtp_on, wire):
"""The rule E9.5's review round 2 put back, having tried the opposite and found it worse.
"While the address is unverified, the newest registration owns it" looked like a fix for address
pre-emption. It is symmetric — an attacker can always be last — so it closed nothing, and it
handed an unauthenticated caller the power to overwrite a real registrant's chosen password and
delete the confirmation link already sitting in their inbox. On a SEC-10-stripped row it would
have installed a password where the whole point is that there is none. Reverted; the owner's
recourse is password-reset, which proves the mailbox. Residual logged as SEC-16.
"""
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
)
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
before = user.password_hash
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
)
db.expire_all()
assert db.get(User, user.id).password_hash == before
def test_a_later_registration_leaves_a_link_already_in_flight_alive(db, client, smtp_on, wire):
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the first password"}
)
db.expire_all()
user = db.execute(select(User).where(User.email == "victim@example.com")).scalar_one()
first = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
# Not just a subset check below: `set() <= set()` is vacuously true, so without this the test
# would go green while asserting nothing the day registration stops issuing a token.
assert len(first) == 1
client.post(
"/auth/register", json={"email": "victim@example.com", "password": "the second password"}
)
db.expire_all()
live = db.execute(select(AuthToken.id).where(AuthToken.user_id == user.id)).scalars().all()
# The re-send adds a link; it must not invalidate the one the owner may already be holding.
assert set(first) <= set(live) and len(live) == len(first) + 1
def test_a_stripped_account_keeps_its_recovery_stamp(db, client, smtp_on, make_user):
"""Prod carries accounts SEC-10 left with no password and a `password_revoked_at` stamp. That
stamp is the only reason password-reset still accepts them; an anonymous POST must not be able to
clear it, nor to put a password on a row whose whole state is "there is none"."""
stripped = make_user(
email="stripped@example.com",
password=None,
email_verified=False,
is_active=True,
password_revoked_at=datetime.now(timezone.utc),
)
client.post(
"/auth/register", json={"email": "stripped@example.com", "password": "an attacker password"}
)
db.expire_all()
row = db.get(User, stripped.id)
assert row.password_hash is None and row.password_revoked_at is not None
def test_a_denied_sibling_is_not_reopened_while_a_pending_row_exists(db, client):
# Canonical siblings exist on prod. Picking the denied one reopens it and re-notifies every
# admin, putting one person in the queue twice — and the exact-spelling tie-break made that
# deterministic rather than merely possible.
db.add_all(
[
Invite(email="pair.one@gmail.com", status="pending"),
Invite(email="pairone@gmail.com", status="denied"),
]
)
db.commit()
client.post("/auth/request-access", json={"email": "pairone@gmail.com"})
db.expire_all()
rows = {i.email: i.status for i in db.execute(select(Invite)).scalars().all()}
assert rows == {"pair.one@gmail.com": "pending", "pairone@gmail.com": "denied"}
# --- the env bootstrap answers the same question as the gate --------------------------------------
def test_the_env_allow_list_is_matched_canonically(db, monkeypatch):
# An operator writing the dotted spelling into ALLOWED_EMAILS was refused when Google answered
# with the undotted primary — the 2026-09-03 lockout, on the bootstrap path.
monkeypatch.setattr(settings, "allowed_emails", "first.last@gmail.com")
assert auth_core.is_allowed(db, "firstlast@gmail.com") is True
assert auth_core.is_allowed(db, "first.last+tag@gmail.com") is True
assert auth_core.is_allowed(db, "someone.else@gmail.com") is False
def test_an_env_granted_address_cannot_be_shadowed_by_a_stranger(db, client, monkeypatch):
# `is_allowed` short-circuits on ANY invite row for the canonical form, so before this an
# unauthenticated caller could request access under a sibling spelling and permanently shadow an
# env grant. With the env list matched canonically the request never opens a row.
monkeypatch.setattr(settings, "allowed_emails", "bob@gmail.com")
client.post("/auth/request-access", json={"email": "b.ob@gmail.com"})
assert db.execute(select(Invite)).scalars().all() == []
assert auth_core.is_allowed(db, "bob@gmail.com") is True
def test_a_dotted_admin_emails_entry_still_bootstraps_its_admin(monkeypatch):
monkeypatch.setattr(settings, "admin_emails", "the.operator+siftlode@gmail.com")
assert "theoperator@gmail.com" in auth_core.env_admin_canonicals()
# --- approving decides about a MAILBOX, not a spelling --------------------------------------------
def test_approving_one_spelling_activates_the_account_registered_under_another(
db, make_user, as_user, smtp_on, wire
):
# The 2026-09-03 incident in one test: the admin approves the address as typed into the request,
# and the account exists under the other Gmail spelling of the same mailbox.
admin = make_user(email="admin@example.com", role="admin")
user = make_user(email="dotless@gmail.com", password=_PW, is_active=False)
user.email = "dotless@gmail.com"
inv = Invite(email="dot.less@gmail.com", status="pending")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
assert r.status_code == 200
db.expire_all()
assert db.get(User, user.id).is_active is True
def test_deleting_one_of_two_accounts_leaves_the_other_one_allowed(db, make_user, as_user):
# Prod carries two accounts for one Gmail mailbox. A canonical invite sweep on delete took the
# survivor's grant with it, refusing their next sign-in — this epic's founding bug, self-inflicted.
admin = make_user(email="admin@example.com", role="admin")
doomed = make_user(email="pair.one@gmail.com", password=_PW)
make_user(email="pairone@gmail.com", password=_PW)
db.add_all([Invite(email="pair.one@gmail.com", status="approved")])
db.commit()
r = as_user(admin).delete(f"/api/admin/users/{doomed.id}")
assert r.status_code == 200
assert auth_core.is_allowed(db, "pairone@gmail.com") is True
# --- a button an admin pressed must not report a send that did not happen -------------------------
def test_a_failed_invitation_changes_nothing_the_admin_was_told_about(
db, make_user, as_user, smtp_on, monkeypatch
):
monkeypatch.setattr(email_mod, "send_access_invitation", lambda *a, **kw: False)
admin = make_user(email="admin@example.com", role="admin")
stranger = make_user(
email="stranger@example.com", password=_PW, email_verified=False, is_active=False
)
inv = Invite(email="stranger@example.com", status="approved")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite", json={"lang": "en"})
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
db.expire_all()
# The column exists to answer "did I already write to them?", and the UI dims the button and
# warns about a second mail on the strength of it. A dropped send must not make it say yes.
assert db.get(Invite, inv.id).invited_at is None
# And a request the UI reports as a failure must not have let somebody in behind the admin's
# back — the activation belongs after a mail that actually left.
assert db.get(User, stranger.id).is_active is False
def test_a_failed_resend_says_so_instead_of_reporting_ok(
db, make_user, as_user, smtp_on, monkeypatch
):
monkeypatch.setattr(email_mod, "send_verify_email", lambda *a, **kw: False)
admin = make_user(email="admin@example.com", role="admin")
stuck = make_user(email="stuck@example.com", password=_PW, email_verified=False)
r = as_user(admin).post(f"/api/admin/users/{stuck.id}/resend-verification")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "send_failed"
# --- refusals and links that are already dead ------------------------------------------------------
def test_an_expired_reset_link_is_reported_dead_without_being_spent(db, client, make_user):
# The endpoint exists because a spent or stale link used to draw the full "set a new password"
# form. Expiry is the reason its docstring gives, and it was the one case no test covered.
user = make_user(email="late@example.com", password=_PW)
raw = auth_routes._issue_token(db, user, "reset", timedelta(hours=1))
token = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalar_one()
token.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db.commit()
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
r = client.post(
"/auth/password-reset/confirm", json={"token": raw, "password": "a new one 123"}
)
assert r.status_code == 400
def test_a_verify_token_cannot_pass_as_a_reset_link(db, client, make_user):
user = make_user(email="mixed@example.com", password=_PW)
raw = auth_routes._issue_token(db, user, "verify", timedelta(hours=1))
assert client.post("/auth/password-reset/check", json={"token": raw}).json() == {"ok": False}
def test_a_throttled_login_says_nothing_about_the_account(db, client, make_user, monkeypatch):
# The two refusals that answer BEFORE the password is proven must stay byte-identical for a real
# account and an unknown one; only `invalid` was pinned.
make_user(email="real@example.com", password=_PW)
monkeypatch.setattr(auth_routes._login_limiter, "allow", lambda key: False)
real = client.post("/auth/password-login", json={"email": "real@example.com", "password": _PW})
unknown = client.post(
"/auth/password-login", json={"email": "nobody@example.com", "password": _PW}
)
assert real.status_code == unknown.status_code == 429
assert real.json() == unknown.json() == {"detail": {"code": "login", "reason": "rate_limited"}}
# --- admins hear about a request in their own language --------------------------------------------
def test_two_admins_two_languages_two_mails(db, client, make_user, smtp_on, wire):
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
client.post("/auth/request-access", json={"email": "asking@example.com", "lang": "en"})
notices = [w for w in wire if "asking@example.com" in w[1]]
assert len(notices) == 2
by_recipient = {w[0][0]: w[1] for w in notices}
assert by_recipient["hu.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
"hu"
].format(requester="asking@example.com")
assert by_recipient["en.admin@example.com"] == mailtext.MAIL_TEXT["admin_new_request.subject"][
"en"
].format(requester="asking@example.com")
def test_an_env_admin_without_an_account_is_written_to_in_the_default(db, monkeypatch):
monkeypatch.setattr(settings, "admin_emails", "bootstrap@example.com")
assert auth_routes.admin_notify_groups(db) == {mailtext.DEFAULT: ["bootstrap@example.com"]}
+117 -8
View File
@@ -7,9 +7,9 @@ guards that keep an upload out of paths that assume a re-fetchable source (edit
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
monkeypatch `_probe` so they don't shell out to ffprobe; the classifier's OWN parsing (image →
file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier` below, which
stubs one level down (`run_ffprobe_json`) and exercises the real `_probe`.
monkeypatch the shared `probe.probe_media` so they don't shell out to ffprobe; the classifier's OWN
parsing (image → file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier`
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
"""
from pathlib import Path
@@ -18,6 +18,7 @@ import pytest
from app.config import settings
from app.downloads import mediakind, quota, storage
from app.downloads import probe as probemod
from app.downloads import upload as uploadmod
from app.models import DownloadQuota, MediaAsset
@@ -42,7 +43,9 @@ def _root(tmp_path, monkeypatch):
def _probe(monkeypatch, result):
monkeypatch.setattr(uploadmod, "_probe", lambda _p: result)
# upload ingest now calls the shared `probe.probe_media` (same ground-truth ffprobe the download
# worker uses); patch it at that shared home.
monkeypatch.setattr(probemod, "probe_media", lambda _p: result)
def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch):
@@ -325,16 +328,16 @@ def test_ingest_cleans_up_placed_files_on_commit_failure(db, make_user, _root, m
assert db.query(MediaAsset).count() == 0 # the flushed asset rolled back — no dangling row
# --- classifier: the REAL _probe parsing (stubs ffprobe one level down) ----------------------
# The ingest/route tests above stub _probe wholesale; these exercise _probe's OWN logic — image
# --- classifier: the REAL probe_media parsing (stubs ffprobe one level down) ------------------
# The ingest/route tests above stub probe_media wholesale; these exercise its OWN logic — image
# detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a
# regression in the classifier the force-download guarantee leans on is actually caught.
class TestProbeClassifier:
def _probe_with(self, monkeypatch, data):
monkeypatch.setattr(uploadmod.editmod, "run_ffprobe_json", lambda *a, **k: data)
return uploadmod._probe(Path("x"))
monkeypatch.setattr(probemod.editmod, "run_ffprobe_json", lambda *a, **k: data)
return probemod.probe_media(Path("x"))
def test_probe_failure_is_a_generic_file(self, monkeypatch):
# run_ffprobe_json returns None (ffprobe failed / not media) → all None → derive() == FILE.
@@ -392,3 +395,109 @@ class TestProbeClassifier:
}
v, a, w, h, d = self._probe_with(monkeypatch, data)
assert d is None and mediakind.derive(v, a) == mediakind.AUDIO
# --- resolve_media_facts: probe-of-the-file wins over yt-dlp's info (the FB regression fix) ---------
# The download worker's finalize must classify from the ACTUAL file, not the extractor's `info` dict.
# yt-dlp omits vcodec/acodec/dims for some sources (Facebook), and deriving media_kind from those
# nulls turned a real H.264 video into a download-only 'file' (no in-app play). These pin that the
# probe is authoritative and `info` is only a gap-filler.
class TestResolveMediaFacts:
# These stub run_ffprobe_json (one level below probe_media) so the REAL probe parsing + the
# probe-vs-info precedence are exercised together — the precedence keys off whether ffprobe RAN.
@staticmethod
def _ffprobe(monkeypatch, data):
monkeypatch.setattr(probemod.editmod, "run_ffprobe_json", lambda *a, **k: data)
def test_probe_wins_when_info_lacks_codecs(self, monkeypatch):
# THE original regression: info has no vcodec/acodec/dims (Facebook), but the file probes as
# h264/aac.
self._ffprobe(
monkeypatch,
{
"format": {"format_name": "mov,mp4,m4a"},
"streams": [
{"codec_type": "video", "codec_name": "h264", "width": 720, "height": 1280},
{"codec_type": "audio", "codec_name": "aac"},
],
},
)
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"width": None, "height": None})
assert (v, a, w, h) == ("h264", "aac", 720, 1280)
assert mediakind.derive(v, a) == mediakind.VIDEO # NOT 'file'
def test_probe_wins_over_a_DIFFERING_populated_info(self, monkeypatch):
# Both the probe AND info are populated but DISAGREE (info carries a pre-remux/pre-merge codec).
# The finished file on disk is ground truth, so the probe wins outright — pins the precedence a
# `info or probe` inversion would silently break.
self._ffprobe(
monkeypatch,
{
"format": {"format_name": "mov,mp4,m4a"},
"streams": [
{"codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080},
{"codec_type": "audio", "codec_name": "aac"},
],
},
)
v, a, w, h = probemod.resolve_media_facts(
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 640, "height": 480}
)
assert (v, a, w, h) == ("h264", "aac", 1920, 1080) # probe, not info
def test_cover_art_audio_download_is_not_resurrected_to_video_by_info(self, monkeypatch):
# THE bug this fix targets: an audio-mode download from a progressive source embeds cover art,
# and yt-dlp's info still carries the source's video codec. The probe correctly finds only audio
# (the attached_pic is skipped); info's vcodec must NOT resurrect a video track → media_kind
# stays audio, not a broken in-app 'video'.
self._ffprobe(
monkeypatch,
{
"format": {"format_name": "mp3", "duration": "180.0"},
"streams": [
{
"codec_type": "video",
"codec_name": "mjpeg",
"disposition": {"attached_pic": 1},
},
{"codec_type": "audio", "codec_name": "mp3"},
],
},
)
v, a, w, h = probemod.resolve_media_facts(
Path("x"), {"vcodec": "avc1", "acodec": "mp4a", "width": 1280, "height": 720}
)
assert v is None and a == "mp3" # info's avc1 did NOT win
assert mediakind.derive(v, a) == mediakind.AUDIO # NOT VIDEO
def test_still_image_download_is_not_resurrected_to_video_by_info(self, monkeypatch):
# A downloaded still image: the probe returns no vcodec (image-format guard). info's image
# "vcodec" must not turn it into a broken 'video' → stays a generic file.
self._ffprobe(
monkeypatch,
{
"format": {"format_name": "png_pipe"},
"streams": [{"codec_type": "video", "codec_name": "png", "width": 8, "height": 8}],
},
)
v, a, w, h = probemod.resolve_media_facts(
Path("x"), {"vcodec": "png", "width": 8, "height": 8}
)
assert v is None and mediakind.derive(v, a) == mediakind.FILE
def test_info_fills_in_only_when_ffprobe_could_not_read_the_file(self, monkeypatch):
# ffprobe failed entirely (None) → fall back to info so we never regress the working path.
self._ffprobe(monkeypatch, None)
v, a, w, h = probemod.resolve_media_facts(
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 1920, "height": 1080}
)
assert (v, a, w, h) == ("vp9", "opus", 1920, 1080)
def test_info_none_codec_string_is_treated_as_absent(self, monkeypatch):
# yt-dlp writes the literal "none" for a missing stream — it must not become a codec name.
self._ffprobe(monkeypatch, None) # probe failed → info path, where the "none" scrub applies
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"vcodec": "none", "acodec": "aac"})
assert (v, a) == (None, "aac")
assert mediakind.derive(v, a) == mediakind.AUDIO
+249
View File
@@ -0,0 +1,249 @@
"""One mail, one click — the branching welcome/invitation mail (E9.5 S6), DB-lane.
The funnel's common ordering makes a person open TWO messages: one saying their access is approved,
another asking them to confirm their address. The C option carries the confirmation link inside the
mail that is already going out, so the usual case is one mail and one click.
Also here: the silent no-op that is the most likely explanation for the 2026-09-03 report that a
confirmation mail "never arrived". Re-registering an address that already had an unconfirmed account
sent nothing at all, while answering with the same cheerful "check your inbox".
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.models import AuditLog, AuthToken, Invite, User
_PW = "correct horse battery"
def _capture(monkeypatch, name):
"""Record the arguments a given email_mod.send_* was called with."""
from app import email as email_mod
calls: list[tuple] = []
monkeypatch.setattr(email_mod, name, lambda *a, **kw: calls.append((a, kw)) or True)
return calls
def _smtp_on(monkeypatch):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
def _approve(client, invite_id):
r = client.post(f"/api/admin/invites/{invite_id}/approve")
assert r.status_code == 200
return r
# --- the approval mail branches ------------------------------------------------------------------
def test_an_unconfirmed_account_gets_the_confirmation_link_in_the_approval_mail(
db, make_user, as_user, monkeypatch
):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="waiting@example.com", status="pending")
db.add(inv)
db.commit()
make_user(email="waiting@example.com", password=_PW, email_verified=False, is_active=False)
_approve(as_user(admin), inv.id)
(email, _lang, link, has_account, hours), _ = sent[0]
assert email == "waiting@example.com" and has_account is True
assert link and "#verify=" in link
# The whole point: this is the ONLY message they now have to open.
assert hours == 24 * 7
def test_the_link_in_that_mail_actually_confirms_the_address(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="waiting@example.com", status="pending")
db.add(inv)
db.commit()
user = make_user(
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
)
_approve(as_user(admin), inv.id)
token = sent[0][0][2].split("#verify=")[1]
r = as_user(admin).post("/auth/verify", json={"token": token})
assert r.status_code == 200
db.expire_all()
assert db.get(User, user.id).email_verified is True
def test_that_link_outlives_a_weekend(db, make_user, as_user, monkeypatch):
# An approval is not a mail anyone sits waiting for; the standard 24h would routinely expire
# before it is read, and the one-mail promise with it.
_smtp_on(monkeypatch)
_capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="waiting@example.com", status="pending")
db.add(inv)
db.commit()
user = make_user(
email="waiting@example.com", password=_PW, email_verified=False, is_active=False
)
_approve(as_user(admin), inv.id)
tok = db.execute(select(AuthToken).where(AuthToken.user_id == user.id)).scalars().all()[-1]
assert tok.expires_at > datetime.now(timezone.utc) + timedelta(days=6)
def test_an_already_confirmed_account_is_just_told_to_sign_in(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="ready@example.com", status="pending")
db.add(inv)
db.commit()
make_user(email="ready@example.com", password=_PW, is_active=False)
_approve(as_user(admin), inv.id)
(_, _lang, link, has_account, _), _ = sent[0]
assert link is None and has_account is True # no link to offer, and none needed
def test_someone_with_no_account_yet_is_told_a_second_mail_is_coming(
db, make_user, as_user, monkeypatch
):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_access_approved")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="stranger@example.com", status="pending")
db.add(inv)
db.commit()
_approve(as_user(admin), inv.id)
(_, _lang, link, has_account, _), _ = sent[0]
# Nothing to link to yet. Saying a confirmation mail follows is what makes its ABSENCE
# noticeable — the failure that went unnoticed on 2026-09-03.
assert link is None and has_account is False
# --- the deliberate invitation --------------------------------------------------------------------
def test_adding_an_address_to_the_whitelist_mails_nobody(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
approved = _capture(monkeypatch, "send_access_approved")
invited = _capture(monkeypatch, "send_access_invitation")
admin = make_user(email="admin@example.com", role="admin")
r = as_user(admin).post("/api/admin/invites", json={"email": "maybe@example.com"})
# Whitelisting opens a door as a possibility — often already agreed in person — and an address
# can sit approved for weeks. A mail fired automatically here would be nudging someone who never
# asked to be nudged.
assert r.status_code == 200 and r.json()["invited_at"] is None
assert approved == [] and invited == []
def test_the_admin_can_then_invite_them_on_purpose(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
invited = _capture(monkeypatch, "send_access_invitation")
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
assert r.status_code == 200 and r.json()["invited_at"] is not None
assert invited[0][0][0] == "maybe@example.com"
# Recorded on the row, so "did I already write to them?" has an answer where the decision is
# made — the wrong answer means mailing a real person twice.
db.expire_all()
assert db.get(Invite, inv.id).invited_at is not None
assert "invite_send" in db.execute(select(AuditLog.action)).scalars().all()
def test_an_undecided_request_cannot_be_invited(db, make_user, as_user, monkeypatch):
_smtp_on(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="asking@example.com", status="pending")
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "not_approved"
def test_the_invitation_says_so_when_the_instance_cannot_send_mail(
db, make_user, as_user, monkeypatch
):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
admin = make_user(email="admin@example.com", role="admin")
inv = Invite(email="maybe@example.com", status="approved", decided_by=admin.email)
db.add(inv)
db.commit()
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/invite")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "email_off"
db.expire_all()
assert db.get(Invite, inv.id).invited_at is None # nothing sent, nothing recorded
# --- the silent register no-op --------------------------------------------------------------------
def test_registering_again_re_sends_the_link_instead_of_sending_nothing(db, client, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_verify_email")
assert (
client.post(
"/auth/register", json={"email": "twice@example.com", "password": _PW}
).status_code
== 200
)
assert len(sent) == 1
r = client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
# The old behaviour was to return in silence: same cheerful "check your inbox", no mail. That is
# the most likely explanation for the 2026-09-03 confirmation mail that "never arrived" — none
# was ever sent. The response stays uniform; only the dead end is gone.
assert r.status_code == 200 and r.json() == {"status": "ok"}
assert len(sent) == 2
assert sent[1][0][0] == "twice@example.com"
def test_registering_again_does_not_create_a_second_account(db, client, monkeypatch):
_smtp_on(monkeypatch)
_capture(monkeypatch, "send_verify_email")
for _ in range(2):
client.post("/auth/register", json={"email": "twice@example.com", "password": _PW})
db.expire_all()
rows = db.execute(select(User).where(User.email == "twice@example.com")).scalars().all()
assert len(rows) == 1
def test_a_confirmed_account_still_gets_nothing(db, client, make_user, monkeypatch):
_smtp_on(monkeypatch)
sent = _capture(monkeypatch, "send_verify_email")
make_user(email="done@example.com", password=_PW) # already verified
r = client.post("/auth/register", json={"email": "done@example.com", "password": _PW})
# There is nothing to confirm, so there is nothing to send — and mailing "confirm your address"
# to someone who already did would be a lie.
assert r.status_code == 200
assert sent == []
+14 -3
View File
@@ -17,6 +17,7 @@ import {
import { notify, removeByMetaKind } from "./lib/notifications";
import { LS, setAccountRaw } from "./lib/storage";
import { usePref } from "./lib/usePref";
import { useVerifyEntry } from "./lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useChannelLayout } from "./components/ChannelsProvider";
@@ -37,6 +38,7 @@ import { StateMessage } from "./components/QueryState";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
import VerifyBanner from "./components/VerifyBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
// The built app version, NOT imported from releaseNotes — importing anything from that module
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
@@ -54,6 +56,11 @@ export default function App() {
const { t, i18n } = useTranslation();
const { page, channelView, ytSearch } = useNavigation();
const { setPage, enterYtSearch } = useNavigationActions();
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
// that branch never renders when signed in — the token was silently dropped. Signed out, the
// outcome goes to the auth card's banners; signed in, to the top-of-app bar.
const verifyEntry = useVerifyEntry();
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
@@ -240,7 +247,10 @@ export default function App() {
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
removeByMetaKind("access-requests");
const pending = meQuery.data.pending_invites ?? 0;
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
// address was approved before they registered. The second used to produce NO signal at all —
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
const pending = (meQuery.data.pending_invites ?? 0) + (meQuery.data.pending_activations ?? 0);
if (pending > 0) {
notify({
level: "warning",
@@ -259,7 +269,7 @@ export default function App() {
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
}, [meQuery.data?.role, meQuery.data?.pending_invites, meQuery.data?.pending_activations]);
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) {
@@ -288,7 +298,7 @@ export default function App() {
</StateMessage>
</div>
);
if (!meQuery.data) return <Welcome />;
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
const pageFallback = (
@@ -386,6 +396,7 @@ export default function App() {
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
banners={
<>
<VerifyBanner entry={verifyEntry} />
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
</>
+418 -8
View File
@@ -2,12 +2,34 @@ import { useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import {
Ban,
Check,
Clock,
MailWarning,
Mails,
RotateCcw,
Send,
Shield,
Trash2,
UserPlus,
X,
} from "lucide-react";
import {
api,
HttpError,
type AdminUserRow,
type Invite,
type Me,
type PendingAccessRow,
} from "../lib/api";
import { notify } from "../lib/notifications";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import Tooltip from "./Tooltip";
import InlineNotice from "./InlineNotice";
import { Section } from "./ui/form";
import SegmentedToggle from "./SegmentedToggle";
import { LANGUAGES, type LangCode } from "../i18n";
import { LoadingState, StateMessage } from "./QueryState";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
@@ -26,7 +48,14 @@ export default function AdminUsers() {
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
{ id: "access", label: t("users.tabs.access"), badge: me.pending_invites },
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
// address was approved BEFORE they registered — the second never reaches the invite queue,
// so the badge used to read zero while somebody sat locked out (roadmap UX-25).
{
id: "access",
label: t("users.tabs.access"),
badge: me.pending_invites + me.pending_activations,
},
{ id: "demo", label: t("users.tabs.demo") },
];
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
@@ -40,7 +69,12 @@ export default function AdminUsers() {
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && <AdminInvites />}
{active === "access" && (
<>
<AdminInvites />
<PendingAccess />
</>
)}
{active === "demo" && <AdminDemo />}
</div>
</>
@@ -260,17 +294,217 @@ function UsersRoles({ me }: { me: Me }) {
);
}
/** Did the invitation fail because this instance has no working SMTP? */
function inviteEmailOff(err: HttpError): boolean {
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
return d?.code === "invite_send" && d.reason === "email_off";
}
// --- Sign-ups that are still outside (roadmap UX-25) --------------------------------------------
//
// The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
// queue row at all — `_decide` only takes a pending invite — so the Access-requests screen above was
// empty while they sat locked out. The only working move was re-typing the address into the
// whitelist box, which nobody would guess. This section makes the state visible and the fix one
// click, WITHOUT changing who may cause what: the button is admin-authenticated and explicit. Two
// earlier attempts moved authority instead (auto-activating on registration, reopening the approved
// invite) and were reverted after review.
function PendingAccess() {
const { t } = useTranslation();
const qc = useQueryClient();
const rows = useQuery({
queryKey: qk.adminPendingAccess(),
queryFn: () => api.adminPendingAccess(),
});
// Activating changes /api/me's badge count too, so refresh both or the number lies until reload.
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
qc.invalidateQueries({ queryKey: qk.me() });
qc.invalidateQueries({ queryKey: qk.adminUsers() });
};
const activate = useMutation({
mutationFn: (u: PendingAccessRow) => api.activateUser(u.id),
onSuccess: (_r, u) => {
refresh();
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
},
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
});
const resend = useMutation({
mutationFn: (u: PendingAccessRow) => api.resendUserVerification(u.id),
onSuccess: (_r, u) => {
refresh();
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
},
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
onError: (err) =>
notify({
level: "error",
message:
err instanceof HttpError && isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
});
const list = rows.data ?? [];
const busy = activate.isPending || resend.isPending;
if (rows.isSuccess && list.length === 0) return null; // nothing stuck: no empty box to explain
return (
<Section card title={t("users.pendingAccess.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.pendingAccess.body")}</p>
{rows.isError && !rows.data ? (
<StateMessage tone="error" onRetry={() => rows.refetch()}>
{t("common.loadError")}
</StateMessage>
) : rows.isLoading && !rows.data ? (
<LoadingState />
) : (
<div className="space-y-1.5">
{list.map((u) => (
<PendingAccessRowView
key={u.id}
row={u}
busy={busy}
onActivate={() => activate.mutate(u)}
onResend={() => resend.mutate(u)}
/>
))}
</div>
)}
</Section>
);
}
/** Did the resend fail because this instance has no working SMTP? */
function isEmailOff(err: HttpError): boolean {
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
return d?.code === "resend" && d.reason === "email_off";
}
// One stuck sign-up. The row names WHICH gate is shut rather than just offering buttons — the same
// reasoning as the sign-in card's blocked panel, from the admin's side: an admin about to let someone
// in should see that the address has not been confirmed yet, because on a no-SMTP instance that is
// the difference between the owner and whoever registered the address first (roadmap SEC-15).
function PendingAccessRowView({
row,
busy,
onActivate,
onResend,
}: {
row: PendingAccessRow;
busy: boolean;
onActivate: () => void;
onResend: () => void;
}) {
const { t } = useTranslation();
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{row.email}</div>
<div className="text-2xs text-muted flex items-center gap-2 mt-0.5 flex-wrap">
{row.created_at && (
<span>
{t("users.pendingAccess.signedUp", {
time: new Date(row.created_at).toLocaleString(),
})}
</span>
)}
{row.email_verified ? (
<span className="inline-flex items-center gap-1 text-success">
<Check className="w-3 h-3" />
{t("users.pendingAccess.emailConfirmed")}
</span>
) : (
<span className="inline-flex items-center gap-1 text-warn">
<MailWarning className="w-3 h-3" />
{t("users.pendingAccess.emailUnconfirmed")}
</span>
)}
{!row.is_active && (
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
{t("users.pendingAccess.inactive")}
</span>
)}
</div>
</div>
{!row.email_verified && (
<Tooltip hint={t("users.pendingAccess.resendHint")}>
<button
onClick={onResend}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
aria-label={t("users.pendingAccess.resend")}
>
<Send className="w-4 h-4" />
</button>
</Tooltip>
)}
{row.can_activate && (
<Tooltip hint={t("users.pendingAccess.activateHint")}>
<button
onClick={onActivate}
disabled={busy}
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
>
{t("users.pendingAccess.activate")}
</button>
</Tooltip>
)}
</div>
);
}
/** The mail language for one invitation. Owns its own display state and reports every change up
* through `onPick`, because the dialog that hosts it resolves to a plain boolean — the choice has
* to live in the caller's closure, not in this component. Endonyms come from the app's own
* language list, so a third locale would appear here for free. */
function InviteLangPicker({
initial,
onPick,
}: {
initial: LangCode;
onPick: (lang: LangCode) => void;
}) {
const { t } = useTranslation();
const [lang, setLang] = useState<LangCode>(initial);
return (
<SegmentedToggle
className="mt-3"
size="md"
ariaLabel={t("settings.invites.inviteLang")}
label={t("settings.invites.inviteLang")}
options={LANGUAGES.map((l) => ({ value: l.code, label: l.label }))}
value={lang}
onChange={(v) => {
setLang(v);
onPick(v);
}}
/>
);
}
// --- Access requests (the Invite whitelist) — moved verbatim from Settings → Account -------
function AdminInvites() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const me = useMe();
const invites = useQuery({ queryKey: qk.adminInvites(), queryFn: () => api.adminInvites() });
const [newEmail, setNewEmail] = useState("");
const refresh = () => {
qc.invalidateQueries({ queryKey: qk.adminInvites() });
qc.invalidateQueries({ queryKey: qk.me() }); // updates the pending badge
// The stuck-sign-ups section below describes the same backlog and changes with every decision
// here: approving an address moves a registrant INTO it (active, still unverified), whitelisting
// one takes a row out. Leaving it stale hides the resend button this epic added for them.
qc.invalidateQueries({ queryKey: qk.adminPendingAccess() });
};
const approve = useMutation({
mutationFn: ({ id }: { id: number; email: string }) => api.approveInvite(id),
@@ -297,6 +531,92 @@ function AdminInvites() {
});
if (ok) deny.mutate(i.id);
};
const remove = useMutation({
mutationFn: (id: number) => api.deleteInvite(id),
onSuccess: (d) => {
refresh();
// Removing the row is not always a revoke: is_allowed falls back to the env allow-lists, so
// for an address on those the person keeps access. Saying "removed" alone would mislead.
if (d.still_allowed_by_env)
notify({ level: "warning", message: t("settings.invites.deletedStillAllowed") });
else notify({ level: "success", message: t("settings.invites.deleted") });
},
onError: (e) => {
// A 409 means the list was stale — someone registered on that address since it loaded. Say
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
const conflict = e instanceof HttpError && e.status === 409;
notify({
level: "error",
message: t(
conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed",
),
});
if (conflict) refresh();
},
});
const onDelete = async (i: Invite) => {
const ok = await confirm({
title: t("settings.invites.deleteTitle"),
// Removing the row is not a revoke when the address is also in the server's env allow-lists —
// say so while the admin can still change their mind, not in a toast after the fact.
message: t(
i.still_allowed_by_env
? "settings.invites.deleteConfirmStillAllowed"
: "settings.invites.deleteConfirm",
{ email: i.email },
),
confirmLabel: t("settings.invites.delete"),
danger: true,
});
if (ok) remove.mutate(i.id);
};
// Sending is its OWN act, never a side effect of approving an address: the operator opens a door
// as a possibility (often already agreed in person), and a mail fired automatically would be
// nudging someone who never asked. `invited_at` comes back on the row so a second click is a
// decision rather than an accident.
const invite = useMutation({
mutationFn: (v: { inv: Invite; lang: LangCode }) => api.sendInvitation(v.inv.id, v.lang),
onSuccess: (_r, v) => {
refresh();
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
},
onError: (err) =>
notify({
level: "error",
message:
err instanceof HttpError && inviteEmailOff(err)
? t("settings.invites.inviteEmailOff")
: t("settings.invites.inviteFailed"),
}),
});
// The one recipient the app has no language signal about: the admin typed this address in, so
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
// is also where the "you already wrote to them" warning belongs, next to the button that would
// do it a second time, rather than only in a tooltip the admin may never hover.
const onInvite = async (inv: Invite) => {
let lang: LangCode = LANGUAGES.some((l) => l.code === inv.lang)
? (inv.lang as LangCode)
: ((i18n.language.split("-")[0] === "hu" ? "hu" : "en") as LangCode);
const ok = await confirm({
title: t("settings.invites.inviteTitle"),
message: (
<>
<p>
{t(
inv.invited_at
? "settings.invites.inviteConfirmAgain"
: "settings.invites.inviteConfirm",
{ email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() },
)}
</p>
<InviteLangPicker initial={lang} onPick={(v) => (lang = v)} />
</>
),
confirmLabel: t("settings.invites.inviteSend"),
});
if (ok) invite.mutate({ inv, lang });
};
const add = useMutation({
mutationFn: (email: string) => api.addInvite(email),
onSuccess: (_d, email) => {
@@ -313,6 +633,9 @@ function AdminInvites() {
return (
<Section card title={t("settings.invites.title")}>
{!me.email_configured && (
<InlineNotice className="mb-3">{t("settings.invites.emailOff")}</InlineNotice>
)}
{invites.isError && !invites.data ? (
<StateMessage tone="error" onRetry={() => invites.refetch()}>
{t("common.loadError")}
@@ -329,7 +652,8 @@ function AdminInvites() {
inv={i}
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
onDeny={() => onDeny(i)}
busy={approve.isPending || deny.isPending}
onDelete={() => onDelete(i)}
busy={approve.isPending || deny.isPending || remove.isPending}
/>
))}
</div>
@@ -364,11 +688,67 @@ function AdminInvites() {
</summary>
<div className="mt-2 space-y-1 text-xs text-muted">
{decided.map((i) => (
<div key={i.id} className="flex items-center justify-between gap-2">
<span className="truncate">{i.email}</span>
<div key={i.id} className="flex items-center gap-2">
<span className="truncate flex-1">{i.email}</span>
<span className={i.status === "approved" ? "text-accent" : "text-danger"}>
{i.status}
</span>
{/* Only an approved row with NOBODY behind it yet: this button means "the door is
open, come in", which is meaningless to someone already inside — and half the
whitelist is existing users. An account that exists but is still stuck has its
own surface above, with a re-send. The label carries the answer to "did I already
write to them?", the question whose wrong answer means mailing a person twice. */}
{i.status === "approved" && !i.has_account && (
<Tooltip
hint={
i.invited_at
? t("settings.invites.invitedAt", {
time: new Date(i.invited_at).toLocaleString(),
})
: t("settings.invites.inviteHint")
}
>
<button
onClick={() => onInvite(i)}
disabled={invite.isPending}
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
i.invited_at
? "text-muted/50 hover:text-fg"
: "text-muted hover:text-accent"
}`}
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
>
<Mails className="w-3.5 h-3.5" />
</button>
</Tooltip>
)}
{/* An entry with an account behind it stays read-only: deleting it would revoke that
user's access, since is_allowed reads this row. Delete the user instead. */}
{i.has_account ? (
// `disabled` would drop it from the tab order AND suppress the title, so the
// reason reached nobody — same call as the Playlists drag handle.
<button
type="button"
aria-disabled
onClick={(e) => e.preventDefault()}
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<button
onClick={() => onDelete(i)}
disabled={remove.isPending}
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</Tooltip>
)}
</div>
))}
</div>
@@ -506,11 +886,13 @@ function InviteRow({
inv,
onApprove,
onDeny,
onDelete,
busy,
}: {
inv: Invite;
onApprove: () => void;
onDeny: () => void;
onDelete: () => void;
busy: boolean;
}) {
const { t } = useTranslation();
@@ -546,6 +928,34 @@ function InviteRow({
<X className="w-4 h-4" />
</button>
</Tooltip>
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
exists for it the row is that account's approval gate, so it is managed from Users & roles
instead — shown as a disabled control rather than omitted, or the admin sees a row that is
silently missing a button their other rows have. */}
{inv.has_account ? (
// aria-disabled, not `disabled`: the reason has to stay reachable by keyboard.
<button
type="button"
aria-disabled
onClick={(e) => e.preventDefault()}
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
>
<Trash2 className="w-4 h-4" />
</button>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<button
onClick={onDelete}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
>
<Trash2 className="w-4 h-4" />
</button>
</Tooltip>
)}
</div>
);
}
+9 -1
View File
@@ -26,6 +26,7 @@ export default function Banner({
action,
onDismiss,
dismissTitle,
role,
}: {
tone: BannerTone;
icon: LucideIcon;
@@ -33,10 +34,17 @@ export default function Banner({
action?: { label: string; onClick: () => void };
onDismiss?: () => void;
dismissTitle?: string;
/** `"status"` for a bar that APPEARS in response to something the user just did, so a screen
* reader announces it. Left unset for the permanent bars (demo, version): those are part of the
* page when it loads, and announcing them on every visit is noise, not information. */
role?: "status";
}) {
const c = TONES[tone];
return (
<div className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}>
<div
role={role}
className={`shrink-0 flex items-center gap-2 px-4 py-2 text-sm border-b text-fg ${c.bar}`}
>
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
<span className="min-w-0">{children}</span>
{action && (
+12 -1
View File
@@ -6,6 +6,8 @@ import { Plug, RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import InlineNotice from "./InlineNotice";
import { useMe } from "../lib/useMe";
import Tabs, { usePersistedTab } from "./Tabs";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
@@ -35,6 +37,7 @@ const GROUP_ORDER = [
export default function ConfigPanel() {
const { t } = useTranslation();
const qc = useQueryClient();
const me = useMe();
const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig });
const data = q.data;
@@ -100,6 +103,10 @@ export default function ConfigPanel() {
}
}
await qc.invalidateQueries({ queryKey: qk.adminConfig() });
// The email-not-configured notice reads me.email_configured, which is otherwise only
// refetched on its own 60s interval — without this it keeps warning about fields the admin
// has just filled in, which reads as the save not having taken.
await qc.invalidateQueries({ queryKey: qk.me() });
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
@@ -204,7 +211,11 @@ export default function ConfigPanel() {
</div>
{activeGroup === "email" && (
<div className="mt-3 pt-3 border-t border-border/60">
<div className="mt-3 pt-3 border-t border-border/60 space-y-3">
{/* The one place an operator can see that mail is off. Sending is fail-soft, so a
half-filled config costs verification links, resets and access-request notices
with no error anywhere — say so next to the fields that fix it. */}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}>
<button
onClick={() => testEmail.mutate()}
+1 -1
View File
@@ -94,7 +94,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
onClose={() => close(null)}
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
>
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
<div className="text-sm text-muted leading-relaxed">{state.opts.message}</div>
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
these are peers. Ordered least to most destructive, top to bottom. */}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { AlertTriangle } from "lucide-react";
// An in-flow warning that sits INSIDE a panel, next to the thing it is about — as opposed to
// Banner, which is the full-bleed top-of-app bar (border-b, app chrome) and looks wrong nested in a
// card. Same warn tokens as Banner's warning tone so the two read as one system.
export default function InlineNotice({
children,
icon: Icon = AlertTriangle,
className = "",
}: {
children: ReactNode;
icon?: LucideIcon;
className?: string;
}) {
return (
<div
role="status"
className={`flex items-start gap-2 rounded-xl border border-warn/30 bg-warn/15 px-3 py-2 text-sm text-fg ${className}`}
>
<Icon className="w-4 h-4 shrink-0 mt-0.5 text-warn" aria-hidden />
<span className="min-w-0">{children}</span>
</div>
);
}
+14
View File
@@ -281,6 +281,20 @@ export default function NavSidebar({
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
{/* The address Google answers with, when it isn't the one above. /api/me sends it
only when the two differ, so for most accounts this line never exists — and for
someone who registered as `me+siftlode@gmail.com` it answers the question their
sign-in screen would otherwise leave open: which address gets me in? A text
label rather than a brand glyph, since the app draws one icon set (lucide) and a
generic mail icon here would say nothing. */}
{me.google_email && (
<div
className="text-2xs text-muted truncate mt-0.5"
title={t("header.account.googleAddress", { email: me.google_email })}
>
<span className="uppercase tracking-wide">Google</span> · {me.google_email}
</div>
)}
</div>
</div>
{me.role === "admin" && (
@@ -243,6 +243,7 @@ function NotificationRow({
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
const isScheduler = n.type === "scheduler" && typeof n.data?.job_id === "string";
const isDownloadDone = n.type === "download_done";
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
let title = n.title;
let body = n.body;
if (isMaintenance) {
@@ -258,6 +259,17 @@ function NotificationRow({
job,
});
body = d.summary || n.body;
} else if (isEmailUndelivered) {
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
// the message that was lost, since that is what tells the operator what their users didn't get.
const d = n.data as { reason: string; subject?: string };
title = t("inbox.emailUndelivered.title");
body = t(
d.reason === "not_configured"
? "inbox.emailUndelivered.notConfigured"
: "inbox.emailUndelivered.sendFailed",
{ subject: d.subject || n.body || "" },
);
} else if (isDownloadDone) {
// "Download ready" / "Edit ready" per the producer's kind, with the video's title as the body
// (shipped in data.title). Same notification type; the copy differs so an edit isn't mislabelled.
+174 -150
View File
@@ -39,6 +39,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
import { useUndoable } from "../lib/useUndoable";
import { cycleSort, type SortState } from "../lib/columnSort";
import { formatRuntime } from "../lib/format";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
@@ -259,6 +260,13 @@ export default function Playlists() {
return (v: Video) => m.get(v.id) ?? 0;
}, [items]);
// Total playtime of the whole playlist (sums every item, matching the itemCount beside it — not
// the filtered `shown` set). Items with an unknown duration count as 0; hidden when nothing sums.
const totalSeconds = useMemo(
() => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 0),
[items],
);
const columns = useMemo(
() => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
[t, storedIndex, onPlay, onRemove, canEdit],
@@ -591,176 +599,192 @@ export default function Playlists() {
}
return (
<div className="px-4 pb-4 pt-3">
// No top padding: the sticky column header must sit flush at the scroller top so it doesn't
// shift up when `position: sticky` engages on the first scroll (the fixed toolbar band above
// gives the breathing room). Same reason as the channel table — see Channels.tsx.
<div className="px-4 pb-4">
{/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
<div className="px-4 pt-2 pb-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-13.5 rounded-lg bg-card overflow-hidden shrink-0">
{/* Two tight rows beside a small cover: title + actions on the first, the meta line and the
view controls sharing the second. This band is FIXED chrome (it never scrolls), so every
row it sheds is list height won back — the old layout stacked three rows next to the
cover plus a fourth block under it, for roughly twice this height. */}
<div className="px-4 py-2 max-w-5xl mx-auto">
<div className="flex gap-3 items-center">
<div className="w-20 h-11.25 rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
<img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
</div>
<div className="min-w-0 flex-1">
{renaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") saveRename();
if (e.key === "Escape") setRenaming(false);
}}
aria-label={t("playlists.rename")}
className="text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<div className="flex items-center gap-2 min-w-0">
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
<div className="flex items-center gap-2 min-w-0">
{renaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={saveRename}
onKeyDown={(e) => {
if (e.key === "Enter") saveRename();
if (e.key === "Escape") setRenaming(false);
}}
aria-label={t("playlists.rename")}
className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<>
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
{editable && (
<button
onClick={() => {
setRenameValue(detail.name);
setRenaming(true);
}}
title={t("playlists.rename")}
aria-label={t("playlists.rename")}
className="shrink-0 text-muted hover:text-fg"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{detail.yt_playlist_id && (
<a
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</>
)}
<div className="ml-auto flex gap-1.5 items-center shrink-0">
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-2xs text-accent mr-1"
>
<Pencil className="w-3.5 h-3.5" />
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
)}
<button
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
)}
{detail.yt_playlist_id && (
<a
href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
target="_blank"
rel="noreferrer noopener"
title={t("playlists.openOnYoutube")}
aria-label={t("playlists.openOnYoutube")}
className="shrink-0 text-muted hover:text-accent"
{linked && detail.dirty && (
<button
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button>
)}
{editable && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
<div className="text-xs text-muted mt-0.5">
{t("playlists.itemCount", { count: items.length })}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</div>
<div className="flex gap-1.5 mt-3 flex-wrap items-center">
<button
onClick={() => shown.length && setPlayingId(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
{editable && canWrite && (
<button
onClick={pushToYoutube}
disabled={pushing}
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
aria-label={
linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
}
className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
detail.dirty
? "border-accent text-accent hover:bg-accent/10"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{pushing ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : linked ? (
<Youtube className="w-3.5 h-3.5" />
) : (
<Upload className="w-3.5 h-3.5" />
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span>
{(items.length > 1 || viewActive) && (
<>
<span aria-hidden="true" className="text-xs text-muted/40">
·
</span>
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => setGroupBy(e.target.checked)}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
>
{t("playlists.clearView")}
</button>
)}
</button>
)}
{linked && detail.dirty && (
<button
onClick={revertToYoutube}
disabled={reverting}
title={t("playlists.revert")}
aria-label={t("playlists.revert")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
>
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
</button>
)}
{editable && (
<button
onClick={deletePlaylist}
title={t("playlists.delete")}
aria-label={t("playlists.delete")}
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{linked && detail.dirty && (
<span
title={t("playlists.unsyncedHint")}
className="inline-flex items-center gap-1.5 text-2xs text-accent ml-1"
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{t("playlists.unsynced")}
</span>
{canSaveOrder && (
<button
onClick={saveOrder}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
>
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
</button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here
(reset link + save hint + "reordering is off"), and none of them answered the
question a user actually has — "did I just change my playlist?". Worse,
sorting a list whose stored order ALREADY matches the sort produced a
warning, no Save button and no visible change, which reads as a malfunction.
Now the line says which of the three situations you are in; the disabled grip
still explains itself on hover/focus. */}
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
{(items.length > 1 || viewActive) && (
<div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
<label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
<input
type="checkbox"
checked={groupBy}
onChange={(e) => setGroupBy(e.target.checked)}
className="accent-accent"
/>
{t("playlists.groupByChannel")}
</label>
{viewActive && (
<button
onClick={clearView}
className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
>
{t("playlists.clearView")}
</button>
)}
{canSaveOrder && (
<button
onClick={saveOrder}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
>
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
</button>
)}
{/* ONE sentence that names the state. Three loose texts used to pile up here (reset
link + save hint + "reordering is off"), and none of them answered the question a
user actually has — "did I just change my playlist?". Worse, sorting a list whose
stored order ALREADY matches the sort produced a warning, no Save button and no
visible change, which reads as a malfunction. Now the line says which of the three
situations you are in; the disabled grip still explains itself on hover/focus. */}
{viewActive && <span className="text-2xs text-muted/80">{viewStatus}</span>}
{canEdit && (
<div className="ml-auto">
<UndoToolbar
canUndo={order.canUndo}
canRedo={order.canRedo}
onUndo={order.undo}
onRedo={order.redo}
/>
</div>
)}
</div>
)}
</div>
</PageToolbar>
+2 -2
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { formatDuration } from "../lib/format";
import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
@@ -247,7 +247,7 @@ export default function PlaylistsRail({
<div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatDuration(pl.total_duration_seconds)}`}
` · ${formatRuntime(pl.total_duration_seconds)}`}
</div>
</div>
</button>
+29
View File
@@ -19,6 +19,7 @@ import i18n from "../i18n";
import { Carousel } from "./Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexCards";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -253,6 +254,33 @@ function CrewPanel({
);
}
// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when
// known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses,
// so the two surfaces can't drift. `overlay` tints the label for the dark player background.
function PlaybackChip({ detail, overlay }: { detail: PlexItemDetail; overlay: boolean }) {
const { t } = useTranslation();
if (!detail.playable) return null;
const cls = playableClass(detail.playable, detail.transcode_hw);
const sub = [
detail.codec_video ? detail.codec_video.toUpperCase() : null,
detail.video_bit_depth != null ? `${detail.video_bit_depth}-bit` : null,
]
.filter(Boolean)
.join(" · ");
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs ${
overlay ? "bg-white/10 text-white/90" : "bg-surface text-muted"
}`}
title={t(`plex.playable.${cls}`)}
>
<span className={`h-2 w-2 shrink-0 rounded-full ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`} />
<span className="font-medium">{t(`plex.filter.playableOpt.${cls}`)}</span>
{sub && <span className="opacity-70">{sub}</span>}
</span>
);
}
type Props = {
detail: PlexItemDetail;
variant: "page" | "overlay";
@@ -479,6 +507,7 @@ export default function PlexInfo({
{detail.studio}
</Filterable>
)}
<PlaybackChip detail={detail} overlay={overlay} />
</div>
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
+14 -1
View File
@@ -101,6 +101,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Which encoder the current transcode session ran: true=VAAPI hardware, false=libx264 software,
// null=not a transcode. Feeds the stats overlay's mode row ("transcode · GPU" / "· CPU").
const hwRef = useRef<boolean | null>(null);
// Whether the current HW transcode tone-mapped a 10-bit/HDR source on the GPU (OpenCL). Feeds the
// stats mode row's "· HDR→SDR" tag; null/false for an 8-bit HW, software, or non-transcode session.
const tonemapRef = useRef<boolean | null>(null);
const durationRef = useRef(0);
// ≥2 audio tracks → multi-rendition HLS: all tracks ship in one session and hls.js switches audio
// client-side (no restart). Ref so the stable loadSession/changeAudio callbacks read it fresh.
@@ -234,6 +237,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// re-encodes the video (hw = which encoder), and its first segment is genuinely slower than remux.
const isTranscode = sess.mode === "hls" && !!sess.transcode;
hwRef.current = isTranscode ? (sess.hw ?? false) : null;
tonemapRef.current = isTranscode ? (sess.tonemap ?? false) : null;
setSessionIsTranscode(isTranscode);
// hls.js plays our non-zero-start copyts stream ~1s BEHIND the media clock (see HLS_SUB_LAG);
// fold that into the session start so the seekbar clock, seeking AND the subtitle shift are all
@@ -257,7 +261,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
hlsRef.current = null;
}
if (sess.mode === "hls" && Hls.isSupported()) {
const hls = new Hls({ maxBufferLength: 30, enableWorker: true });
// startPosition: 0 — our HLS is a growing EVENT playlist with no ENDLIST (see _HLS_TAIL in
// stream.py), which hls.js classifies as LIVE. Its default (startPosition: -1) then starts
// playback at the live edge and hugs it, so it never builds a forward buffer (rebuffer
// spinners), and on a resume/seek session the segments' absolute PTS (-copyts, offset by the
// server's -ss) don't line up with the live-edge timeline → 0 fragments load → permanent
// stall. But the seek-restart model means fragment 0 IS the requested content position
// (`?t=` positioned the server ffmpeg there), so we always want to start VOD-style at 0 and
// buffer forward up to maxBufferLength. Applies to remux + transcode alike (both use copyts).
const hls = new Hls({ maxBufferLength: 30, enableWorker: true, startPosition: 0 });
hlsRef.current = hls;
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
hls.attachMedia(video);
@@ -1206,6 +1218,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
hlsRef={hlsRef}
playable={detail?.playable ?? ""}
hwRef={hwRef}
tonemapRef={tonemapRef}
sessionStartRef={sessionStartRef}
absRef={absRef}
durationRef={durationRef}
+27 -12
View File
@@ -32,6 +32,7 @@ import {
X,
} from "lucide-react";
import { api, type PlexCard } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
import { LoadingState, StateMessage } from "./QueryState";
@@ -387,6 +388,12 @@ export default function PlexPlaylistView({
}, [q.data]);
const blocks = useMemo(() => buildBlocks(items), [items]);
const order = useMemo(() => items.map((i) => i.id), [items]);
// Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with
// an unknown duration count as 0, and the runtime is dropped from the meta line when nothing sums.
const totalSeconds = useMemo(
() => items.reduce((acc, it) => acc + (it.duration_seconds ?? 0), 0),
[items],
);
const invalidate = () => {
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
@@ -517,18 +524,26 @@ export default function PlexPlaylistView({
<div className="glass rounded-2xl p-4 sm:p-6">
<div className="mb-4 flex flex-wrap items-center gap-3">
{renaming ? (
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && rename()}
onBlur={rename}
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
)}
<div className="min-w-0 flex-1">
{renaming ? (
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && rename()}
onBlur={rename}
className="glass-card w-full rounded-lg px-3 py-1.5 text-lg font-bold focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<h1 className="truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
)}
{items.length > 0 && (
<div className="mt-0.5 text-sm text-muted">
{t("plex.browse.items", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
</div>
)}
</div>
{/* Layout preference: Accordion or Tree — the shared list-view switcher. */}
<ViewSwitcher
value={layout}
+22
View File
@@ -296,6 +296,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
},
]
: []),
// Playback class (Direct Play / Direct Stream / Transcode HW/SW) — movie-only (shows have no
// class), and dropped when the facet is empty. Labels are localized (the facet ships class keys).
...(scope === "movie" && facets && facets.playable.length > 0
? [
{
id: "playable",
title: t("plex.filter.playable"),
body: (
<Combobox
label={t("plex.filter.playable")}
options={facets.playable.map((p) => ({
value: p.value,
label: t(`plex.filter.playableOpt.${p.value}`),
count: p.count,
}))}
selected={filters.playable}
onChange={(next) => patch({ playable: next })}
/>
),
},
]
: []),
];
return (
+4 -1
View File
@@ -13,6 +13,7 @@ export function PlexStatsOverlay({
hlsRef,
playable,
hwRef,
tonemapRef,
sessionStartRef,
absRef,
durationRef,
@@ -22,6 +23,7 @@ export function PlexStatsOverlay({
hlsRef: RefObject<Hls | null>;
playable: string;
hwRef: RefObject<boolean | null>; // active transcode encoder: true=GPU, false=CPU, null=not transcode
tonemapRef: RefObject<boolean | null>; // HW HDR→SDR tonemap engaged (adds a "· HDR→SDR" mode tag)
sessionStartRef: RefObject<number>;
absRef: RefObject<number>;
durationRef: RefObject<number>;
@@ -78,6 +80,7 @@ export function PlexStatsOverlay({
buildStatRows({
playable,
hwEncoder: hwRef.current,
tonemap: tonemapRef.current,
transport: hls ? "hls" : "direct",
videoWidth: v?.videoWidth ?? 0,
videoHeight: v?.videoHeight ?? 0,
@@ -94,7 +97,7 @@ export function PlexStatsOverlay({
sample();
const iv = setInterval(sample, 1000);
return () => clearInterval(iv);
}, [videoRef, hlsRef, playable, hwRef, sessionStartRef, absRef, durationRef]);
}, [videoRef, hlsRef, playable, hwRef, tonemapRef, sessionStartRef, absRef, durationRef]);
return (
<div
@@ -413,6 +413,15 @@ function SignInMethods({ me }: { me: Me }) {
? t("settings.account.googleLink.connectedHint")
: t("settings.account.googleLink.connectHint")}
</p>
{/* Which address Google actually answers with — sent only when it differs from the
account's own (see /api/me). "Connected" alone left that unanswerable, and it is
exactly what someone who registered with a `+tag` needs to know. */}
{me.google_email && (
<p className="text-xs text-fg/80 mt-1.5">
<span className="text-muted">{t("settings.account.googleLink.signedInAs")}</span>{" "}
{me.google_email}
</p>
)}
</div>
{me.has_google ? (
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
+41
View File
@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { CheckCircle2, AlertTriangle } from "lucide-react";
import Banner from "./Banner";
import type { VerifyEntry } from "../lib/useVerifyEntry";
// The top-of-app outcome of an emailed verification link opened in a browser that ALREADY holds a
// session. That case used to be silent: the token was only ever consumed inside Welcome.tsx, which a
// signed-in visit never reaches, so the link opened a tab, the fragment was stripped, and nothing
// happened. Signed OUT, the same outcome is rendered by the auth card's own banners instead — this
// bar is only for the signed-in half.
//
// No "send a new link" action here, unlike the auth card: this viewer is signed in, so the sign-in
// screen (where resending lives) is not where they are, and the message is a report, not a task. A
// wrong-account case is why the address is named.
export default function VerifyBanner({ entry }: { entry: VerifyEntry }) {
const { t } = useTranslation();
if (!entry.outcome) return null;
const ok = entry.outcome !== "invalid";
const text =
entry.outcome === "invalid"
? t("header.verify.invalid")
: entry.email
? t("header.verify.confirmedNamed", { email: entry.email })
: t("header.verify.confirmed");
return (
<Banner
tone={ok ? "accent" : "warning"}
icon={ok ? CheckCircle2 : AlertTriangle}
role="status"
onDismiss={entry.dismiss}
dismissTitle={t("header.banner.dismiss")}
>
{text}
{entry.outcome === "ok_reset" && (
<span className="text-muted"> {t("header.verify.reset")}</span>
)}
</Banner>
);
}
+357 -57
View File
@@ -1,33 +1,53 @@
import { useEffect, useRef, useState } from "react";
import { useHistorySubview, type SubviewUrl } from "../lib/history";
import { useTranslation } from "react-i18next";
import {
ArrowRight,
Check,
Clapperboard,
Clock,
Code2,
Download,
Expand,
Filter,
Inbox,
Link2Off,
ListVideo,
Lock,
MailWarning,
PlayCircle,
Search,
ShieldAlert,
X,
type LucideIcon,
} from "lucide-react";
// Siftlode is open source; the landing footer links to the public repository.
const REPO_URL = "https://git.b1fr0st.eu/peter/siftlode";
import { api } from "../lib/api";
import { api, deadResetLink, loginBlocked, type LoginBlocked } from "../lib/api";
import { HttpError } from "../lib/api";
import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { inputCls } from "./ui/form";
import { entryHash, entrySearch } from "../lib/entryUrl";
import type { VerifyEntry } from "../lib/useVerifyEntry";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
export type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
// The card's sub-views are real history entries, so browser/mouse Back leaves one the way it leaves
// any other page — they used to be plain state, and Back silently changed only the address bar.
// "signin" is the root (no param) and "reset" is deliberately NOT mirrored: that view is only
// reachable with the one-time token from the #reset fragment, so a bookmarked ?m=reset would open a
// form that cannot submit.
export const MODE_URL: SubviewUrl<Mode> = {
key: "m",
encode: (m: Mode) => (m === "signin" || m === "reset" ? null : m),
decode: (raw: string) =>
raw === "register" || raw === "forgot" || raw === "resend" ? (raw as Mode) : null,
};
// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries)
// keeps adding a new mode a one-line change and makes a missing key a compile error.
@@ -50,7 +70,7 @@ const SUBMIT_KEY: Record<Mode, string> = {
// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown
// whenever the user isn't signed in (see App.tsx). The auth forms talk to /auth/* (see the
// backend); errors are surfaced inline (the api calls use the quiet flag).
export default function Welcome() {
export default function Welcome({ verify }: { verify: VerifyEntry }) {
const { t, i18n } = useTranslation();
// Which screenshot (if any) is open in the full-size lightbox.
const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null);
@@ -74,7 +94,7 @@ export default function Welcome() {
<p className="text-muted mt-4 text-lg leading-relaxed">{t("welcome.hero.subtitle")}</p>
<p className="text-sm text-muted mt-4">{t("welcome.hero.trust")}</p>
</div>
<AuthCard />
<AuthCard verify={verify} />
</section>
{/* App preview */}
@@ -288,47 +308,59 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
);
}
function AuthCard() {
// One answer per reset token, however many times React mounts this card. StrictMode double-invokes
// effects in dev and the shell remounts the card as the session probe settles — measured in UAT on
// 2026-09-05: FOUR probes went out for one opened link. That would burn the endpoint's per-IP budget
// after a couple of reloads and silently drop back to drawing the form unchecked. Holding the
// in-flight promise means a remount joins the request already running instead of starting another.
const resetTokenChecks = new Map<string, Promise<boolean>>();
function checkResetToken(token: string): Promise<boolean> {
let pending = resetTokenChecks.get(token);
if (!pending) {
pending = api
.checkPasswordReset(token)
.then((r) => r.ok)
// A probe that FAILED says nothing about the token, so it answers "go ahead" and forgets
// itself: a network blip must never be cached as "this link is dead".
.catch(() => {
resetTokenChecks.delete(token);
return true;
});
resetTokenChecks.set(token, pending);
}
return pending;
}
function AuthCard({ verify }: { verify: VerifyEntry }) {
const { t } = useTranslation();
// Snapshot the pre-login params ONCE (query + fragment) so we can clean the address bar below
// without dismissing the banners/reset-mode they drive. Secret tokens (reset, verify) ride the
// URL FRAGMENT so they never reach the server / proxy logs / Referer (SB3); plain status flags
// stay in the query string.
const [params] = useState(() => new URLSearchParams(window.location.search));
const [hashParams] = useState(() => new URLSearchParams(window.location.hash.replace(/^#/, "")));
const resetToken = hashParams.get("reset");
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
const bounced = params.get("access"); // Google denied → "requested" | "denied"
const deleted = params.get("deleted"); // self-service account deletion just completed
// The entry URL is snapshotted once at import time (lib/entryUrl) rather than here, so nothing can
// rewrite the address bar out from under this card before it mounts — and so the App-level hook and
// this card read the SAME values regardless of which mounts first. Clearing the address bar is that
// hook's job now, since it is the one thing that runs on every visit, signed in or not.
//
// State, not a const: once the token has been spent the reset view must stop being the card's
// root, or a Back from the "password changed" screen lands on a form whose token is already used.
const [resetToken, setResetToken] = useState<string | null>(() => entryHash.get("reset"));
// The Google access-request notice, off the FRAGMENT (see lib/entryUrl): it names the address now,
// and an address in the query string would land in every reverse-proxy access log. `linked` is set
// only when the request already existed under ANOTHER spelling of the same mailbox — the case worth
// explaining; the same spelling twice is just a repeat.
const bounced = entryHash.get("access"); // Google denied → "requested" | "denied"
const bouncedEmail = entryHash.get("email");
const bouncedLinked = entryHash.get("linked");
const deleted = entrySearch.get("deleted"); // self-service account deletion just completed
// Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the
// first deliberate action, like the verify banner — otherwise it lingers stalely on later screens.
const [login, setLogin] = useState<string | null>(() => params.get("login"));
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
const [login, setLogin] = useState<string | null>(() => entrySearch.get("login"));
// The verification outcome is confirmed in App (above the signed-in branch) and handed down, so
// one emailed link behaves the same whether or not this browser already holds a session.
const verifyOutcome = verify.outcome;
// Strip BOTH the query string and the fragment so the address bar stays clean and the token
// doesn't linger in history.
useEffect(() => {
if (window.location.search || window.location.hash) {
window.history.replaceState(null, "", window.location.pathname);
}
}, []);
// Confirm a fragment verification token by POSTing it (new SB3 flow). Fire ONCE: the token is
// single-use, so a double POST (StrictMode remount / any re-render) would 400 on the second call
// and wrongly flip the banner to "invalid".
const verifyFired = useRef(false);
useEffect(() => {
if (!verifyToken || verifyFired.current) return;
verifyFired.current = true;
api
.verifyEmail(verifyToken)
.then(() => setVerify("ok"))
.catch(() => setVerify("invalid"));
}, [verifyToken]);
const [mode, setMode] = useState<Mode>(resetToken ? "reset" : "signin");
const { view: mode, open: setMode } = useHistorySubview<Mode>(
resetToken ? "reset" : "signin",
MODE_URL,
);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
@@ -344,6 +376,44 @@ function AuthCard() {
.catch(() => {});
}, []);
const [done, setDone] = useState<string>(""); // success/info message replacing the form
// Why password sign-in refused, when the server named a state worth a screen rather than a red line
// (E9.5 S4). It REPLACES the form: in all three of those states the password was already proven
// correct, so there is nothing to retype — leaving the form up would only invite a pointless retry.
const [blocked, setBlocked] = useState<LoginBlocked | null>(null);
// The emailed reset link turned out to be spent, expired, or to belong to an account that no longer
// exists. Same reasoning as `blocked`: the form cannot succeed, so it must not be on screen.
const [deadReset, setDeadReset] = useState(false);
// Ask whether the `#reset=` token is still usable BEFORE drawing the form, and hold the card blank
// until the answer lands. Reported 2026-09-05: deleting an account and then opening its reset link
// rendered a full "set a new password" form that could only ever answer 400 — the user finds out
// after typing. This is advisory (the confirm below stays the authority); a check that errors is
// treated as "go ahead", never as "dead", so a broken probe cannot lock anyone out of a live link.
const [checkingReset, setCheckingReset] = useState(() => entryHash.get("reset") !== null);
useEffect(() => {
const token = entryHash.get("reset");
if (!token) return;
let live = true;
void checkResetToken(token).then((ok) => {
if (!live) return;
if (!ok) {
setDeadReset(true);
setResetToken(null); // and stop the reset view being the card's root, so Back can't return
}
setCheckingReset(false);
});
return () => {
live = false;
};
}, []);
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
// — without this, Back out of a submitted form would show the old success message under the
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
useEffect(() => {
setDone("");
setError("");
setBlocked(null);
setDeadReset(false);
}, [mode]);
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
// by the demo whitelist server-side.
@@ -355,9 +425,11 @@ function AuthCard() {
const reset = () => {
setError("");
setDone("");
setBlocked(null);
setDeadReset(false);
// The verify/login banners are one-time entry notices — dismiss once the user takes any
// deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
setVerify(null);
verify.dismiss();
setLogin(null);
};
@@ -367,6 +439,13 @@ function AuthCard() {
setMode("resend");
};
// Used by the "verified, but your password was revoked" banner — the only way back into that
// account is a reset link, so put the user one click from it.
const goForgot = () => {
reset();
setMode("forgot");
};
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
reset();
@@ -400,9 +479,24 @@ function AuthCard() {
setDone(t("welcome.auth.resendDone"));
} else if (mode === "reset") {
await api.confirmPasswordReset(resetToken ?? "", password);
setResetToken(null); // single-use: the reset view is no longer a place to come back to
setDone(t("welcome.auth.resetDone"));
}
} catch (err) {
// An account-shaped refusal becomes a named screen; `invalid` / `rate_limited` stay a line
// under the form, because there the form IS the next step.
const refusal = err instanceof HttpError ? loginBlocked(err.detailData) : null;
if (refusal && PANEL_REASONS.has(refusal.reason)) {
setBlocked(refusal);
return;
}
// The token died between the pre-flight check and this submit — or was never checked, because
// the probe itself failed. Either way the answer is the same screen, not a red line.
if (mode === "reset" && err instanceof HttpError && deadResetLink(err.detailData)) {
setDeadReset(true);
setResetToken(null);
return;
}
setError(
err instanceof HttpError && err.detail ? err.detail : t("welcome.auth.genericError"),
);
@@ -431,15 +525,37 @@ function AuthCard() {
}
}
const titleKey = TITLE_KEY[mode];
// A named state owns the card's heading while it is up, so it reads as the card's subject rather
// than a box sitting inside a heading that still says "Set a new password".
const titleKey = blocked
? `welcome.auth.blocked.${blocked.reason}.title`
: deadReset
? "welcome.auth.deadLink.title"
: TITLE_KEY[mode];
const StateIcon = blocked ? BLOCKED_ICON[blocked.reason] : deadReset ? Link2Off : null;
return (
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
<h2 className="text-lg font-semibold mb-1">{t(titleKey)}</h2>
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2.5">
{StateIcon && (
<span className="w-8 h-8 rounded-xl bg-warn/15 text-warn grid place-items-center shrink-0">
<StateIcon className="w-[18px] h-[18px]" />
</span>
)}
{t(titleKey)}
</h2>
{/* One-time status banners from a redirect (Google-denied, email verification). */}
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
{verify === "invalid" && (
{verifyOutcome === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
{verifyOutcome === "ok_reset" && (
<Banner tone="warn">
{t("welcome.auth.verifyOkReset")}{" "}
<button onClick={goForgot} className="font-semibold text-accent hover:underline">
{t("welcome.auth.verifyOkResetCta")}
</button>
</Banner>
)}
{verifyOutcome === "invalid" && (
<Banner tone="warn">
{t("welcome.auth.verifyInvalid")}{" "}
<button onClick={goResend} className="font-semibold text-accent hover:underline">
@@ -447,7 +563,26 @@ function AuthCard() {
</button>
</Banner>
)}
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
{bounced === "requested" &&
(bouncedEmail ? (
<Banner tone="ok">
<span className="block">{t("welcome.auth.accessRequestedFor")}</span>
<Address value={bouncedEmail} />
<span className="block">{t("welcome.auth.accessRequestedReview")}</span>
</Banner>
) : (
<Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>
))}
{/* The alias insight, stated only when it applies: Google answered with one spelling of a
mailbox whose request is already on file under another. Silence here is what let the
2026-09-03 registrant believe nothing had been recorded at all. */}
{bounced === "requested" && bouncedLinked && (
<Banner tone="warn">
<span className="block">{t("welcome.auth.accessLinked")}</span>
<Address value={bouncedLinked} />
<span className="block">{t("welcome.auth.accessLinkedTail")}</span>
</Banner>
)}
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
{login === "oauth_cancelled" && (
@@ -455,7 +590,22 @@ function AuthCard() {
)}
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
{done ? (
{blocked ? (
<BlockedPanel blocked={blocked} onResend={goResend} onBack={() => setBlocked(null)} />
) : deadReset ? (
<StatePanel
body={t("welcome.auth.deadLink.body")}
cta={{ label: t("welcome.auth.deadLink.cta"), onClick: goForgot }}
onBack={() => {
reset();
setMode("signin");
}}
/>
) : checkingReset ? (
// Blank rather than the form: showing a form we may be about to withdraw is the flicker this
// whole check exists to remove.
<p className="mt-3 text-sm text-muted">{t("welcome.auth.working")}</p>
) : done ? (
<div className="mt-3">
<p className="text-sm text-fg/80 leading-relaxed">{done}</p>
<button
@@ -507,6 +657,18 @@ function AuthCard() {
</button>
</form>
{/* A confirmation mail can be slow, filtered, or — before E9.5 S6 — never sent at all for a
repeat sign-up. Offered right under the form rather than below the mode switches: it is
the second thing a stuck person needs, after the password they just tried. */}
{mode === "signin" && (
<button
onClick={goResend}
className="mt-3 block w-full text-center text-xs text-muted hover:text-fg"
>
{t("welcome.auth.resendLink")}
</button>
)}
{/* Mode switches */}
<div className="flex items-center justify-between mt-3 text-xs">
{mode === "signin" ? (
@@ -545,14 +707,6 @@ function AuthCard() {
{mode === "signin" && (
<>
{/* A verification email can be lost or never arrive, so offer a resend path from
sign-in too — not only from the expired-link banner (needs a broken link to show). */}
<button
onClick={goResend}
className="mt-2 block w-full text-center text-xs text-muted hover:text-fg"
>
{t("welcome.auth.resendLink")}
</button>
<div className="flex items-center gap-3 my-4 text-2xs uppercase tracking-wide text-muted">
<span className="h-px flex-1 bg-border" />
{t("welcome.auth.or")}
@@ -603,9 +757,155 @@ function AuthCard() {
);
}
// An email address the user has to READ and compare, character by character — the recorded address on
// a blocked-sign-in panel, the mailbox a request was filed under. Monospaced and breakable because
// that comparison is the whole job: a dot or a `+tag` in a proportional font is exactly what nobody
// noticed on 2026-09-03.
function Address({ value }: { value: string }) {
return <span className="block font-mono font-medium break-all my-0.5">{value}</span>;
}
// Which refusals earn a screen of their own. The other two (`invalid`, `rate_limited`) are answered
// BEFORE the password is proven, carry no account detail, and leave the form as the next step — a
// line under it is the honest size for them.
const PANEL_REASONS = new Set<LoginBlocked["reason"]>([
"email_unverified",
"pending_approval",
"suspended",
]);
const BLOCKED_ICON: Partial<Record<LoginBlocked["reason"], LucideIcon>> = {
email_unverified: MailWarning,
pending_approval: Clock,
suspended: ShieldAlert,
};
// The frame every named state in this card shares: a sentence, whatever detail that state can give,
// then the way out. The icon and title sit in the card's own h2 (see titleKey/StateIcon) so a state
// reads as the card's subject. One frame, because two states that mean "this form cannot succeed"
// should not drift apart in shape.
function StatePanel({
body,
cta,
onBack,
children,
}: {
body: string;
cta?: { label: string; onClick: () => void };
onBack: () => void;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
// Announced and focused, because this panel REPLACES the form the user just submitted: without a
// live region a screen reader reports nothing at all, and without the focus move the button they
// were standing on is unmounted, dropping focus to <body> so Tab restarts at the top of the page.
// The Banner in this same file got `role="status"` for the same reason; the panel is the louder
// half — it is the whole answer to a submit — so it announces assertively.
const heading = useRef<HTMLParagraphElement>(null);
useEffect(() => heading.current?.focus(), []);
return (
<div className="mt-3 flex flex-col gap-4" role="alert" aria-live="assertive">
<p
ref={heading}
tabIndex={-1}
className="text-sm text-fg/80 leading-relaxed focus:outline-hidden"
>
{body}
</p>
{children}
<div className="flex items-center justify-between gap-3">
<button onClick={onBack} className="text-sm text-accent hover:underline">
{t("welcome.auth.backToSignin")}
</button>
{cta && (
<button
onClick={cta.onClick}
className="shrink-0 px-3 py-2 rounded-xl text-xs font-semibold bg-card border border-border hover:border-accent transition"
>
{cta.label}
</button>
)}
</div>
</div>
);
}
// The named state behind a 403, in place of the sign-in form (E9.5 S4). It says WHICH mailbox the
// account is on and WHICH of the two gates is still shut, because the flat "awaiting admin approval"
// string it replaces was true and useless: it never revealed that the address the account carried was
// a different spelling of the one the user had just typed.
function BlockedPanel({
blocked,
onResend,
onBack,
}: {
blocked: LoginBlocked;
onResend: () => void;
onBack: () => void;
}) {
const { t } = useTranslation();
const { reason, email, emailVerified, approved, operator } = blocked;
// Suspension is not a gate that will open on its own, so the checklist would be a lie there.
const gated = reason !== "suspended";
return (
<StatePanel
body={t(`welcome.auth.blocked.${reason}.body`)}
cta={
reason === "email_unverified"
? { label: t("welcome.auth.resendCta"), onClick: onResend }
: undefined
}
onBack={onBack}
>
{gated && (
<div className="rounded-xl border border-border bg-card/60 px-3.5 py-3 flex flex-col gap-2">
<Gate done={emailVerified === true} k="email" />
<Gate done={approved === true} k="approval" />
</div>
)}
{email && (
<div className="border-l-2 border-border pl-3 flex flex-col gap-0.5">
<span className="text-2xs uppercase tracking-wide text-muted">
{t("welcome.auth.blocked.addressLabel")}
</span>
<span className="text-sm">
<Address value={email} />
</span>
</div>
)}
{operator && (
<p className="text-xs text-muted leading-relaxed">
{t("welcome.auth.blocked.operator", { contact: operator })}
</p>
)}
</StatePanel>
);
}
// One row of the two-gate checklist. Both states get their own full sentence rather than a shared
// label plus a status word: Hungarian puts them together differently, and gluing translated
// fragments is how a bilingual UI ends up reading like neither language.
function Gate({ done, k }: { done: boolean; k: "email" | "approval" }) {
const { t } = useTranslation();
const Icon = done ? Check : Clock;
return (
<span className={`flex items-center gap-2 text-xs ${done ? "text-fg/80" : "text-muted"}`}>
<Icon className={`w-4 h-4 shrink-0 ${done ? "text-success" : ""}`} />
{t(`welcome.auth.blocked.gate.${k}${done ? "Done" : "Pending"}`)}
</span>
);
}
// `role="status"` because these appear in response to something that just happened (a verification
// link, a denied Google sign-in, a completed deletion) rather than being part of the card at rest —
// without it a screen-reader user gets no announcement that anything changed.
function Banner({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
const cls = tone === "ok" ? "bg-accent/15 text-fg" : "bg-warn/15 text-fg";
return (
<div className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>{children}</div>
<div role="status" className={`rounded-xl px-3 py-2 text-xs leading-relaxed mt-3 ${cls}`}>
{children}
</div>
);
}
@@ -0,0 +1,347 @@
// @vitest-environment jsdom
//
// The Access-requests panel's delete affordance and its three outcomes. The control's whole point is
// that it explains itself: an entry backed by an account keeps a focusable, labelled control instead
// of quietly missing one, and an address the server's env still allow-lists gets a warning BEFORE the
// irreversible click, not a correction after it.
//
// i18n is a key-passthrough so assertions read stable keys; the API and the confirm/notify
// singletons are stubbed so the panel can be driven without a server.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
// Echo the interpolation too, so a message that forgets its {{email}} is visible in the test.
t: (key: string, vars?: Record<string, unknown>) =>
vars?.email ? `${key}:${String(vars.email)}` : key,
i18n: { language: "en" },
}),
};
});
type ConfirmOpts = {
title?: string;
message?: React.ReactNode;
confirmLabel?: string;
danger?: boolean;
};
const confirmMock = vi.fn(async (_opts: ConfirmOpts) => true);
vi.mock("./ConfirmProvider", () => ({
useConfirm: () => confirmMock,
ConfirmProvider: ({ children }: { children: React.ReactNode }) => children,
}));
const notifyMock = vi.fn();
vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>();
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
});
const meMock = {
pending_invites: 0,
pending_activations: 0,
email_configured: true,
id: 1,
role: "admin",
};
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
// The tab strip is rendered into an app-level toolbar slot that does not exist here, so pin the
// active tab directly — this file is about the Access-requests panel, not the tab chrome.
vi.mock("./Tabs", () => ({
default: () => null,
usePersistedTab: () => ["access", () => {}],
}));
const listMock = vi.fn();
const deleteMock = vi.fn();
const inviteMock = vi.fn();
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminInvites: () => listMock(),
deleteInvite: (id: number) => deleteMock(id),
sendInvitation: (id: number, lang: string) => inviteMock(id, lang),
// The access tab also renders the stuck-sign-ups section (E9.5 S5); nothing stuck here, so it
// renders nothing and this file stays about the delete affordance.
adminPendingAccess: () => Promise.resolve([]),
},
};
});
import { HttpError } from "../lib/api";
import AdminUsers from "./AdminUsers";
const invite = (over: Partial<Record<string, unknown>> = {}) => ({
id: 1,
email: "asker@example.com",
status: "pending",
note: null,
requested_at: null,
decided_at: null,
decided_by: null,
has_account: false,
still_allowed_by_env: false,
invited_at: null,
lang: null,
...over,
});
const renderPanel = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<AdminUsers />
</QueryClientProvider>,
);
};
beforeEach(() => {
confirmMock.mockClear().mockResolvedValue(true);
notifyMock.mockClear();
deleteMock.mockReset().mockResolvedValue({ deleted: 1, still_allowed_by_env: false });
listMock.mockReset().mockResolvedValue([invite()]);
inviteMock
.mockReset()
.mockResolvedValue(invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }));
meMock.email_configured = true;
});
afterEach(cleanup);
const openAccessTab = async () => {
await screen.findByText("settings.invites.title");
};
describe("Access requests — the delete affordance", () => {
it("offers a labelled delete on an entry with no account behind it", async () => {
renderPanel();
await openAccessTab();
const btn = await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com");
expect(btn.getAttribute("aria-disabled")).toBeNull();
});
it("keeps the control present and focusable — not missing — when the address has an account", async () => {
listMock.mockResolvedValue([invite({ has_account: true })]);
renderPanel();
await openAccessTab();
const btn = await screen.findByLabelText("settings.invites.deleteHasAccount");
// aria-disabled, not `disabled`: a disabled button leaves the tab order and takes its
// explanation with it, which is the whole reason the control is shown at all.
expect(btn.getAttribute("aria-disabled")).toBe("true");
expect((btn as HTMLButtonElement).disabled).toBe(false);
expect(btn.getAttribute("title")).toBe("settings.invites.deleteHasAccount");
expect(screen.queryByLabelText(/deleteLabel/)).toBeNull();
});
it("keeps the same control on the previously-decided list, where the case is commonest", async () => {
// The affordance exists twice — the pending rows and the collapsed "decided" list — and an
// approved address that has since registered lands in the second one. Both must behave alike.
listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]);
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByText(/settings\.invites\.decided/));
const btn = await screen.findByLabelText("settings.invites.deleteHasAccount");
expect(btn.getAttribute("aria-disabled")).toBe("true");
expect((btn as HTMLButtonElement).disabled).toBe(false);
});
it("offers a working delete on a decided entry with no account", async () => {
listMock.mockResolvedValue([invite({ status: "denied" })]);
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByText(/settings\.invites\.decided/));
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(1));
});
it("warns in the confirmation — before the click is irreversible — when the env still allows it", async () => {
listMock.mockResolvedValue([invite({ still_allowed_by_env: true })]);
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
expect(confirmMock).toHaveBeenCalledTimes(1);
expect(confirmMock.mock.calls[0]![0]).toMatchObject({
message: "settings.invites.deleteConfirmStillAllowed:asker@example.com",
danger: true,
});
});
it("uses the plain confirmation otherwise", async () => {
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
expect(confirmMock.mock.calls[0]![0]).toMatchObject({
message: "settings.invites.deleteConfirm:asker@example.com",
});
});
it("says the entry kept its access when the server reports the env allow-list", async () => {
deleteMock.mockResolvedValue({ deleted: 1, still_allowed_by_env: true });
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({
level: "warning",
message: "settings.invites.deletedStillAllowed",
}),
);
});
it("turns a 409 into the actionable reason rather than a generic failure", async () => {
// The stale-list case: someone registered on the address after the page loaded.
deleteMock.mockRejectedValue(new HttpError(409, "That address already has an account"));
renderPanel();
await openAccessTab();
fireEvent.click(await screen.findByLabelText("settings.invites.deleteLabel:asker@example.com"));
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({
level: "error",
message: "settings.invites.deleteHasAccount",
}),
);
});
it("shows the mail-is-off warning only while email is unconfigured", async () => {
renderPanel();
await openAccessTab();
expect(screen.queryByText("settings.invites.emailOff")).toBeNull();
cleanup();
meMock.email_configured = false;
renderPanel();
await openAccessTab();
expect(await screen.findByText("settings.invites.emailOff")).toBeTruthy();
});
});
// Whitelisting an address is opening a door as a possibility, not knocking on one — the operator has
// usually already agreed it with the person, and a row can sit approved for weeks. So adding stays
// silent and telling them is its own act, on its own button (E9.5 S6).
describe("inviting someone the admin whitelisted", () => {
// The decided rows live behind a <details>; await the summary rather than the section title, or
// the click races the invite query that populates it.
const open = async () => {
renderPanel();
fireEvent.click(await screen.findByText(/settings.invites.decided/));
};
it("offers a send button on an approved row", async () => {
listMock.mockResolvedValue([invite({ status: "approved" })]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
// Falls back to the admin's own UI language: nobody ever told us anything about this mailbox.
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "en"));
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "settings.invites.invited:asker@example.com" }),
);
});
it("asks first, and writes to nobody when the admin backs out", async () => {
confirmMock.mockResolvedValue(false);
listMock.mockResolvedValue([invite({ status: "approved" })]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(confirmMock).toHaveBeenCalledTimes(1));
expect(inviteMock).not.toHaveBeenCalled();
});
it("defaults to the language this person asked us in", async () => {
listMock.mockResolvedValue([invite({ status: "approved", lang: "hu" })]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
});
it("sends in the language picked in the dialog, not the default", async () => {
// The dialog is the only place this decision can be made — and the pick has to survive back to
// the caller, because the confirmation resolves to a plain boolean.
let release: (ok: boolean) => void = () => {};
confirmMock.mockImplementation(() => new Promise<boolean>((r) => (release = r)));
listMock.mockResolvedValue([invite({ status: "approved" })]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
fireEvent.click(dialog.getByText("Magyar"));
release(true);
await waitFor(() => expect(inviteMock).toHaveBeenCalledWith(1, "hu"));
});
it("warns in the dialog when this person has already been written to", async () => {
// The warning used to live only in a tooltip, which the admin may never hover. It belongs next
// to the button that would mail a real person a second time.
listMock.mockResolvedValue([
invite({ status: "approved", invited_at: "2026-09-05T09:00:00Z" }),
]);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
const dialog = render(<>{confirmMock.mock.calls[0]![0].message}</>);
expect(dialog.container.textContent).toContain("settings.invites.inviteConfirmAgain");
});
it("offers none on a denied row, which would contradict the decision", async () => {
listMock.mockResolvedValue([invite({ status: "denied" })]);
await open();
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
});
it("offers none once somebody is already behind that address", async () => {
// "The door is open, come in" is meaningless to someone already inside, and most of a mature
// whitelist is existing users. An account that exists but is stuck has its own row above.
listMock.mockResolvedValue([invite({ status: "approved", has_account: true })]);
await open();
expect(screen.queryByLabelText("settings.invites.inviteLabel:asker@example.com")).toBeNull();
});
it("says the mail was never sent when the instance has no SMTP", async () => {
listMock.mockResolvedValue([invite({ status: "approved" })]);
inviteMock.mockRejectedValue(
new HttpError(409, "ignored", { code: "invite_send", reason: "email_off" }),
);
await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
// A button the admin pressed must not answer with a success toast for a mail nobody received.
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "settings.invites.inviteEmailOff" }),
),
);
});
});
@@ -0,0 +1,186 @@
// @vitest-environment jsdom
//
// The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25). Approve an address
// BEFORE the person registers and their sign-up produces no queue row at all, so the Access-requests
// screen was empty while they sat locked out and the only working move — re-typing the address into
// the whitelist box — was undiscoverable. These pin what the section shows and, just as importantly,
// what it does NOT show: the section is a discovery surface, and a person the queue above already
// covers must not appear twice.
//
// i18n is a key-passthrough so assertions read stable keys; the API and the notify singleton are
// stubbed so the panel can be driven without a server.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars?.email ? `${key}:${String(vars.email)}` : key,
i18n: { language: "en" },
}),
};
});
vi.mock("./ConfirmProvider", () => ({
useConfirm: () => vi.fn(async () => true),
ConfirmProvider: ({ children }: { children: React.ReactNode }) => children,
}));
const notifyMock = vi.fn();
vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>();
return { ...actual, notify: (...a: unknown[]) => notifyMock(...a) };
});
const meMock = {
pending_invites: 0,
pending_activations: 0,
email_configured: true,
id: 1,
role: "admin",
};
vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
// The tab strip renders into an app-level toolbar slot that does not exist here; pin the tab.
vi.mock("./Tabs", () => ({
default: () => null,
usePersistedTab: () => ["access", () => {}],
}));
const pendingMock = vi.fn();
const activateMock = vi.fn();
const resendMock = vi.fn();
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
adminInvites: () => Promise.resolve([]),
adminPendingAccess: () => pendingMock(),
activateUser: (id: number) => activateMock(id),
resendUserVerification: (id: number) => resendMock(id),
},
};
});
import AdminUsers from "./AdminUsers";
/** A row as /api/admin/pending-access serves it. */
const row = (over: Record<string, unknown> = {}) => ({
id: 7,
email: "later@example.com",
display_name: null,
role: "user",
is_active: false,
is_suspended: false,
email_verified: true,
is_demo: false,
has_password: true,
has_google: false,
created_at: "2026-09-01T10:00:00Z",
can_activate: true,
...over,
});
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<AdminUsers />
</QueryClientProvider>,
);
}
beforeEach(() => {
pendingMock.mockReset();
activateMock.mockReset().mockResolvedValue({});
resendMock.mockReset().mockResolvedValue({ ok: true, email: "later@example.com" });
notifyMock.mockReset();
});
afterEach(cleanup);
describe("the stuck sign-ups section", () => {
it("offers Activate for a registrant whose address was already approved", async () => {
pendingMock.mockResolvedValue([row()]);
mount();
const button = await screen.findByText("users.pendingAccess.activate");
fireEvent.click(button);
await waitFor(() => expect(activateMock).toHaveBeenCalledWith(7));
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "users.pendingAccess.activated:later@example.com" }),
);
});
it("shows no Activate when the address is not allowed, since that would be a different decision", async () => {
// Activating an address with no approved invite makes an account that signs in with a password
// but is turned away by Google — the whitelist box is the honest move for that.
pendingMock.mockResolvedValue([row({ can_activate: false, email_verified: false })]);
mount();
await screen.findByText("users.pendingAccess.emailUnconfirmed");
expect(screen.queryByText("users.pendingAccess.activate")).toBeNull();
});
it("names which gate is shut, so an admin sees an unconfirmed address before letting it in", async () => {
pendingMock.mockResolvedValue([row({ email_verified: false })]);
mount();
// On a no-SMTP instance this is the difference between the owner and whoever registered the
// address first (roadmap SEC-15), so it belongs on the row, not in a tooltip.
expect(await screen.findByText("users.pendingAccess.emailUnconfirmed")).toBeTruthy();
expect(screen.getByText("users.pendingAccess.inactive")).toBeTruthy();
});
it("re-sends the confirmation link for an unconfirmed account", async () => {
pendingMock.mockResolvedValue([row({ email_verified: false })]);
mount();
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
await waitFor(() => expect(resendMock).toHaveBeenCalledWith(7));
});
it("offers no resend once the address is confirmed", async () => {
pendingMock.mockResolvedValue([row()]);
mount();
await screen.findByText("users.pendingAccess.activate");
expect(screen.queryByLabelText("users.pendingAccess.resend")).toBeNull();
});
it("says the mail was never sent when the instance has no SMTP", async () => {
const { HttpError } = await import("../lib/api");
pendingMock.mockResolvedValue([row({ email_verified: false })]);
resendMock.mockRejectedValue(
new HttpError(409, "ignored", { code: "resend", reason: "email_off" }),
);
mount();
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
// back on. A button the admin pressed must not answer with a success toast.
await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "users.pendingAccess.resendEmailOff" }),
),
);
});
it("renders nothing at all when nobody is stuck", async () => {
pendingMock.mockResolvedValue([]);
mount();
// The section withdraws itself rather than leaving an empty box to explain — so wait for it to
// GO, not merely for the request to have been made.
await waitFor(() => expect(screen.queryByText("users.pendingAccess.title")).toBeNull());
expect(pendingMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,267 @@
// @vitest-environment jsdom
//
// The honest states of the sign-in card (E9.5 S4). Two things are under test and they share a root:
// a screen that does not name the address it is talking about hides an alias mismatch. On 2026-09-03
// a registration as `np.temu@gmail.com` met a Google claim of `nptemu@gmail.com` and every screen in
// the funnel stayed silent about it.
//
// entryUrl snapshots window.location at IMPORT time, so the access-notice cases reset the module
// registry and set the URL before importing — that is the behaviour under test, not a workaround.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import type { VerifyEntry } from "../lib/useVerifyEntry";
const noVerify: VerifyEntry = { outcome: null, email: null, dismiss: () => {} };
afterEach(() => {
cleanup();
vi.resetModules();
vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
});
/** Mount the real card at a given entry URL, with i18n as a key-passthrough and /auth/* stubbed. */
async function mountCard(url: string, apiOverrides: Record<string, unknown> = {}) {
window.history.replaceState(null, "", url);
vi.resetModules();
vi.doMock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && typeof opts.contact === "string" ? `${key}:${opts.contact}` : key,
i18n: { language: "en" },
}),
};
});
vi.doMock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
authConfig: () => Promise.resolve({ google_enabled: false }),
...apiOverrides,
},
};
});
const { HttpError } = await import("../lib/api");
const { default: Welcome } = await import("./Welcome");
await act(async () => {
render(<Welcome verify={noVerify} />);
});
return { HttpError, Welcome };
}
/** Fill the sign-in form and submit it. */
async function signIn(email = "someone@example.com") {
fireEvent.change(screen.getByPlaceholderText("welcome.auth.email"), { target: { value: email } });
fireEvent.change(screen.getByPlaceholderText("welcome.auth.password"), {
target: { value: "correct horse battery" },
});
await act(async () => {
fireEvent.click(screen.getByText("welcome.auth.signinButton"));
});
}
/** A password-login refusal carrying the server's structured detail. */
function refusal(
HttpError: typeof import("../lib/api").HttpError,
detail: Record<string, unknown>,
) {
return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail }));
}
describe("a refused sign-in becomes a named state", () => {
it("names the address the account is on, not the one that was typed", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(
refusal(HttpError, {
reason: "pending_approval",
email: "np.temu@gmail.com",
email_verified: true,
approved: false,
}),
);
await signIn("nptemu@gmail.com");
expect(screen.getByText("welcome.auth.blocked.pending_approval.title")).toBeTruthy();
expect(screen.getByText("np.temu@gmail.com")).toBeTruthy();
// The password was already proven correct, so the form is not the next step and must be gone —
// leaving it up would only invite a retry that cannot succeed.
expect(screen.queryByPlaceholderText("welcome.auth.password")).toBeNull();
});
it("reports each gate from its own flag, so an approved-but-unverified account is told the truth", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(
refusal(HttpError, {
reason: "email_unverified",
email: "approved.first@example.com",
email_verified: false,
approved: true,
}),
);
await signIn("approved.first@example.com");
// E9.5 S1 made the two gates order-independent; a screen that assumed a sequence would send this
// user off to wait for an admin who has already decided.
expect(screen.getByText("welcome.auth.blocked.gate.emailPending")).toBeTruthy();
expect(screen.getByText("welcome.auth.blocked.gate.approvalDone")).toBeTruthy();
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
});
it("shows no checklist for a suspension, which is not a gate that opens on its own", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(
refusal(HttpError, {
reason: "suspended",
email: "banned@example.com",
operator: "ops@example.com",
}),
);
await signIn("banned@example.com");
expect(screen.getByText("welcome.auth.blocked.suspended.title")).toBeTruthy();
expect(screen.getByText("welcome.auth.blocked.operator:ops@example.com")).toBeTruthy();
expect(screen.queryByText("welcome.auth.blocked.gate.emailDone")).toBeNull();
});
it("leaves a wrong password as a line under the form, where the form IS the next step", async () => {
const passwordLogin = vi.fn();
const { HttpError } = await mountCard("/", { passwordLogin });
passwordLogin.mockImplementation(() =>
Promise.reject(new HttpError(401, "localized invalid", { code: "login", reason: "invalid" })),
);
await signIn();
expect(screen.getByText("localized invalid")).toBeTruthy();
expect(screen.getByPlaceholderText("welcome.auth.password")).toBeTruthy();
});
});
// Reported 2026-09-05: delete an account, then open its reset link. The card drew the full "set a new
// password" form purely because a `#reset=` fragment was present, and only told the user after they
// had typed one. Opening a dead reset link is ordinary — the token lives an hour, mail is slow, and
// whoever got two mails clicks the older one.
describe("an emailed reset link that is already finished", () => {
it("refuses to draw a form that cannot succeed", async () => {
await mountCard("/#reset=dead-token", {
checkPasswordReset: () => Promise.resolve({ ok: false }),
});
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
expect(screen.getByText("welcome.auth.deadLink.cta")).toBeTruthy();
expect(screen.queryByPlaceholderText("welcome.auth.newPassword")).toBeNull();
});
it("draws the form when the link is still good", async () => {
await mountCard("/#reset=live-token", {
checkPasswordReset: () => Promise.resolve({ ok: true }),
});
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
});
it("takes the form down if the link dies between the check and the submit", async () => {
const confirmPasswordReset = vi.fn();
const { HttpError } = await mountCard("/#reset=racing-token", {
checkPasswordReset: () => Promise.resolve({ ok: true }),
confirmPasswordReset,
});
confirmPasswordReset.mockImplementation(() =>
Promise.reject(new HttpError(400, "ignored", { code: "reset", reason: "invalid" })),
);
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
target: { value: "brand new password" },
});
await act(async () => {
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
});
expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy();
});
it("keeps the form up for a too-short password, which costs the link nothing", async () => {
const confirmPasswordReset = vi.fn();
const { HttpError } = await mountCard("/#reset=live-token", {
checkPasswordReset: () => Promise.resolve({ ok: true }),
confirmPasswordReset,
});
confirmPasswordReset.mockImplementation(() =>
Promise.reject(
new HttpError(400, "too short", { code: "reset", reason: "weak_password", min: 10 }),
),
);
fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), {
target: { value: "0123456789ab" },
});
await act(async () => {
fireEvent.click(screen.getByText("welcome.auth.resetButton"));
});
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull();
});
it("probes a token once, however many times the card mounts", async () => {
const checkPasswordReset = vi.fn().mockResolvedValue({ ok: true });
const { Welcome } = await mountCard("/#reset=counted-token", { checkPasswordReset });
await act(async () => {
render(<Welcome verify={noVerify} />);
});
// StrictMode double-invokes effects and the shell remounts this card as the session probe
// settles; four probes per opened link would exhaust the endpoint's per-IP budget in two reloads
// and silently drop back to drawing the form unchecked.
expect(checkPasswordReset).toHaveBeenCalledTimes(1);
});
it("shows the form when the check itself fails, so a broken probe locks nobody out", async () => {
await mountCard("/#reset=live-token", {
checkPasswordReset: () => Promise.reject(new Error("network")),
});
expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy();
});
});
describe("the access-request notice from a denied Google sign-in", () => {
it("names the address the request was recorded for", async () => {
await mountCard("/#access=requested&email=nptemu%40gmail.com");
expect(screen.getByText("welcome.auth.accessRequestedFor")).toBeTruthy();
expect(screen.getByText("nptemu@gmail.com")).toBeTruthy();
expect(screen.queryByText("welcome.auth.accessLinked")).toBeNull();
});
it("explains the alias when the request is already on file under another spelling", async () => {
await mountCard(
"/#access=requested&email=nptemu%40gmail.com&linked=np.temu%2Bsiftlode%40gmail.com",
);
expect(screen.getByText("welcome.auth.accessLinked")).toBeTruthy();
// %2B, not a bare `+`: URLSearchParams reads the latter as a space and would mangle exactly the
// address family this epic exists to protect.
expect(screen.getByText("np.temu+siftlode@gmail.com")).toBeTruthy();
});
it("falls back to the unnamed notice when the server sent no address", async () => {
await mountCard("/#access=requested");
expect(screen.getByText("welcome.auth.accessRequested")).toBeTruthy();
expect(screen.queryByText("welcome.auth.accessRequestedFor")).toBeNull();
});
});
+1 -1
View File
@@ -94,7 +94,7 @@ export type PageDef = {
export const PAGE_CONTENT: Record<Page, PageDef> = {
feed: { Component: FeedPage },
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
playlists: { Component: Playlists, maxWidth: "64rem" },
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "1600px" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
messages: { Component: MessagesPage },
+31 -13
View File
@@ -8,14 +8,23 @@ import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "..
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
// state). Rendered by the browse grid, the person page, and the series show/season views.
// Playback cost, escalating good→heavy: direct (green) → remux, a light repackage
// (amber) → transcode, a full re-encode (red). Reuses the status trio as a gradient;
// all three stay visually distinct.
const PLAYABLE_TINT: Record<string, string> = {
// Playback cost, escalating good→heavy: Direct Play (green) → Direct Stream / remux, a light
// repackage (blue) → Transcode · HW, a hardware re-encode (amber) → Transcode · SW, a software
// re-encode (red). transcode splits HW vs SW by card.transcode_hw. Reuses the semantic tokens as a
// gradient; all four stay visually distinct in light + dark.
export const PLAYABLE_TINT: Record<string, string> = {
direct: "bg-success",
remux: "bg-warn",
transcode: "bg-danger",
remux: "bg-info",
transcode_hw: "bg-warn",
transcode_sw: "bg-danger",
};
// Collapse the wire `playable` (direct|remux|transcode) + transcode_hw flag into one of the 4 display
// classes, so the corner dot's colour + tooltip can't drift from the sidebar filter's option keys.
// Shared with the info-page playback chip (PlexInfo) so both surfaces derive the class identically.
export function playableClass(playable: string, transcodeHw?: boolean | null): string {
if (playable === "transcode") return transcodeHw ? "transcode_hw" : "transcode_sw";
return playable; // direct | remux
}
// The reveal class for a status pill (watched / in-progress) that shares a corner with a hover-reveal
// control (the watched-toggle). When that control is present it takes over on touch, so the pill yields
@@ -82,13 +91,22 @@ export function PlexPosterCard({
decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). */}
{card.playable && (
<span
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
title={t(`plex.playable.${card.playable}`)}
/>
)}
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-20 keeps the dot
ABOVE the full-card hover-reveal overlay below (absolute inset-0, no pointer-events-none):
without it the overlay sits on top and swallows the cursor, so the dot's native `title`
tooltip never fired on hover (a regression once the reveal overlay was added). */}
{card.playable &&
(() => {
const cls = playableClass(card.playable, card.transcode_hw);
return (
<span
role="img"
aria-label={t(`plex.playable.${cls}`)}
className={`absolute top-1.5 left-1.5 z-20 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`}
title={t(`plex.playable.${cls}`)}
/>
);
})()}
{/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card
(reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */}
{card.status === "watched" && (
@@ -110,6 +110,13 @@ export function buildPlexActiveFilters(
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
});
}
for (const p of filters.playable) {
out.push({
key: `playable:${p}`,
label: t(`plex.filter.playableOpt.${p}`),
remove: () => patch({ playable: filters.playable.filter((x) => x !== p) }),
});
}
for (const k of filters.keywords) {
out.push({
key: `keyword:${k}`,
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
//
// The login card's sub-views (Create an account / Forgot password / Didn't get the email) used to be
// plain React state: clicking one changed nothing in history, so Back popped to whatever entry came
// before the card and the address bar moved while the form on screen did not. They now ride
// useHistorySubview, and this pins the wiring decisions that are specific to THIS card — which modes
// are mirrored, and that Back walks the views. The hook's own mechanics live in
// history.subview.dom.test.ts.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useHistorySubview } from "../lib/history";
import { MODE_URL, type Mode } from "./Welcome";
const render = (root: Mode = "signin") => renderHook(() => useHistorySubview<Mode>(root, MODE_URL));
// jsdom's history.back() is asynchronous and does not rewind location, so — as in
// history.subview.dom.test.ts — a Back is simulated by landing on the target entry and firing
// popstate. `open()` pushing a REAL entry is asserted separately via history.length.
const popTo = (search: string, state: unknown = null) =>
act(() => {
window.history.replaceState(state, "", search || "/");
window.dispatchEvent(new PopStateEvent("popstate", { state }));
});
// jsdom keeps one history across a file; land on a clean root entry before each test.
beforeEach(() => window.history.replaceState(null, "", "/"));
afterEach(() => window.history.replaceState(null, "", "/"));
describe("login card mode ↔ URL", () => {
it("mirrors the three link-reachable views and leaves sign-in as the bare root", () => {
expect(MODE_URL.encode("signin")).toBeNull();
expect(MODE_URL.encode("register")).toBe("register");
expect(MODE_URL.encode("forgot")).toBe("forgot");
expect(MODE_URL.encode("resend")).toBe("resend");
});
it("never mirrors the reset view", () => {
// Reset is only reachable with the one-time token from the #reset fragment. Mirroring it would
// hand out a bookmark that opens a password form with no token behind it.
expect(MODE_URL.encode("reset")).toBeNull();
expect(MODE_URL.decode("reset")).toBeNull();
});
it("decodes only real views, so a hand-typed ?m= falls back to sign-in", () => {
expect(MODE_URL.decode("register")).toBe("register");
expect(MODE_URL.decode("")).toBeNull();
expect(MODE_URL.decode("nonsense")).toBeNull();
});
it("opening a view pushes a real history entry, and landing back on the root restores sign-in", () => {
const { result } = render();
const entries = window.history.length;
expect(result.current.view).toBe("signin");
act(() => result.current.open("forgot"));
// The whole point: a Back has somewhere to go. Before this the click changed state only.
expect(window.history.length).toBe(entries + 1);
expect(result.current.view).toBe("forgot");
expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot");
popTo("/");
expect(result.current.view).toBe("signin");
});
it("walks back one view at a time when the user hops between them", () => {
const { result } = render();
const entries = window.history.length;
act(() => result.current.open("register"));
act(() => result.current.open("forgot"));
expect(window.history.length).toBe(entries + 2); // one entry per view, not one for the lot
popTo("/?m=register", { _sub: "register" });
expect(result.current.view).toBe("register");
popTo("/");
expect(result.current.view).toBe("signin");
});
it("opens on the reset view when a reset token seeded it, without putting it in the URL", () => {
const { result } = render("reset");
expect(result.current.view).toBe("reset");
expect(window.location.search).toBe("");
});
it("restores a bookmarked view from the URL alone", () => {
window.history.replaceState(null, "", "/?m=resend");
const { result } = render();
expect(result.current.view).toBe("resend");
});
});
@@ -0,0 +1,121 @@
// @vitest-environment jsdom
//
// The sign-in card's sub-views as the user meets them: clicking a link must leave a history entry,
// so Back — including the mouse's back button — returns to the previous view. They used to be plain
// React state, and Back moved only the address bar while the form on screen stayed put. The
// encode/decode table lives in welcomeMode.dom.test.ts; this file renders the real card, so a revert
// to useState fails here.
//
// i18n is mocked to a key-passthrough so queries read stable keys, and the API module is stubbed —
// the card probes /auth/config on mount and must not reach the network.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: { ...actual.api, authConfig: () => Promise.resolve({ google_enabled: false }) },
};
});
import Welcome from "./Welcome";
import type { VerifyEntry } from "../lib/useVerifyEntry";
// App confirms the emailed token and hands the outcome down; these tests are about the card's
// sub-views, so there is nothing to report.
const noVerify: VerifyEntry = { outcome: null, email: null, dismiss: () => {} };
const back = () =>
act(() => {
// jsdom's history.back() is async and does not rewind location, so land on the previous entry
// the way the browser would and fire popstate. The push itself is asserted via history.length.
window.history.replaceState(null, "", "/");
window.dispatchEvent(new PopStateEvent("popstate", { state: null }));
});
beforeEach(() => window.history.replaceState(null, "", "/"));
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
describe("sign-in card sub-views and the Back button", () => {
it("opens Forgot password as a history entry and comes back to sign in", async () => {
render(<Welcome verify={noVerify} />);
expect(await screen.findByText("welcome.auth.signinTitle")).toBeTruthy();
const entries = window.history.length;
fireEvent.click(screen.getByText("welcome.auth.forgotLink"));
expect(screen.getByText("welcome.auth.forgotTitle")).toBeTruthy();
expect(window.history.length).toBe(entries + 1); // a Back now has somewhere to go
expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot");
back();
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
expect(screen.queryByText("welcome.auth.forgotTitle")).toBeNull();
});
it("does the same for Create an account and the verification-email link", async () => {
render(<Welcome verify={noVerify} />);
await screen.findByText("welcome.auth.signinTitle");
fireEvent.click(screen.getByText("welcome.auth.createLink"));
expect(screen.getByText("welcome.auth.createTitle")).toBeTruthy();
back();
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
fireEvent.click(screen.getByText("welcome.auth.resendLink"));
expect(screen.getByText("welcome.auth.resendTitle")).toBeTruthy();
back();
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
});
});
// The signed-OUT half of the verification outcome: App confirms the emailed token above its
// signed-in branch and hands the result down, so the card no longer posts anything itself. These pin
// that each outcome still reaches the right banner — and that the banner is announced (role=status),
// which the 2026-09-02 review asked for and which is the whole point when the alternative was a
// screen that said nothing at all.
describe("the verification outcome handed down from App", () => {
const withOutcome = (outcome: VerifyEntry["outcome"]): VerifyEntry => ({
outcome,
email: "someone@example.com",
dismiss: () => {},
});
it("announces a confirmed address", async () => {
render(<Welcome verify={withOutcome("ok")} />);
const banner = await screen.findByText("welcome.auth.verifyOk");
expect(banner.closest('[role="status"]')).toBeTruthy();
});
it("offers the reset flow when the account's password had been revoked", async () => {
render(<Welcome verify={withOutcome("ok_reset")} />);
expect(await screen.findByText("welcome.auth.verifyOkReset")).toBeTruthy();
expect(screen.getByText("welcome.auth.verifyOkResetCta")).toBeTruthy();
});
it("offers a new link when the token is spent", async () => {
render(<Welcome verify={withOutcome("invalid")} />);
expect(await screen.findByText("welcome.auth.verifyInvalid")).toBeTruthy();
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
});
it("shows no banner when there was no token to confirm", async () => {
render(<Welcome verify={noVerify} />);
await screen.findByText("welcome.auth.signinTitle");
expect(screen.queryByText("welcome.auth.verifyOk")).toBeNull();
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
});
});
@@ -3,6 +3,8 @@
"user_suspend": "User suspended",
"user_unsuspend": "User reinstated",
"user_delete": "User deleted",
"user_activate": "Account activated",
"user_verify_resend": "Confirmation link re-sent",
"invite_approve": "Access request approved",
"invite_deny": "Access request denied",
"invite_add": "Email whitelisted",
@@ -19,5 +21,7 @@
"job_run": "Scheduler job run",
"audit_clear": "Audit log cleared",
"download_credential_set": "Download cookies set",
"download_credential_delete": "Download cookies removed"
"download_credential_delete": "Download cookies removed",
"invite_delete": "Whitelist entry deleted",
"invite_send": "Invitation sent"
}
+2 -1
View File
@@ -216,5 +216,6 @@
"plexPickLibraries": "Libraries to expose:",
"plexPickHint": "Unchecking all is the same as exposing every library. Remember to Save.",
"plexMovies": "Movies",
"plexShows": "TV Shows"
"plexShows": "TV Shows",
"emailOff": "Email sending is not configured — Siftlode silently skips every message: verification links, password resets and access-request notices. Fill in the fields above, then send a test."
}
+8 -1
View File
@@ -34,7 +34,8 @@
"signOut": "Sign out",
"addAccount": "Add another account",
"switchTo": "Switch to {{name}}",
"audit": "Audit log"
"audit": "Audit log",
"googleAddress": "Google account: {{email}}"
},
"sync": {
"yours": "yours",
@@ -55,5 +56,11 @@
"updated": "Updated to v{{version}} — see what's changed.",
"releaseNotes": "Release notes",
"dismiss": "Dismiss"
},
"verify": {
"confirmed": "Email address confirmed.",
"confirmedNamed": "Email address confirmed — {{email}}.",
"invalid": "That verification link is no longer valid — it has expired or was already used.",
"reset": "The password this account registered with was cleared for security; set a new one before signing in."
}
}
+5
View File
@@ -28,5 +28,10 @@
"jobDone": {
"titleOk": "{{job}} finished",
"titleError": "{{job}} failed"
},
"emailUndelivered": {
"title": "Email is not going out",
"notConfigured": "SMTP is not configured, so nothing this instance sends is being delivered — verification links, password resets and access requests alike. One of them was “{{subject}}”. Set it up under Configuration → Email / SMTP.",
"sendFailed": "Sending failed, so messages are not being delivered. One of them was “{{subject}}”. Check Configuration → Email / SMTP and send a test; the server log has the reason."
}
}
+12 -3
View File
@@ -104,6 +104,13 @@
"collectionEmpty": "No collections",
"matchLabel": "Match",
"studio": "Studio",
"playable": "Playback",
"playableOpt": {
"direct": "Direct Play",
"remux": "Direct Stream",
"transcode_hw": "Transcode · HW",
"transcode_sw": "Transcode · SW"
},
"moreRefine": "{{count}} more — refine with search"
},
"count": "{{count}} titles",
@@ -285,8 +292,9 @@
},
"playable": {
"direct": "Plays directly in the browser",
"remux": "Needs a light remux (no video re-encode)",
"transcode": "Needs transcoding (heavier)"
"remux": "Direct Stream — a light remux (no video re-encode)",
"transcode_hw": "Transcoded on the hardware encoder",
"transcode_sw": "Transcoded in software (heaviest)"
},
"playlistAdd": {
"manage": "Add to playlist",
@@ -342,7 +350,8 @@
"browse": "Browse"
},
"browse": {
"items": "{{count}} items"
"items_one": "{{count}} item",
"items_other": "{{count}} items"
},
"discover": {
"tab": "Discover",
+25 -2
View File
@@ -78,7 +78,8 @@
"linked": "Google account linked.",
"conflict": "That Google account is already linked to a different Siftlode account.",
"mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.",
"error": "Couldn't link the Google account. Please try again."
"error": "Couldn't link the Google account. Please try again.",
"signedInAs": "Signed in with Google as"
},
"googleCancelled": "Google sign-in was cancelled.",
"password": {
@@ -156,6 +157,17 @@
"approveFailed": "Approve failed",
"denyFailed": "Deny failed",
"addedToWhitelist": "{{email}} added to the whitelist",
"inviteHint": "Send them an invitation. Adding an address never mails anyone — this does.",
"invitedAt": "Invited {{time}}. Sending again will mail them a second time.",
"inviteLabel": "Send an invitation to {{email}}",
"invited": "Invitation sent to {{email}}.",
"inviteFailed": "Couldn't send that invitation.",
"inviteEmailOff": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email.",
"inviteTitle": "Send invitation?",
"inviteConfirm": "Email {{email}} to say their address is whitelisted and Siftlode is open for them.",
"inviteConfirmAgain": "You already invited {{email}} on {{time}}. Sending now writes to them a second time.",
"inviteSend": "Send",
"inviteLang": "Mail language",
"addFailed": "Couldn't add that email",
"requested": "requested {{time}}",
"approveHint": "Approve — whitelist this email and email them they're in.",
@@ -164,6 +176,17 @@
"deny": "Deny",
"denied": "Request denied",
"denyTitle": "Deny access request?",
"denyConfirm": "Deny the access request from {{email}}?"
"denyConfirm": "Deny the access request from {{email}}?",
"delete": "Delete",
"deleteLabel": "Delete {{email}} from the whitelist",
"deleteHint": "Forget this address. A later request from it comes back as a new one.",
"deleteTitle": "Delete whitelist entry?",
"deleteConfirm": "Forget {{email}}? Siftlode drops the record, so anyone at that address would have to ask again.",
"deleteHasAccount": "Someone has registered on this address — manage it under Users & roles, where deleting the account removes this entry too.",
"deleted": "Whitelist entry removed.",
"deleteFailed": "Could not remove the entry.",
"emailOff": "Email sending is not configured, so nobody gets a verification link and no request reaches you by email. Watch this list and your inbox instead, and fix it under Configuration → Email / SMTP.",
"deletedStillAllowed": "Entry removed, but this address is still allow-listed by the server's configuration, so it keeps access. Remove it from ALLOWED_EMAILS / ADMIN_EMAILS to change that.",
"deleteConfirmStillAllowed": "Forget {{email}}? Careful: this address is also allow-listed in the server's configuration (ALLOWED_EMAILS / ADMIN_EMAILS), so removing this row will NOT take its access away — and an ADMIN_EMAILS address is made an admin again on its next Google sign-in."
}
}
+17
View File
@@ -4,6 +4,23 @@
"access": "Access requests",
"demo": "Demo"
},
"pendingAccess": {
"title": "Signed up, but still outside",
"body": "These people created an account that can't sign in yet. If you approved their address before they registered, no request reached the queue above — let them in here.",
"signedUp": "Signed up {{time}}",
"emailConfirmed": "email confirmed",
"emailUnconfirmed": "email not confirmed",
"inactive": "waiting for activation",
"activate": "Activate",
"activateHint": "Let this account in. Their address is already approved — this only finishes it.",
"activated": "{{email}} can sign in now.",
"activateFailed": "Couldn't activate that account.",
"resend": "Send the confirmation link again",
"resendHint": "Send the confirmation link again, to the address on the account.",
"resent": "Confirmation link sent to {{email}}.",
"resendFailed": "Couldn't send the confirmation link.",
"resendEmailOff": "No mail was sent: this instance has no working SMTP setup. Check Configuration → Email."
},
"roles": {
"title": "Users & roles",
"intro": "Everyone who can sign in. Promote a user to admin, or back to a regular user.",
+46 -3
View File
@@ -82,15 +82,58 @@
"demoEmail": "Demo email",
"demoEnter": "Enter",
"demoDenied": "That email isn't enabled for the demo. Ask the admin to add it.",
"registerDone": "Almost there — check your email for a verification link. After you confirm it, an admin approves your account before you can sign in.",
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox.",
"registerDone": "Almost there — check your email for a confirmation link, and your spam folder if it isn't there in a minute. After you confirm it, an admin approves your account before you can sign in.",
"forgotDone": "If that email has an account, we've sent a reset link. Check your inbox, and your spam folder if it hasn't arrived in a minute.",
"resetDone": "Your password has been set — you can sign in now.",
"deadLink": {
"title": "This link is no longer valid",
"body": "A password reset link lasts one hour and works once. This one has expired, has already been used, or belongs to an account that no longer exists — so there is nothing to set a password on. Ask for a fresh one and we'll email it.",
"short": "This reset link is no longer valid.",
"cta": "Request a new link"
},
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
"verifyOkReset": "Email confirmed. The password this account registered with had been cleared for security — choose a new one to sign in.",
"verifyOkResetCta": "Set a new password",
"verifyInvalid": "That verification link is invalid or has expired.",
"resendCta": "Send a new link",
"resendDone": "If that email has an unverified account, we've sent a new verification link. Check your inbox.",
"resendDone": "If that email has an unconfirmed account, we've sent a new confirmation link. Check your inbox, and your spam folder if it hasn't arrived in a minute.",
"oauthCancelled": "Google sign-in was cancelled. You can try again or use email and password.",
"accessRequested": "Thanks — we recorded your request. An admin will review it.",
"accessRequestedFor": "We've recorded your access request for:",
"accessRequestedReview": "An admin will review it, and we'll email you either way.",
"accessLinked": "You already had a request on file for this mailbox, under a different spelling:",
"accessLinkedTail": "Google returns one fixed spelling of a Gmail address, so we linked this sign-in to that request instead of opening a second one. Any mail goes to the address above.",
"blocked": {
"addressLabel": "Address on file",
"operator": "If you think this is a mistake, contact the operator at {{contact}}.",
"gate": {
"emailDone": "Email address confirmed",
"emailPending": "Email address — not confirmed yet",
"approvalDone": "Admin approval — granted",
"approvalPending": "Admin approval — pending"
},
"email_unverified": {
"title": "Confirm your email address",
"body": "Your password is correct — we just can't let you in until the address on this account is confirmed. Open the link we emailed you, or ask for a new one.",
"short": "Confirm your email address first — check your inbox."
},
"pending_approval": {
"title": "Waiting for approval",
"body": "Your password is correct and your address is confirmed. An admin still has to let you in — we'll email you as soon as they decide.",
"short": "Your account is waiting for admin approval."
},
"suspended": {
"title": "Account suspended",
"body": "This account has been suspended, so it can't sign in by any method.",
"short": "This account has been suspended."
},
"invalid": {
"short": "Invalid email or password."
},
"rate_limited": {
"short": "Too many attempts. Try again shortly."
}
},
"accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access.",
"suspended": "This account has been suspended and can't sign in. If you think this is a mistake, contact the operator (check your email for details).",
"deleted": "Your account and all its data were permanently deleted. We've emailed you a confirmation."
@@ -3,6 +3,8 @@
"user_suspend": "Felhasználó felfüggesztve",
"user_unsuspend": "Felhasználó visszaállítva",
"user_delete": "Felhasználó törölve",
"user_activate": "Fiók aktiválva",
"user_verify_resend": "Megerősítő link újraküldve",
"invite_approve": "Hozzáférési kérelem jóváhagyva",
"invite_deny": "Hozzáférési kérelem elutasítva",
"invite_add": "Email fehérlistázva",
@@ -19,5 +21,7 @@
"job_run": "Ütemező job lefutott",
"audit_clear": "Napló ürítve",
"download_credential_set": "Letöltési cookie-k beállítva",
"download_credential_delete": "Letöltési cookie-k eltávolítva"
"download_credential_delete": "Letöltési cookie-k eltávolítva",
"invite_delete": "Whitelista-bejegyzés törölve",
"invite_send": "Meghívó elküldve"
}
+2 -1
View File
@@ -216,5 +216,6 @@
"plexPickLibraries": "Megjelenítendő könyvtárak:",
"plexPickHint": "Ha egyiket sem jelölöd be, az az összes könyvtár megjelenítésével egyenértékű. Ne feledj menteni.",
"plexMovies": "Filmek",
"plexShows": "Sorozatok"
"plexShows": "Sorozatok",
"emailOff": "Az e-mail-küldés nincs beállítva — a Siftlode némán kihagy minden üzenetet: megerősítő linkeket, jelszó-visszaállításokat és hozzáférés-kérés értesítőket. Töltsd ki a fenti mezőket, majd küldj tesztet."
}
+8 -1
View File
@@ -34,7 +34,8 @@
"signOut": "Kijelentkezés",
"addAccount": "Másik fiók hozzáadása",
"switchTo": "Váltás erre: {{name}}",
"audit": "Naplózás"
"audit": "Naplózás",
"googleAddress": "Google-fiók: {{email}}"
},
"sync": {
"yours": "tiéd",
@@ -55,5 +56,11 @@
"updated": "Frissítve a(z) v{{version}} verzióra — nézd meg, mi változott.",
"releaseNotes": "Verziójegyzék",
"dismiss": "Elvetés"
},
"verify": {
"confirmed": "Megerősítettük az e-mail-címedet.",
"confirmedNamed": "Megerősítettük az e-mail-címedet — {{email}}.",
"invalid": "Ez a megerősítő link már nem érvényes — lejárt, vagy már felhasználták.",
"reset": "A fiók regisztrációkori jelszavát biztonsági okból töröltük; belépés előtt állíts be újat."
}
}
+5
View File
@@ -28,5 +28,10 @@
"jobDone": {
"titleOk": "{{job}} kész",
"titleError": "{{job}} hibázott"
},
"emailUndelivered": {
"title": "Nem megy ki e-mail",
"notConfigured": "Az SMTP nincs beállítva, így semmi nem kézbesíthető, amit ez a példány küldene — megerősítő linkek, jelszó-visszaállítások és hozzáférés-kérelmek egyaránt. Az egyik ilyen: „{{subject}}”. Állítsd be a Konfiguráció → E-mail / SMTP fülön.",
"sendFailed": "A küldés nem sikerült, így az üzenetek nem kézbesíthetők. Az egyik ilyen: „{{subject}}”. Nézd meg a Konfiguráció → E-mail / SMTP fület, és küldj tesztet; az okot a szerver naplója tartalmazza."
}
}
+12 -3
View File
@@ -104,6 +104,13 @@
"collectionEmpty": "Nincs kollekció",
"matchLabel": "Egyezés",
"studio": "Stúdió",
"playable": "Lejátszás",
"playableOpt": {
"direct": "Közvetlen lejátszás",
"remux": "Közvetlen streamelés",
"transcode_hw": "Átkódolás · HW",
"transcode_sw": "Átkódolás · SW"
},
"moreRefine": "még {{count}} — finomíts kereséssel"
},
"count": "{{count}} cím",
@@ -285,8 +292,9 @@
},
"playable": {
"direct": "Közvetlenül játszható a böngészőben",
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
"transcode": "Transcode kell (nehezebb)"
"remux": "Közvetlen streamelés — könnyű remux (nincs videó-újrakódolás)",
"transcode_hw": "Hardveres kódolóval átkódolva",
"transcode_sw": "Szoftveresen átkódolva (legnehezebb)"
},
"playlistAdd": {
"manage": "Listához adás",
@@ -342,7 +350,8 @@
"browse": "Böngészés"
},
"browse": {
"items": "{{count}} elem"
"items_one": "{{count}} elem",
"items_other": "{{count}} elem"
},
"discover": {
"tab": "Felfedezés",
+25 -2
View File
@@ -78,7 +78,8 @@
"linked": "Google-fiók összekapcsolva.",
"conflict": "Ez a Google-fiók már egy másik Siftlode-fiókhoz van kapcsolva.",
"mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.",
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra."
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra.",
"signedInAs": "Google-belépés ezzel a címmel:"
},
"googleCancelled": "A Google-belépés megszakadt.",
"password": {
@@ -156,6 +157,17 @@
"approveFailed": "A jóváhagyás sikertelen",
"denyFailed": "Az elutasítás sikertelen",
"addedToWhitelist": "{{email}} hozzáadva a fehérlistához",
"inviteHint": "Küldj neki meghívót. A cím hozzáadása soha nem küld levelet — ez igen.",
"invitedAt": "Meghívva: {{time}}. Az újraküldés másodszor is levelet küld neki.",
"inviteLabel": "Meghívó küldése ide: {{email}}",
"invited": "Meghívó elküldve ide: {{email}}.",
"inviteFailed": "Nem sikerült elküldeni a meghívót.",
"inviteEmailOff": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Beállítások → E-mail részt.",
"inviteTitle": "Küldesz meghívót?",
"inviteConfirm": "Levél megy a(z) {{email}} címre arról, hogy a címe engedélyezve van, és nyitva áll előtte a Siftlode.",
"inviteConfirmAgain": "Ezt a címet már meghívtad ekkor: {{time}} ({{email}}). Ha most küldesz, másodszor is írsz neki.",
"inviteSend": "Küldés",
"inviteLang": "A levél nyelve",
"addFailed": "Nem sikerült hozzáadni ezt az e-mailt",
"requested": "kérve: {{time}}",
"approveHint": "Jóváhagyás — fehérlistára teszi ezt az e-mailt és e-mailben értesíti őket, hogy bekerültek.",
@@ -164,6 +176,17 @@
"deny": "Elutasítás",
"denied": "Kérelem elutasítva",
"denyTitle": "Elutasítod a hozzáférési kérelmet?",
"denyConfirm": "Elutasítod {{email}} hozzáférési kérelmét?"
"denyConfirm": "Elutasítod {{email}} hozzáférési kérelmét?",
"delete": "Törlés",
"deleteLabel": "{{email}} törlése a whitelistáról",
"deleteHint": "A cím elfelejtése. Egy későbbi kérelme új kérelemként jelenik meg.",
"deleteTitle": "Törlöd a whitelista-bejegyzést?",
"deleteConfirm": "Elfelejted {{email}} címet? A Siftlode eldobja a bejegyzést, így az adott címről újra kérelmezni kell.",
"deleteHasAccount": "Erre a címre már regisztrált valaki — a Felhasználók és szerepek fülön kezelhető, ahol a fiók törlése ezt a bejegyzést is eltávolítja.",
"deleted": "Whitelista-bejegyzés eltávolítva.",
"deleteFailed": "A bejegyzést nem sikerült eltávolítani.",
"emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az értesítéseidet figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.",
"deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál.",
"deleteConfirmStillAllowed": "Elfelejted {{email}} címet? Figyelem: ezt a címet a szerver konfigurációja is engedélyezi (ALLOWED_EMAILS / ADMIN_EMAILS), így a bejegyzés törlése NEM veszi el a hozzáférését — sőt egy ADMIN_EMAILS cím a következő Google-belépéskor újra adminná válik."
}
}
+17
View File
@@ -4,6 +4,23 @@
"access": "Hozzáférési kérések",
"demo": "Demo"
},
"pendingAccess": {
"title": "Regisztrált, de még kívül van",
"body": "Ezek a fiókok léteznek, de még nem tudnak belépni. Ha a címüket a regisztrációjuk előtt hagytad jóvá, a fenti sorba semmi nem került — itt engedheted be őket.",
"signedUp": "Regisztrált: {{time}}",
"emailConfirmed": "cím megerősítve",
"emailUnconfirmed": "cím nincs megerősítve",
"inactive": "aktiválásra vár",
"activate": "Aktiválás",
"activateHint": "Beengedi ezt a fiókot. A címe már jóvá van hagyva — ez csak befejezi.",
"activated": "{{email}} mostantól be tud lépni.",
"activateFailed": "Nem sikerült aktiválni a fiókot.",
"resend": "Megerősítő link újraküldése",
"resendHint": "Újraküldi a megerősítő linket, a fiókon szereplő címre.",
"resent": "Megerősítő link elküldve ide: {{email}}.",
"resendFailed": "Nem sikerült elküldeni a megerősítő linket.",
"resendEmailOff": "Nem ment ki levél: ezen a példányon nincs működő SMTP. Nézd meg a Beállítások → E-mail részt."
},
"roles": {
"title": "Felhasználók és szerepek",
"intro": "Mindenki, aki be tud lépni. Léptess egy felhasználót adminná, vagy vissza sima felhasználóvá.",
+46 -3
View File
@@ -82,15 +82,58 @@
"demoEmail": "Demó e-mail",
"demoEnter": "Belépés",
"demoDenied": "Ez az e-mail nincs engedélyezve a demóhoz. Kérd az admint, hogy adja hozzá.",
"registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.",
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod.",
"registerDone": "Majdnem kész — nézd meg a postafiókod a megerősítő linkért, és a Spam mappát is, ha egy percen belül nem jön meg. Miután megerősítetted, egy admin jóváhagyja a fiókodat, mielőtt beléphetnél.",
"forgotDone": "Ha ehhez az e-mailhez tartozik fiók, elküldtük a visszaállító linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.",
"resetDone": "A jelszavad be van állítva — most már beléphetsz.",
"deadLink": {
"title": "Ez a link már nem érvényes",
"body": "A jelszó-visszaállító link egy óráig él, és egyszer használható. Ez lejárt, már felhasználták, vagy olyan fiókhoz tartozik, ami már nem létezik — nincs mire jelszót állítani. Kérj újat, és elküldjük.",
"short": "Ez a visszaállító link már nem érvényes.",
"cta": "Új link kérése"
},
"verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.",
"verifyOkReset": "E-mail megerősítve. A fiók regisztrációkori jelszavát biztonsági okból korábban töröltük — válassz újat a belépéshez.",
"verifyOkResetCta": "Új jelszó beállítása",
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
"resendCta": "Új link küldése",
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod.",
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod, és a Spam mappát is, ha egy percen belül nem érkezik meg.",
"oauthCancelled": "A Google-belépés megszakadt. Próbáld újra, vagy lépj be e-maillel és jelszóval.",
"accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.",
"accessRequestedFor": "Rögzítettük a hozzáférési kérelmedet erre a címre:",
"accessRequestedReview": "Egy admin átnézi, és e-mailben szólunk a döntésről.",
"accessLinked": "Ehhez a postafiókhoz már volt kérelmed, csak más írásmóddal:",
"accessLinkedTail": "A Google egy rögzített írásmódot ad vissza a Gmail-címekből, ezért ezt a belépést a meglévő kérelemhez kötöttük, nem indítottunk újat. A levelek a fenti címre mennek.",
"blocked": {
"addressLabel": "Rögzített cím",
"operator": "Ha tévedésnek gondolod, keresd az üzemeltetőt: {{contact}}.",
"gate": {
"emailDone": "E-mail-cím megerősítve",
"emailPending": "E-mail-cím — még nincs megerősítve",
"approvalDone": "Admin jóváhagyás — megvan",
"approvalPending": "Admin jóváhagyás — folyamatban"
},
"email_unverified": {
"title": "Erősítsd meg az e-mail-címed",
"body": "A jelszavad jó — csak addig nem engedhetünk be, amíg a fiókon lévő cím nincs megerősítve. Nyisd meg a kiküldött linket, vagy kérj újat.",
"short": "Előbb erősítsd meg az e-mail-címed — nézd meg a postafiókod."
},
"pending_approval": {
"title": "Jóváhagyásra vár",
"body": "A jelszavad jó, a címed megerősítve. Már csak arra várunk, hogy egy admin beengedjen — szólunk e-mailben, amint döntött.",
"short": "A fiókod admin jóváhagyásra vár."
},
"suspended": {
"title": "Felfüggesztett fiók",
"body": "Ez a fiók fel van függesztve, így semmilyen módon nem tud belépni.",
"short": "Ez a fiók fel van függesztve."
},
"invalid": {
"short": "Hibás e-mail-cím vagy jelszó."
},
"rate_limited": {
"short": "Túl sok próbálkozás. Próbáld újra kicsit később."
}
},
"accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól.",
"suspended": "Ez a fiók fel van függesztve, nem tud belépni. Ha tévedésnek gondolod, keresd az üzemeltetőt (a részletek az e-mailedben).",
"deleted": "A fiókod és minden adata véglegesen törölve lett. A megerősítést e-mailben is elküldtük."
+1
View File
@@ -27,6 +27,7 @@ import { accountApi } from "./api/account";
export type * from "./api/account";
import { authApi } from "./api/auth";
export type * from "./api/auth";
export { loginBlocked, deadResetLink } from "./api/auth";
import { setupApi } from "./api/setup";
export type * from "./api/setup";
import { feedApi } from "./api/feed";
+11
View File
@@ -10,12 +10,23 @@ export interface Me {
role: string;
is_demo: boolean;
has_google: boolean;
/** The address Google answers with, ONLY when it differs from `email` — the backend does that
* comparison, so a surface can render this whenever it is present. Someone who registered as
* `me+siftlode@gmail.com` keeps that address; this is the other one that also signs them in. */
google_email: string | null;
has_password: boolean;
google_enabled: boolean;
plex_enabled: boolean;
can_read: boolean;
can_write: boolean;
pending_invites: number;
/** Admin-only: sign-ups an admin still has to let in — approved BEFORE they registered, so nothing
* ever reached the Access-requests queue and its badge read zero (roadmap UX-25). Counted apart
* from pending_invites because they are different facts, and widening one number silently would
* change what an existing field means. */
pending_activations: number;
/** Admin-only: false when SMTP is incomplete, so every outbound message is silently skipped. */
email_configured: boolean;
preferences: Record<string, unknown>;
}
+48
View File
@@ -19,6 +19,30 @@ export interface Invite {
requested_at: string | null;
decided_at: string | null;
decided_by: string | null;
/** An account already exists for this address, so the entry can't be deleted from here — deleting
* it would revoke that user's access (is_allowed reads this row). Delete the user instead. */
has_account: boolean;
/** The address stays allow-listed by the server's env ALLOWED_EMAILS / ADMIN_EMAILS, so removing
* this row would NOT take access away. The delete confirmation says so. */
still_allowed_by_env: boolean;
/** When the admin last invited this person, or null if never. Adding an address to the whitelist
* is deliberately silent — it opens a door rather than knocking — so telling them is a separate
* act, and this is how the admin sees they have already done it. */
invited_at: string | null;
/** The language this person asked us in, or null when nobody ever told us — a row the admin
* whitelisted by hand. The invitation dialog preselects it and falls back to the admin's own UI
* language, so the guess is visible and correctable before anything is sent. */
lang: string | null;
}
/** A sign-up that is still outside, as the admin screen shows it. Same shape as a user row plus the
* one thing the client cannot work out for itself. */
export interface PendingAccessRow extends AdminUserRow {
/** The address is already allowed and the account is merely inactive, so Activate finishes a
* decision the admin has already made. False when there is no approved invite behind it —
* activating then would make an account that signs in with a password but is turned away by
* Google, so the whitelist box is the honest move instead. */
can_activate: boolean;
}
// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
@@ -163,10 +187,34 @@ export const adminApi = {
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
req("/api/admin/demo/reset", { method: "POST" }),
// --- access invites ---
// Sign-ups that registered but still cannot get in — the admin lifeline for roadmap UX-25.
adminPendingAccess: (): Promise<PendingAccessRow[]> => req("/api/admin/pending-access"),
// Let one in. Admin-only and explicit: it changes discoverability, not authority — the same thing
// was always possible by re-entering the address in the whitelist box.
activateUser: (id: number): Promise<AdminUserRow> =>
req(`/api/admin/users/${id}/activate`, { method: "POST" }, { quiet: true }),
// Re-send someone's confirmation link. Unlike the public resend this one says what happened,
// because the caller is already an authenticated admin.
resendUserVerification: (id: number): Promise<{ ok: boolean; email: string }> =>
req(`/api/admin/users/${id}/resend-verification`, { method: "POST" }, { quiet: true }),
adminInvites: (status?: string): Promise<Invite[]> =>
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
// Tell someone their address is whitelisted. Never automatic: adding the address stays silent, so
// this is the deliberate act that actually reaches a person.
sendInvitation: (id: number, lang: string): Promise<Invite> =>
req(
`/api/admin/invites/${id}/invite`,
{ method: "POST", body: JSON.stringify({ lang }) },
{ quiet: true },
),
addInvite: (email: string) =>
req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),
/** `still_allowed_by_env` is true when the address stays allow-listed by the server's env
* ALLOWED_EMAILS / ADMIN_EMAILS, i.e. removing the row did not actually revoke access. */
// quiet: a 409 here ("that address has an account") is a case the UI handles with a translated
// toast; without this the shared handler ALSO raises the server's English detail as a modal.
deleteInvite: (id: number): Promise<{ deleted: number; still_allowed_by_env: boolean }> =>
req(`/api/admin/invites/${id}`, { method: "DELETE" }, { quiet: true }),
};
+79 -4
View File
@@ -1,7 +1,56 @@
// The sign-in funnel: public auth config, email+password register/login/reset, email verification,
// session management, access requests, and the hidden demo login. Errors on the interactive paths
// are surfaced inline (quiet) rather than via the global modal. Only touches core's req.
import { req } from "./core";
import { req, uiLang } from "./core";
/** Why password sign-in refused. The three account-shaped reasons name the mailbox and both gates,
* so the card can render an honest state instead of a red line; `invalid` and `rate_limited` answer
* BEFORE the password is proven and therefore carry nothing (they must stay identical for a real
* account and an unknown one). Since E9.5 S1 the gates are order-independent — read both flags, never
* infer one from the other. */
export type LoginBlocked = {
reason: "email_unverified" | "pending_approval" | "suspended" | "invalid" | "rate_limited";
/** The address ON THE ACCOUNT — not the spelling that was typed. Absent for the two pre-proof
* reasons. Naming it is what would have exposed the 2026-09-03 Gmail-alias mismatch in a second. */
email?: string;
emailVerified?: boolean;
approved?: boolean;
/** Operator contact, when the instance publishes one (suspended accounts only). */
operator?: string;
};
/** Did a password-reset call fail because the LINK itself is finished — spent, expired, or belonging
* to an account that no longer exists — rather than because the new password was too short? The two
* arrive as the same 400, and only the first one means the form should come down. */
export function deadResetLink(detail: unknown): boolean {
const d = detail as { code?: string; reason?: string } | null | undefined;
return d?.code === "reset" && d.reason === "invalid";
}
const LOGIN_REASONS = new Set<LoginBlocked["reason"]>([
"email_unverified",
"pending_approval",
"suspended",
"invalid",
"rate_limited",
]);
/** Read a `{code:"login", …}` error detail into LoginBlocked, or null for anything else — including
* a reason a NEWER server invented, which the card must fall back to a plain message for rather than
* render as a blank panel. */
export function loginBlocked(detail: unknown): LoginBlocked | null {
const d = detail as Record<string, unknown> | null | undefined;
if (!d || d.code !== "login") return null;
const reason = d.reason as LoginBlocked["reason"];
if (!LOGIN_REASONS.has(reason)) return null;
return {
reason,
email: typeof d.email === "string" ? d.email : undefined,
emailVerified: typeof d.email_verified === "boolean" ? d.email_verified : undefined,
approved: typeof d.approved === "boolean" ? d.approved : undefined,
operator: typeof d.operator === "string" ? d.operator : undefined,
};
}
export const authApi = {
// Public: which sign-in options this instance offers (e.g. hide Google when not configured).
@@ -12,12 +61,16 @@ export const authApi = {
}> => req("/auth/config"),
// --- email + password (errors handled inline via quiet) ---
// `lang` is stamped here rather than passed in: this is the ONE moment the sign-up language
// exists anywhere the server can see it (the account does not exist yet to carry it), and every
// mail this person ever gets is written from what lands here.
register: (email: string, password: string): Promise<{ status: string }> =>
req(
"/auth/register",
{ method: "POST", body: JSON.stringify({ email, password }) },
{ method: "POST", body: JSON.stringify({ email, password, lang: uiLang() }) },
{ quiet: true },
),
// A refusal carries a structured `{code:"login", reason, …}` detail — see LoginBlocked above.
passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-login",
@@ -30,6 +83,15 @@ export const authApi = {
{ method: "POST", body: JSON.stringify({ email }) },
{ quiet: true },
),
// Is a `#reset=` token still usable? Answers WITHOUT spending it, so the card can refuse to draw a
// form that cannot succeed (a deleted account, an expired or already-used link). Advisory only —
// confirmPasswordReset stays the authority, since the token can die between the two calls.
checkPasswordReset: (token: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-reset/check",
{ method: "POST", body: JSON.stringify({ token }) },
{ quiet: true },
),
confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
req(
"/auth/password-reset/confirm",
@@ -37,7 +99,15 @@ export const authApi = {
{ quiet: true },
),
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
// `password_reset_required` = SEC-10 dropped the password this account registered with, so signing
// in would fail with the uniform credentials error. The card sends the user to the reset flow instead
// of leaving them to guess. Rare since E9.5 S1: only accounts stripped before that change, and the
// Google-adoption path, still reach it.
// `email` is the address that was just confirmed — echoed back so the banner can name it, which
// matters when the browser holds a session for a DIFFERENT account.
verifyEmail: (
token: string,
): Promise<{ ok: boolean; password_reset_required?: boolean; email?: string }> =>
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
// reveals whether the email has a pending account.
@@ -58,8 +128,13 @@ export const authApi = {
),
// --- onboarding: access requests + demo entry ---
// Same reasoning as register: the requester has no account to carry a language preference, so the
// page they asked from is what decides the language of the approval or denial mail.
requestAccess: (email: string): Promise<{ status: string }> =>
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
req("/auth/request-access", {
method: "POST",
body: JSON.stringify({ email, lang: uiLang() }),
}),
// Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
// sign-in. Returns whether a session was established.
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
+23 -1
View File
@@ -8,6 +8,12 @@ import { formatBytes } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
* every caller is one every caller can forget, which is how the registration language shipped as a
* field nobody ever filled (E9.5 S6 → S7). */
export const uiLang = (): string => i18n.language.split("-")[0] || "en";
export class HttpError extends Error {
status: number;
detail?: string; // server-provided reason (FastAPI's `detail`), when present
@@ -31,7 +37,10 @@ function localizeDetail(d: unknown): string {
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as { code?: string; reason?: string; limit?: number } | null | undefined;
const o = d as
| { code?: string; reason?: string; limit?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
@@ -46,6 +55,19 @@ function localizeDetail(d: unknown): string {
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals (E9.5 S4). The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
if (o?.code === "reset")
return o.reason === "weak_password"
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
: t("welcome.auth.deadLink.short");
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
+29
View File
@@ -3,6 +3,9 @@ import { describe, it, expect } from "vitest";
import {
coercePlexFilters,
plexFiltersSignature,
plexFiltersToParams,
parsePlexFilters,
plexFilterCount,
EMPTY_PLEX_FILTERS,
type PlexFilters,
} from "./plex";
@@ -54,12 +57,14 @@ describe("plexFiltersSignature", () => {
directors: [],
actors: [],
studios: [],
playable: [],
keywords: [],
collections: ["A"],
};
const b: PlexFilters = {
collections: ["A"],
keywords: [],
playable: [],
studios: [],
actors: [],
directors: [],
@@ -81,4 +86,28 @@ describe("plexFiltersSignature", () => {
const b: PlexFilters = { ...base(), genres: ["Comedy"] };
expect(plexFiltersSignature(a)).not.toBe(plexFiltersSignature(b));
});
it("differs when the playback-class filter differs", () => {
const a = base();
const b: PlexFilters = { ...base(), playable: ["direct", "transcode_hw"] };
expect(plexFiltersSignature(a)).not.toBe(plexFiltersSignature(b));
});
});
describe("playable filter round-trip + count", () => {
it("serializes playable as a CSV param and parses it back", () => {
const f: PlexFilters = { ...EMPTY_PLEX_FILTERS, playable: ["direct", "transcode_sw"] };
const params = plexFiltersToParams(f);
expect(params.get("playable")).toBe("direct,transcode_sw");
expect(parsePlexFilters(params).playable).toEqual(["direct", "transcode_sw"]);
});
it("omits the playable param when empty", () => {
expect(plexFiltersToParams(EMPTY_PLEX_FILTERS).has("playable")).toBe(false);
});
it("counts each selected playback class like studios", () => {
const f: PlexFilters = { ...EMPTY_PLEX_FILTERS, playable: ["remux", "transcode_hw"] };
expect(plexFilterCount(f)).toBe(2);
});
});
+12
View File
@@ -45,6 +45,7 @@ export interface PlexCard {
art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key
rating?: number | null; // Plex audience score (~IMDb), shown on the card hover-overview
playable?: string; // direct | remux | transcode
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
status?: string; // new | watched | hidden
position_seconds?: number;
season_count?: number | null; // show
@@ -139,6 +140,9 @@ export interface PlexItemDetail {
year?: number | null;
duration_seconds: number | null;
playable: string; // direct | remux | transcode
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle)
video_bit_depth?: number | null; // e.g. 8 / 10 (info-chip subtitle)
thumb: string;
art: string;
library?: string | null; // Plex section key (for the admin collection editor)
@@ -253,6 +257,7 @@ export interface PlexFilters {
actors: string[]; // OR (any) by default, or AND (all) via actorMode
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
studios: string[]; // OR — from any selected studio
playable: string[]; // OR — playback class (direct | remux | transcode_hw | transcode_sw); movie-only
keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5)
collections: string[]; // Plex collection rating_keys; a title can be in several
collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR)
@@ -264,6 +269,7 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = {
directors: [],
actors: [],
studios: [],
playable: [],
keywords: [],
collections: [],
};
@@ -309,6 +315,7 @@ export function plexFilterCount(f: PlexFilters): number {
f.directors.length +
f.actors.length +
f.studios.length +
f.playable.length +
f.keywords.length +
f.collections.length
);
@@ -330,6 +337,7 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
if (f.actors?.length) u.set("actors", f.actors.join(","));
if (f.actorMode === "all") u.set("actor_mode", "all");
if (f.studios?.length) u.set("studios", f.studios.join(","));
if (f.playable?.length) u.set("playable", f.playable.join(","));
if (f.keywords?.length) u.set("keywords", f.keywords.join(","));
if (f.collections?.length) u.set("collections", f.collections.join(","));
if (f.collectionMode === "all") u.set("collection_mode", "all");
@@ -371,6 +379,7 @@ export function parsePlexFilters(p: URLSearchParams): PlexFilters {
f.actors = csv("actors");
if (p.get("actor_mode") === "all") f.actorMode = "all";
f.studios = csv("studios");
f.playable = csv("playable");
f.keywords = csv("keywords");
f.collections = csv("collections");
if (p.get("collection_mode") === "all") f.collectionMode = "all";
@@ -380,6 +389,8 @@ export interface PlexFacets {
genres: { value: string; count: number }[];
content_ratings: { value: string; count: number }[];
studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7)
// Playback-class facet (movie-only): value ∈ direct|remux|transcode_hw|transcode_sw. Empty on shows.
playable: { value: string; count: number }[];
year_min: number | null;
year_max: number | null;
rating_max: number | null;
@@ -392,6 +403,7 @@ export interface PlexPlaySession {
start_s: number;
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
tonemap?: boolean; // hls HW transcode only: 10-bit/HDR source tone-mapped HDR→SDR on the GPU (OpenCL)
}
// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the

Some files were not shown because too many files have changed in this diff Show More