mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Review of the pop-out work found ways to lose text, a wrong sign-out and changes reaching ordinary browser tabs. - Drafts: a closing window hands text over only to a main window holding its login, and asks instead when the last save failed, the browser is offline, a save is running or plugins could still have a say. A failed rescue goes to "Continue draft". The opening window lets go only once the composer is on screen, also for Edit draft in a message window. Send options travel with the draft. Closing during a send asks first. - Sessions: answers are encrypted for the window that asked, and only a window holding the login answers. A renewal queued on the lock can be called off by a sign-out, signs nobody out once its account is gone, and takes the cached token after waiting (Rauthy `nbf`, #552). - Ordinary tabs run none of the host. No lock is held for life. - A message window hides after a refused session and waits for its folders; a composer stays on screen with its text. Another tab dropping a login it couldn't restore no longer closes pop-outs. - Plain-text mail stays selectable in the desktop app. Folder save renames reserved and shell names and skips one refused name. All display-mode checks know the overlay. The attachment bar keeps to one row. The earlier test that accepted text being gone after a failed rescue now asserts that it reaches the "Continue draft" slot.
79 lines
2.9 KiB
TypeScript
79 lines
2.9 KiB
TypeScript
/**
|
|
* One token renewal at a time across the windows of this browser.
|
|
*
|
|
* Refresh tokens rotate, and the token route reads the current one from a
|
|
* cookie. Two windows renewing at once send the same token twice; on Stalwart
|
|
* 1.0 a superseded token reused past its retry window counts as a replay and
|
|
* revokes the whole grant. Held across the request, so the next window's
|
|
* request carries the cookie the first one's answer wrote.
|
|
*
|
|
* Without Web Locks, or after waiting too long for a window that hangs, the
|
|
* work runs anyway: never worse than having no lock.
|
|
*/
|
|
|
|
interface LockManagerLike {
|
|
request<T>(name: string, options: { signal?: AbortSignal; ifAvailable?: boolean }, callback: (lock: unknown) => T | Promise<T>): Promise<T>;
|
|
}
|
|
|
|
/** How long a renewal waits for another window's before going ahead. Above the route's own timeouts. */
|
|
export const TOKEN_LOCK_WAIT_MS = 30_000;
|
|
|
|
const NOT_HELD = Symbol('not held');
|
|
|
|
export interface TokenLockTurn {
|
|
/**
|
|
* Another window held the lock first, so it may have renewed already. A
|
|
* renewal that was going to force one should take the cached token instead:
|
|
* an identity provider can refuse a second one this soon (Rauthy `nbf`, #552).
|
|
*/
|
|
contended: boolean;
|
|
}
|
|
|
|
function lockManager(): LockManagerLike | null {
|
|
const locks = typeof navigator !== 'undefined' ? (navigator as { locks?: LockManagerLike }).locks : undefined;
|
|
return locks && typeof locks.request === 'function' ? locks : null;
|
|
}
|
|
|
|
export function tokenLockName(slot: number): string {
|
|
return `bulwark-token-refresh:${slot}`;
|
|
}
|
|
|
|
export async function withTokenLock<T>(
|
|
slot: number,
|
|
work: (turn: TokenLockTurn) => Promise<T>,
|
|
options: { waitMs?: number; signal?: AbortSignal } = {},
|
|
): Promise<T> {
|
|
const locks = lockManager();
|
|
if (!locks) return work({ contended: false });
|
|
|
|
const name = tokenLockName(slot);
|
|
let started = false;
|
|
const run = (contended: boolean) => {
|
|
started = true;
|
|
return work({ contended });
|
|
};
|
|
|
|
const gaveUp = new AbortController();
|
|
const forwardAbort = () => gaveUp.abort();
|
|
options.signal?.addEventListener('abort', forwardAbort);
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
const first = await locks.request<T | typeof NOT_HELD>(name, { ifAvailable: true }, (lock) => (lock ? run(false) : NOT_HELD));
|
|
if (first !== NOT_HELD) return first as T;
|
|
timer = setTimeout(() => gaveUp.abort(), options.waitMs ?? TOKEN_LOCK_WAIT_MS);
|
|
return await locks.request<T>(name, { signal: gaveUp.signal }, () => {
|
|
clearTimeout(timer);
|
|
return run(true);
|
|
});
|
|
} catch (error) {
|
|
// An error from the work goes to the caller, who decides what it means. So
|
|
// does a caller calling it off (a sign-out). Only the lock itself failing,
|
|
// or the wait running out, runs the work.
|
|
if (started || options.signal?.aborted) throw error;
|
|
return run(true);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
options.signal?.removeEventListener('abort', forwardAbort);
|
|
}
|
|
}
|