Files
siftlode/frontend/src/lib/entryUrl.ts
T
peter 3c6c23507b fix(frontend): a page opened at a //path boots, and the account-switch copy reads right in Hungarian
Every address-bar writer glued `pathname + query`, so a path of `//evil.com/x` named another host:
history refused it (SecurityError) and the first nav mirror threw the app into its crash screen. One
builder, `hrefHere`, now makes the address from the URL itself and stays on this origin.
2026-10-21 02:51:48 +02:00

118 lines
7.0 KiB
TypeScript

// The URL the app was OPENED with, captured once when this module is first imported — before any
// component renders and before anything rewrites the address bar.
//
// Why a module-level snapshot rather than a `useState(() => …)` in whichever component needs it: the
// 2026-09-02 incident was exactly this race. The nav-param mirrors mount ABOVE the lazily-loaded
// <App>, and their mount effect replaceState'd the fragment away before AuthCard had snapshotted it,
// so the emailed verification token was gone by the time anyone looked for it. 0.78.7 fixed that by
// teaching urlWithParams/clearParams to carry the hash, but that leaves the guarantee resting on
// every future URL writer remembering to do the same. Reading the entry URL once, at import time,
// cannot lose the race no matter who mounts first or which chunk loads when.
//
// Secret tokens (verify, reset) ride the FRAGMENT so they never reach the server, a proxy log or a
// Referer header (SB3); so does the Google access-request notice (`access`, and the addresses it
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
// with nothing personal in them (login, deleted, account) ride the query string.
import type { NotifLevel } from "./notifications";
import { hrefHere } from "./urlState";
export const entrySearch = new URLSearchParams(window.location.search);
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
/** One-time QUERY flags: safe to drop once the snapshot above has them, because re-reading them on a
* reload would re-show a notice about something that already happened (`access` only appears here
* in its old shape — it rides the fragment now). Deliberately NOT `link`,
* `oauth`, `admin` or the setup `token` — those have their own readers, and some of them (the setup
* wizard, the lazily-mounted module views) do not run until several commits later. */
const ONE_TIME_QUERY = ["login", "deleted", "access", "account"] as const;
/** The flag a tab reloads with when it was showing another account than the one signed in now (the
* server refused it: `account_changed`), and where a navigation the server refused for that lands
* (backend `ACCOUNT_CHANGED_LANDING`). A reload wipes any toast, so the reason rides the URL. */
export const ACCOUNT_CHANGED_FLAG = "account=changed";
/** This page again, carrying `ACCOUNT_CHANGED_FLAG`: the tab boots as the account signed in now and
* says why, on the page it was on. */
export function accountChangedUrl(): string {
const params = new URLSearchParams(window.location.search);
const [key, value] = ACCOUNT_CHANGED_FLAG.split("=") as [string, string];
params.set(key, value);
return hrefHere(params);
}
/** Reload a tab whose session the server refused (a 401). Showing another account than the one
* signed in now (`account_changed`: a sign-in as someone else in another tab), it boots as that one
* and says why — a Sign out that silently left someone else signed in would read as broken. Any
* other refusal means the session ended: a plain reload lands on the sign-in page. */
export function reloadAfterRefusal(err: { detailData?: unknown }): void {
if ((err.detailData as { reason?: string } | undefined)?.reason === "account_changed")
window.location.replace(accountChangedUrl());
else window.location.reload();
}
/** Strip the one-time entry state from the address bar, so a token doesn't linger in history or get
* re-sent on a reload. ONE owner calls this (useVerifyEntry, which App mounts unconditionally) — a
* second caller would be harmless but makes it unclear who is responsible.
*
* SURGICAL, and that is the whole of it. This used to blank the entire query string, the fragment
* and `history.state`, which was safe only while it lived inside the signed-out auth card; moving it
* up to App pointed it at every load. It then ran on the FIRST commit — before the setup
* wizard mounts behind its own `?token=`, before App's own `?link=`/`?oauth=` effects read theirs,
* and before a lazily-loaded module view reads its `?v=`/`?chan=` deep link — and took
* `history.state` (scroll restoration, the channel entry) with it. Everything not listed above is
* somebody else's to clear, and `clearParams` is how they do it. */
export function clearEntryUrl(): void {
const params = new URLSearchParams(window.location.search);
const dropped = ONE_TIME_QUERY.filter((k) => params.has(k));
for (const k of dropped) params.delete(k);
// A `#reset=` token that nothing has spent STAYS. Only the signed-out auth card consumes it, so in
// a browser that already holds a session nobody reads it — and erasing it there turned the link
// into a dead end with no explanation: the page loads, nothing happens, and clicking the link
// again does the same, because the token is untouched and the session is still there. Leaving it
// in the address bar keeps the recovery `main` had, where this strip lived inside the auth card:
// sign out (which reloads, fragment intact) and the reset form is waiting. Once the card has used
// it the token is spent, so a later reload meets the named dead-link screen — which is true.
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const keep = hash.has("reset") ? window.location.hash : "";
if (!dropped.length && !window.location.hash) return;
if (!dropped.length && keep) return;
window.history.replaceState(
// Preserved, not nulled: it carries scroll restoration and the channel entry.
window.history.state,
"",
hrefHere(params, keep),
);
}
export interface AccountNotice {
level: NotifLevel;
key: string;
values?: Record<string, string>;
}
/** What the entry URL says to the account signed in here about a switch of account. A sign-in as
* someone else that did not happen: the callback answers a Google account that is not approved, or
* is suspended, without replacing the session, and with the flags the sign-in page reads — the
* signed-in app read none of them, so the person came back as before without a word. Its own copy,
* not the sign-in page's: beside this account's avatar, "this account is suspended" would read as
* this one. A request recorded on the way names the address it lives under (`linked`, when that is
* not the one Google returned). And a tab that reloaded because another account signed in under it
* says that it did. */
export function accountSwitchNotice(
search: URLSearchParams = entrySearch,
hash: URLSearchParams = entryHash,
): AccountNotice | null {
if (search.get("account") === "changed")
return { level: "info", key: "header.account.tabSwitched" };
if (search.get("login") === "suspended")
return { level: "warning", key: "header.account.switchSuspended" };
const access = hash.get("access");
const email = hash.get("linked") || hash.get("email");
if (access === "requested" && email)
return { level: "info", key: "header.account.switchRequested", values: { email } };
if (access === "requested" || access === "denied")
return { level: "warning", key: "header.account.switchNotApproved" };
return null;
}