#143 added a device-local flag recording that background notifications were switched on in this browser, and made the renewal on app start key off it. It is not in KEEP_ON_SIGN_OUT, and that is the whole bug: clearSignedInData() runs on two different endings and only one of them is a sign-out. The other is a session expiring, which is what a deploy does to every signed-in browser at once. That path deliberately does not remove the push subscription -- there is no session left to remove it with -- so the subscription stays registered at Stalwart and the browser keeps its own. Losing the flag there left nothing to renew them: push would have gone quiet a week after every deploy, with the switch in Settings still reading as on because both ends of the subscription still existed. That is the exact failure #143 was written to prevent, reintroduced through a different door, and the first deploy carrying #143 would have been the thing that triggered it. Signing out for real still forgets it. That happens directly in unsubscribeThisDevice, next to destroying the subscription, and it happens even when the server cannot be reached -- a browser that goes on believing it has push would have renewal resurrect it on the next sign-in. Both halves are tested now, because they are one invariant seen from two sides: storage.test.ts covers the flag surviving an expiry, webpush.test.ts covers a sign-out clearing it with the server unreachable.
142 lines
4.7 KiB
TypeScript
142 lines
4.7 KiB
TypeScript
/**
|
|
* Local storage, gated on whether this device is trusted.
|
|
*
|
|
* Everything here is a *cache* or a screen preference — the real copy lives in
|
|
* the account's JMAP Files (see `settingsSync`). That makes it safe to write
|
|
* nothing at all, which is what an untrusted device does: on a shared or public
|
|
* machine the cost of a stale first frame is nothing beside leaving someone's
|
|
* address book on it.
|
|
*
|
|
* Reads are gated as well as writes. A machine that was trusted once still has
|
|
* the residue, and honouring it would let a previous session's data surface in
|
|
* a later untrusted one.
|
|
*/
|
|
const PREFIX = "ihasmail:";
|
|
|
|
/**
|
|
* Kept when a session ends. Everything else is cleared, so a key added later
|
|
* is forgotten by default rather than by nobody having thought about it.
|
|
*
|
|
* - `lastUser` is a deliberate convenience: it prefills the sign-in field, and
|
|
* it is only ever written by a trusted device in the first place.
|
|
* - `deviceTrusted` is how the next boot knows to read at all.
|
|
* - `pushDeviceId` is a random id for this browser, so re-subscribing replaces
|
|
* rather than accumulates. The subscription itself is removed on sign-out.
|
|
* - `pushEnabled` records that background notifications were switched on here,
|
|
* and is what the renewal on app start keys off. It is kept because this
|
|
* function runs on two different endings and only one of them is a sign-out:
|
|
* a *deploy* expires every session, and the handler for that clears local
|
|
* data without removing the push subscription, because there is no longer a
|
|
* session to remove it with. Dropping the flag there would leave the
|
|
* subscription registered, the switch still reading as on, and nothing
|
|
* renewing it -- so push would go quiet a week after every deploy, which is
|
|
* the exact failure the renewal exists to prevent. Signing out for real
|
|
* clears it directly, in `unsubscribeThisDevice`, alongside the subscription.
|
|
*/
|
|
const KEEP_ON_SIGN_OUT = ["lastUser", "deviceTrusted", "pushDeviceId", "pushEnabled"];
|
|
|
|
const TRUST_KEY = `${PREFIX}deviceTrusted`;
|
|
|
|
/**
|
|
* Read at module load rather than waiting for the session, so a trusted device
|
|
* still paints its first frame from cache. An untrusted one has nothing to
|
|
* read, so there is nothing to wait for.
|
|
*/
|
|
let trusted = (() => {
|
|
try {
|
|
return localStorage.getItem(TRUST_KEY) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
export function isDeviceTrusted(): boolean {
|
|
return trusted;
|
|
}
|
|
|
|
/** Set from the session's `remember` flag, which is the answer given at sign-in. */
|
|
export function setDeviceTrusted(value: boolean): void {
|
|
trusted = value;
|
|
try {
|
|
if (value) localStorage.setItem(TRUST_KEY, "1");
|
|
else localStorage.removeItem(TRUST_KEY);
|
|
} catch {
|
|
/* private mode: the in-memory flag still holds for this tab */
|
|
}
|
|
}
|
|
|
|
/** Every `ihasmail:` key currently present, without the prefix. */
|
|
function ownKeys(): string[] {
|
|
const out: string[] = [];
|
|
try {
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const k = localStorage.key(i);
|
|
if (k && k.startsWith(PREFIX)) out.push(k.slice(PREFIX.length));
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Drop what this browser was holding for a signed-in account. Called on every
|
|
* sign-out, trusted or not: handing a laptop to someone else is the same
|
|
* exposure as a public machine, only quieter.
|
|
*/
|
|
export function clearSignedInData(): void {
|
|
for (const key of ownKeys()) {
|
|
if (KEEP_ON_SIGN_OUT.includes(key)) continue;
|
|
removeKey(key);
|
|
}
|
|
}
|
|
|
|
/** Everything, `lastUser` included — for signing in to a device we do not trust. */
|
|
export function clearAllData(): void {
|
|
for (const key of ownKeys()) removeKey(key);
|
|
}
|
|
|
|
export function loadJson<T>(key: string, fallback: T): T {
|
|
if (!trusted) return fallback;
|
|
try {
|
|
const raw = localStorage.getItem(PREFIX + key);
|
|
if (raw == null) return fallback;
|
|
return { ...fallback, ...(JSON.parse(raw) as T) };
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
export function loadRaw<T>(key: string, fallback: T): T {
|
|
if (!trusted) return fallback;
|
|
try {
|
|
const raw = localStorage.getItem(PREFIX + key);
|
|
if (raw == null) return fallback;
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
export function saveJson(key: string, value: unknown): void {
|
|
if (!trusted) return;
|
|
try {
|
|
localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
|
} catch {
|
|
/* quota exceeded or private mode */
|
|
}
|
|
}
|
|
|
|
export function removeKey(key: string): void {
|
|
try {
|
|
localStorage.removeItem(PREFIX + key);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
/** Namespaced per account so multiple logins on one browser don't collide. */
|
|
export function accountKey(accountId: string | null | undefined, key: string): string {
|
|
return `${accountId ?? "anon"}:${key}`;
|
|
}
|