mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: restore push when the browser replaces its subscription with no tab open
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { forgetRelayRegistration, keepRelayRegistration, relayRegistrationsKey } from '@/lib/pwa-notify/worker-state';
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
vi.stubGlobal('caches', {
|
||||
open: async () => ({
|
||||
match: async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined),
|
||||
put: async (key: string, res: Response) => { store.set(key, await res.text()); },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const stored = () => JSON.parse(store.get(relayRegistrationsKey(window.location.origin)) ?? 'null');
|
||||
|
||||
describe('what the worker keeps to re-register without a tab', () => {
|
||||
it('keeps one entry per account and the key', async () => {
|
||||
await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, 'KEY');
|
||||
await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY');
|
||||
expect(stored()).toEqual({
|
||||
applicationServerKey: 'KEY',
|
||||
entries: { a: { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, b: { relay: 'https://r', subscriptionId: 'd2' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('forgets an account, and leaves the others', async () => {
|
||||
await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY');
|
||||
await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY');
|
||||
await forgetRelayRegistration('a');
|
||||
expect(Object.keys(stored().entries)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('stays out of the way without a Cache API', async () => {
|
||||
vi.stubGlobal('caches', undefined);
|
||||
await expect(keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY')).resolves.toBeUndefined();
|
||||
await expect(forgetRelayRegistration('a')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,11 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
// in a VM with a minimal ServiceWorkerGlobalScope and fire the event.
|
||||
const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
|
||||
|
||||
function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
|
||||
function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registrations?: unknown) {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const subscribe = vi.fn(async () => ({}));
|
||||
const newSubscription = { toJSON: () => ({ endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }) };
|
||||
const subscribe = vi.fn(async (_options: { userVisibleOnly?: boolean; applicationServerKey?: Uint8Array }) => newSubscription);
|
||||
const relayCalls: Array<{ url: string; body: unknown }> = [];
|
||||
const self = {
|
||||
location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' },
|
||||
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
|
||||
@@ -18,7 +20,14 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
|
||||
registration: { pushManager: { subscribe } },
|
||||
};
|
||||
const context = vm.createContext({
|
||||
self, caches: {}, fetch: vi.fn(), URL, URLSearchParams, Response, Date, console,
|
||||
self,
|
||||
atob,
|
||||
caches: { open: async () => ({ match: async () => (registrations ? { json: async () => registrations } : undefined) }) },
|
||||
fetch: vi.fn(async (url: string, init: { body: string }) => {
|
||||
relayCalls.push({ url, body: JSON.parse(init.body) });
|
||||
return { ok: true };
|
||||
}),
|
||||
URL, URLSearchParams, Response, Date, console, Uint8Array,
|
||||
});
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
|
||||
@@ -27,13 +36,45 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
|
||||
listeners.get(type)?.({ ...event, waitUntil: (p: Promise<unknown>) => waits.push(p) });
|
||||
await Promise.all(waits);
|
||||
}
|
||||
return { fire, subscribe };
|
||||
return { fire, subscribe, relayCalls, newSubscription };
|
||||
}
|
||||
|
||||
// The push service dropped or replaced the subscription while no page was
|
||||
// involved (#1321). The worker puts a subscription back and tells the open
|
||||
// tabs, which hold the login needed to register the new endpoint.
|
||||
describe('service worker pushsubscriptionchange', () => {
|
||||
const REGISTRATIONS = {
|
||||
applicationServerKey: 'QUJD',
|
||||
entries: {
|
||||
a: { relay: 'https://relay.example', subscriptionId: 'device-a', accountLabel: 'a@example.org' },
|
||||
b: { relay: 'https://relay.example', subscriptionId: 'device-b', accountLabel: 'b@example.org' },
|
||||
},
|
||||
};
|
||||
|
||||
it('tells the relay the new endpoint itself, for every account, with no tab open', async () => {
|
||||
const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS);
|
||||
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
|
||||
expect(relayCalls).toEqual([
|
||||
{ url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-a', subscription: newSubscription.toJSON(), accountLabel: 'a@example.org' } },
|
||||
{ url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-b', subscription: newSubscription.toJSON(), accountLabel: 'b@example.org' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('subscribes with the kept key when the browser gives no old subscription', async () => {
|
||||
const { fire, subscribe, relayCalls } = loadWorker([], REGISTRATIONS);
|
||||
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription: null });
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(subscribe.mock.calls[0][0]).toMatchObject({ userVisibleOnly: true });
|
||||
expect(Array.from(subscribe.mock.calls[0][0].applicationServerKey!)).toEqual([65, 66, 67]);
|
||||
expect(relayCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('calls no relay for a browser where push was never on', async () => {
|
||||
const { fire, relayCalls, newSubscription } = loadWorker([]);
|
||||
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
|
||||
expect(relayCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('re-subscribes with the old key and tells the open tabs to re-register', async () => {
|
||||
const tab = { postMessage: vi.fn() };
|
||||
const { fire, subscribe } = loadWorker([tab]);
|
||||
|
||||
@@ -29,6 +29,60 @@ export function pendingReplyKey(origin: string): string {
|
||||
return `${origin}${BASE_PATH}/__push-state/pending-reply`;
|
||||
}
|
||||
|
||||
/** The cache key sw.js finds the relay registrations under. */
|
||||
export function relayRegistrationsKey(origin: string): string {
|
||||
return `${origin}${BASE_PATH}/__push-state/relay-registrations`;
|
||||
}
|
||||
|
||||
export interface RelayRegistration {
|
||||
relay: string;
|
||||
subscriptionId: string;
|
||||
accountLabel?: string;
|
||||
}
|
||||
|
||||
interface RelayRegistrations {
|
||||
applicationServerKey?: string;
|
||||
entries: Record<string, RelayRegistration>;
|
||||
}
|
||||
|
||||
async function readRelayRegistrations(cache: Cache, key: string): Promise<RelayRegistrations> {
|
||||
const stored = (await (await cache.match(key))?.json()) as RelayRegistrations | undefined;
|
||||
return stored?.entries ? stored : { entries: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the worker re-register a new browser endpoint with the relay when no tab
|
||||
* is open (pushsubscriptionchange). None of this is secret to the browser that
|
||||
* subscribed, and it opens nothing but the relay record.
|
||||
*/
|
||||
export async function keepRelayRegistration(accountId: string, entry: RelayRegistration, applicationServerKey: string): Promise<void> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return;
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
const key = relayRegistrationsKey(window.location.origin);
|
||||
const stored = await readRelayRegistrations(cache, key);
|
||||
stored.entries[accountId] = entry;
|
||||
stored.applicationServerKey = applicationServerKey;
|
||||
await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } }));
|
||||
} catch {
|
||||
// A new endpoint then waits for the page's re-sync.
|
||||
}
|
||||
}
|
||||
|
||||
export async function forgetRelayRegistration(accountId: string): Promise<void> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return;
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
const key = relayRegistrationsKey(window.location.origin);
|
||||
const stored = await readRelayRegistrations(cache, key);
|
||||
if (!(accountId in stored.entries)) return;
|
||||
delete stored.entries[accountId];
|
||||
await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } }));
|
||||
} catch {
|
||||
// Signing out deletes the store anyway.
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeNotificationActionPrefs(prefs: NotificationActionPrefs): Promise<void> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isBuiltInHost, relayAllowed } from '@/lib/outgoing-hosts';
|
||||
import { noThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { serviceWorkerUrl, SW_SCOPE } from '@/lib/app-update';
|
||||
import { forgetRelayRegistration, keepRelayRegistration } from '@/lib/pwa-notify/worker-state';
|
||||
import {
|
||||
EMPTY_NOTIFICATION_MUTES,
|
||||
isAccountMuted,
|
||||
@@ -801,6 +802,11 @@ async function enableWebPushForThisTab(
|
||||
},
|
||||
accountLabel: params.accountLabel,
|
||||
});
|
||||
await keepRelayRegistration(accountId, {
|
||||
relay: relayBaseUrl,
|
||||
subscriptionId: deviceClientId,
|
||||
...(params.accountLabel ? { accountLabel: params.accountLabel } : {}),
|
||||
}, vapidPublicKey);
|
||||
|
||||
// Reuse the JMAP-side PushSubscription if the server still has it, just
|
||||
// refreshing the expiry so it doesn't time out between sessions. With
|
||||
@@ -983,6 +989,7 @@ async function disableWebPushForThisTab(params: DisableWebPushParams): Promise<v
|
||||
}
|
||||
localStorage.removeItem(subIdKey);
|
||||
localStorage.removeItem(relayBaseKey(accountId));
|
||||
await forgetRelayRegistration(accountId);
|
||||
// Enabling again starts from the full emailPush map.
|
||||
localStorage.removeItem(emailPushRefusedKey(accountId));
|
||||
|
||||
|
||||
+51
-10
@@ -975,20 +975,61 @@ async function writeNotifiedIds(accountId, ids) {
|
||||
}
|
||||
}
|
||||
|
||||
/** What the page left here: lib/pwa-notify/worker-state.ts. */
|
||||
async function readRelayRegistrations() {
|
||||
try {
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
const res = await cache.match(`${self.location.origin}${BASE_PATH}/__push-state/relay-registrations`);
|
||||
const stored = res ? await res.json() : null;
|
||||
return stored && stored.entries && typeof stored.entries === "object" ? stored : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applicationServerKeyOf(base64Url) {
|
||||
const padded = base64Url + "=".repeat((4 - (base64Url.length % 4)) % 4);
|
||||
const raw = atob(padded.replace(/-/g, "+").replace(/_/g, "/"));
|
||||
return Uint8Array.from(raw, (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
// The push service dropped or replaced the browser's subscription (expiry,
|
||||
// a key rotation, revoked permission). Re-subscribe with the same relay key
|
||||
// unless the browser already did, then tell the open tabs: only a page has
|
||||
// the login needed to register the new endpoint with the relay and the mail
|
||||
// server (resyncWebPush in lib/web-push.ts). With no tab open, the next page
|
||||
// load's re-sync picks the new subscription up.
|
||||
// unless the browser already did. The mail server's subscription points at the
|
||||
// relay record, not at the endpoint, so telling the relay is all it takes, and
|
||||
// that needs no login: push is back with no tab open. Open tabs hear of it too,
|
||||
// for what only a page can do (resyncWebPush in lib/web-push.ts).
|
||||
async function handlePushSubscriptionChange(event) {
|
||||
const old = event.oldSubscription;
|
||||
if (!event.newSubscription && old && old.options) {
|
||||
try {
|
||||
await self.registration.pushManager.subscribe(old.options);
|
||||
} catch (_) {
|
||||
// Permission revoked or push service unreachable: the page's re-sync
|
||||
// subscribes again itself when it can.
|
||||
const registrations = await readRelayRegistrations();
|
||||
let subscription = event.newSubscription || null;
|
||||
if (!subscription) {
|
||||
const options = old && old.options
|
||||
? old.options
|
||||
: registrations && registrations.applicationServerKey
|
||||
? { userVisibleOnly: true, applicationServerKey: applicationServerKeyOf(registrations.applicationServerKey) }
|
||||
: null;
|
||||
if (options) {
|
||||
try {
|
||||
subscription = await self.registration.pushManager.subscribe(options);
|
||||
} catch (_) {
|
||||
// Permission revoked or push service unreachable: the page's re-sync
|
||||
// subscribes again itself when it can.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (subscription && registrations && typeof subscription.toJSON === "function") {
|
||||
const json = subscription.toJSON();
|
||||
for (const entry of Object.values(registrations.entries)) {
|
||||
try {
|
||||
await fetch(`${entry.relay}/api/push/register/web`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ subscriptionId: entry.subscriptionId, subscription: json, accountLabel: entry.accountLabel }),
|
||||
});
|
||||
} catch (_) {
|
||||
// The page's re-sync registers it when it next runs.
|
||||
}
|
||||
}
|
||||
}
|
||||
const allClients = await self.clients.matchAll({
|
||||
|
||||
Reference in New Issue
Block a user