Files
siftlode/frontend/src/lib/urlState.ts
T
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

192 lines
8.0 KiB
TypeScript

// Serialize feed filters to/from a compact, human-readable URL query string, and build/patch the
// address-bar URL for the whole app's navigation. The query string IS the source of truth for the
// current view (page / channel / plex sub-view / playlist) and the feed filters, so every URL is
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
import { TAG_CATEGORIES, type FeedFilters } from "./api";
const KEYS = [
"q",
"show",
"sort",
"scope",
"source",
"normal",
"shorts",
"live",
"tags",
"tagmode_language",
"tagmode_topic",
"tagmode_other",
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
"notag",
"channel",
"maxage",
"from",
"to",
"year",
"mindur",
"maxdur",
] as const;
export function filtersToParams(f: FeedFilters): URLSearchParams {
const p = new URLSearchParams();
if (f.q) p.set("q", f.q);
if (f.show && f.show !== "unwatched") p.set("show", f.show);
if (f.sort && f.sort !== "newest") p.set("sort", f.sort);
if (f.scope === "all") p.set("scope", "all");
// Library provenance filter (only meaningful in "all" scope); "organic" is the default.
if (f.scope === "all" && f.librarySource && f.librarySource !== "organic")
p.set("source", f.librarySource);
if (!f.includeNormal) p.set("normal", "0");
if (f.includeShorts) p.set("shorts", "1");
if (f.includeLive) p.set("live", "1");
if (f.tags.length) p.set("tags", f.tags.join(","));
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
}
if (f.untagged) p.set("notag", "1");
if (f.channelIds.length) p.set("channel", f.channelIds.join(","));
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
if (f.dateFrom) p.set("from", f.dateFrom);
if (f.dateTo) p.set("to", f.dateTo);
if (f.year) p.set("year", String(f.year));
if (f.minDuration != null) p.set("mindur", String(f.minDuration));
if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration));
return p;
}
/** Build the address-bar delta for the feed filters: each recognized filter key mapped to its emitted
* value, or null to delete it (so switching a filter back to its default clears the key). Pass `null`
* to clear ALL filter keys — used when leaving the feed, where the localStorage copy still holds them.
* Feed the result into urlWithParams(). */
export function filterParamUpdates(f: FeedFilters | null): Record<string, string | null> {
const p = f ? filtersToParams(f) : new URLSearchParams();
const out: Record<string, string | null> = {};
for (const k of KEYS) out[k] = p.get(k);
return out;
}
/** Merge URL params onto a base filter set (typically the defaults). */
export function paramsToFilters(params: URLSearchParams, base: FeedFilters): FeedFilters {
const f: FeedFilters = { ...base };
const num = (v: string | null): number | undefined => {
if (v == null || v === "") return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
if (params.has("q")) f.q = params.get("q") ?? "";
if (params.has("show")) f.show = params.get("show") || "unwatched";
if (params.has("sort")) f.sort = params.get("sort") || "newest";
if (params.has("scope")) f.scope = params.get("scope") === "all" ? "all" : "my";
if (params.has("source")) {
const s = params.get("source");
f.librarySource = s === "all" || s === "search" || s === "plex" ? s : "organic";
}
if (params.has("normal")) f.includeNormal = params.get("normal") !== "0";
if (params.has("shorts")) f.includeShorts = params.get("shorts") === "1";
if (params.has("live")) f.includeLive = params.get("live") === "1";
if (params.has("tags"))
f.tags = (params.get("tags") ?? "")
.split(",")
.map((s) => Number(s))
.filter((n) => Number.isInteger(n));
// Per-category tag match; a legacy global `tagmode=and` (topic-only) migrates into the topic slot.
f.tagModes = {
language: params.get("tagmode_language") === "and" ? "and" : "or",
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
other: params.get("tagmode_other") === "and" ? "and" : "or",
};
if (params.has("notag")) f.untagged = params.get("notag") === "1";
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
f.maxAgeDays = num(params.get("maxage"));
f.dateFrom = params.get("from") || undefined;
f.dateTo = params.get("to") || undefined;
f.year = num(params.get("year"));
f.minDuration = num(params.get("mindur"));
f.maxDuration = num(params.get("maxdur"));
return f;
}
/** True if the query string carries any filter param we recognize. */
export function hasFilterParams(params: URLSearchParams): boolean {
return KEYS.some((k) => params.has(k));
}
// The single source of truth for valid page ids; `Page` and the runtime validator both
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
// sync). "feed" is the default/fallback.
const PAGES = [
"feed",
"channels",
"stats",
"playlists",
"settings",
"scheduler",
"config",
"users",
"audit",
"notifications",
"messages",
"downloads",
"plex",
] as const;
export type Page = (typeof PAGES)[number];
/** Narrow an arbitrary string to a known Page (else null). */
export function isPage(p: string | null | undefined): p is Page {
return !!p && (PAGES as readonly string[]).includes(p);
}
export function readPage(): Page {
const p = new URLSearchParams(window.location.search).get("page");
return isPage(p) ? p : "feed";
}
/** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query —
* a null value deletes that key, any other string sets it. Lets each provider own its own nav key(s) —
* page / chan / v / pl / the feed-filter keys — and update the address bar without clobbering the others
* (read-modify-write on the live query). The caller decides pushState vs replaceState with the string.
*
* ⚠️ The FRAGMENT must be carried over. The one-time auth tokens ride it (`#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's this URL before the auth card's
* chunk has even loaded. Dropping the hash here silently ate the token: the verification link opened a
* bare sign-in page and the account could never be verified. */
export function urlWithParams(updates: Record<string, string | null>): string {
const params = new URLSearchParams(window.location.search);
for (const [k, v] of Object.entries(updates)) {
if (v == null) params.delete(k);
else params.set(k, v);
}
const qs = params.toString();
return `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`;
}
/** Remove specific TRANSIENT params (one-time auth/admin links: ?link, ?oauth, ?admin, ?access, ?verify,
* ?reset) from the address bar once they've been read, WITHOUT touching the nav/filter params that now
* live there. Preserves history.state. Replaces the old whole-query stripUrlParams, which is unsafe now
* that the query is the source of truth for navigation. No-op when none of the keys are present. */
export function clearParams(...keys: string[]): void {
const params = new URLSearchParams(window.location.search);
let changed = false;
for (const k of keys) {
if (params.has(k)) {
params.delete(k);
changed = true;
}
}
if (!changed) return;
const qs = params.toString();
window.history.replaceState(
window.history.state,
"",
// Same fragment rule as urlWithParams: this strips QUERY keys only, so a `#verify=…`/`#reset=…`
// token that has not been consumed yet must survive. Welcome's own cleanup is what clears it.
`${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`,
);
}