mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: only replace a browser subscription on the relay's own answer, and move the other logins along
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
import { webPushErrorKey } from '@/lib/web-push-errors';
|
||||
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
|
||||
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
|
||||
import { keepRelayRegistration } from '@/lib/pwa-notify/worker-state';
|
||||
|
||||
const RELAY = 'https://relay.example';
|
||||
const RELAY_BASE_KEY = `bulwark.push.relayBase.v1.${'acct-1'}`;
|
||||
@@ -277,7 +278,7 @@ function installFetch(activeByDevice: Record<string, boolean | 'unknown'>) {
|
||||
if (url.includes('/api/push/active/')) {
|
||||
const device = url.split('/api/push/active/')[1];
|
||||
const state = activeByDevice[device];
|
||||
if (state === undefined || state === 'unknown') return json({ error: 'Unknown' }, 404);
|
||||
if (state === undefined || state === 'unknown') return json({ error: 'Unknown subscription' }, 404);
|
||||
return json({ active: state });
|
||||
}
|
||||
return json({});
|
||||
@@ -763,6 +764,53 @@ describe('an endpoint the push service gave up on', () => {
|
||||
expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not take a relay that lacks the route for one that lost the record', async () => {
|
||||
const { browserSub } = installPushBrowser();
|
||||
const client = registered();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response;
|
||||
if (url.includes('/api/push/active/')) return json({ error: 'Not found' }, 404);
|
||||
return url.endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({});
|
||||
}));
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves the other logins to the new endpoint, connected or not', async () => {
|
||||
const { registration } = installPushBrowser();
|
||||
const moved = { endpoint: 'https://fcm.example/moved', options: { applicationServerKey: new Uint8Array([65, 66, 67]).buffer }, getKey: () => new Uint8Array([1, 2, 3]).buffer, unsubscribe: vi.fn(async () => true) };
|
||||
(registration.pushManager.subscribe as ReturnType<typeof vi.fn>).mockResolvedValue(moved);
|
||||
const stored = new Map<string, string>();
|
||||
vi.stubGlobal('caches', {
|
||||
open: async () => ({
|
||||
match: async (key: string) => (stored.has(key) ? new Response(stored.get(key)) : undefined),
|
||||
put: async (key: string, res: Response) => { stored.set(key, await res.text()); },
|
||||
}),
|
||||
});
|
||||
await keepRelayRegistration('other-login', { relay: 'https://relay-of-other.example', subscriptionId: 'device-other', accountLabel: 'o@example.org' }, 'QUJD');
|
||||
const client = registered();
|
||||
const bodies: Array<{ url: string; body: { subscriptionId: string; subscription: { endpoint: string }; accountLabel?: string } }> = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response;
|
||||
if (url.endsWith('/vapid-public-key')) return json({ publicKey: 'QUJD' });
|
||||
if (url.includes('/api/push/active/')) return json({ error: 'Unknown subscription' }, 404);
|
||||
if (url.endsWith('/api/push/register/web')) bodies.push({ url, body: JSON.parse(String(init?.body)) });
|
||||
return json({});
|
||||
}));
|
||||
|
||||
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
|
||||
|
||||
const other = bodies.find((b) => b.body.subscriptionId === 'device-other');
|
||||
expect(other?.url).toBe('https://relay-of-other.example/api/push/register/web');
|
||||
expect(other?.body.subscription.endpoint).toBe('https://fcm.example/moved');
|
||||
expect(other?.body.accountLabel).toBe('o@example.org');
|
||||
expect(bodies.filter((b) => b.body.subscriptionId === THIS_DEVICE)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the subscription while the relay knows the record', async () => {
|
||||
const { browserSub } = installPushBrowser();
|
||||
const client = registered();
|
||||
|
||||
@@ -45,11 +45,22 @@ interface RelayRegistrations {
|
||||
entries: Record<string, RelayRegistration>;
|
||||
}
|
||||
|
||||
async function readRelayRegistrations(cache: Cache, key: string): Promise<RelayRegistrations> {
|
||||
async function readStored(cache: Cache, key: string): Promise<RelayRegistrations> {
|
||||
const stored = (await (await cache.match(key))?.json()) as RelayRegistrations | undefined;
|
||||
return stored?.entries ? stored : { entries: {} };
|
||||
}
|
||||
|
||||
/** Every login's relay registration on this browser, by JMAP account id. */
|
||||
export async function readRelayRegistrations(): Promise<Record<string, RelayRegistration>> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return {};
|
||||
const cache = await caches.open(PUSH_STATE_CACHE);
|
||||
return (await readStored(cache, relayRegistrationsKey(window.location.origin))).entries;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -60,7 +71,7 @@ export async function keepRelayRegistration(accountId: string, entry: RelayRegis
|
||||
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);
|
||||
const stored = await readStored(cache, key);
|
||||
stored.entries[accountId] = entry;
|
||||
stored.applicationServerKey = applicationServerKey;
|
||||
await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } }));
|
||||
@@ -74,7 +85,7 @@ export async function forgetRelayRegistration(accountId: string): Promise<void>
|
||||
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);
|
||||
const stored = await readStored(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' } }));
|
||||
|
||||
+21
-2
@@ -14,7 +14,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 { forgetRelayRegistration, keepRelayRegistration, readRelayRegistrations } from '@/lib/pwa-notify/worker-state';
|
||||
import {
|
||||
EMPTY_NOTIFICATION_MUTES,
|
||||
isAccountMuted,
|
||||
@@ -664,12 +664,28 @@ async function relayDroppedOurEndpoint(
|
||||
if (localStorage.getItem(endpointKey(accountId)) !== endpoint) return false;
|
||||
try {
|
||||
const res = await fetch(buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`));
|
||||
return res.status === 404;
|
||||
if (res.status !== 404) return false;
|
||||
// An old relay without this route answers 404 for everything.
|
||||
const body = (await res.json().catch(() => null)) as { error?: unknown } | null;
|
||||
return body?.error === 'Unknown subscription';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The browser moved to a new endpoint: every other login's relay record has to follow, connected in this tab or not. */
|
||||
async function moveOtherLogins(accountId: string, subscription: PushSubscription): Promise<void> {
|
||||
const others = Object.entries(await readRelayRegistrations()).filter(([id]) => id !== accountId);
|
||||
for (const [, entry] of others) {
|
||||
await registerWithRelay({
|
||||
relayBaseUrl: entry.relay,
|
||||
subscriptionId: entry.subscriptionId,
|
||||
subscription: { endpoint: subscription.endpoint, keys: { p256dh: readPushKey(subscription, 'p256dh'), auth: readPushKey(subscription, 'auth') } },
|
||||
accountLabel: entry.accountLabel,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollVerificationCode(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
@@ -815,9 +831,11 @@ async function enableWebPushForThisTab(
|
||||
}
|
||||
const accountId = params.client.getAccountId();
|
||||
const deviceClientId = getOrCreateDeviceClientId(accountId);
|
||||
let endpointGivenUp = false;
|
||||
if (pushSubscription && await relayDroppedOurEndpoint(accountId, relayBaseUrl, deviceClientId, pushSubscription.endpoint)) {
|
||||
await pushSubscription.unsubscribe().catch(() => undefined);
|
||||
pushSubscription = null;
|
||||
endpointGivenUp = true;
|
||||
// The other accounts' records point at the old endpoint: their turn comes too.
|
||||
lastResync.clear();
|
||||
}
|
||||
@@ -826,6 +844,7 @@ async function enableWebPushForThisTab(
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey,
|
||||
});
|
||||
if (endpointGivenUp) await moveOtherLogins(accountId, pushSubscription);
|
||||
}
|
||||
|
||||
await registerWithRelay({
|
||||
|
||||
Reference in New Issue
Block a user