diff --git a/apps/web/src/app/server-storage/server-storage-start.spec.ts b/apps/web/src/app/server-storage/server-storage-start.spec.ts index e3a12e07f..eda02cad1 100644 --- a/apps/web/src/app/server-storage/server-storage-start.spec.ts +++ b/apps/web/src/app/server-storage/server-storage-start.spec.ts @@ -45,6 +45,28 @@ describe('resolveServerStorageStart', () => { ).toBeNull(); }); + it('keeps the token only for the same server and database', () => { + const token = 'iptvnator-server:access-token'; + resolveServerStorageStart({ kind: 'server', instance }, '/api'); + localStorage.setItem(token, 'secret'); + + resolveServerStorageStart({ kind: 'server', instance }, '/api/'); + expect(localStorage.getItem(token)).toBe('secret'); + + resolveServerStorageStart( + { kind: 'server', instance: { ...instance, instanceId: 'i-2' } }, + '/api' + ); + expect(localStorage.getItem(token)).toBeNull(); + + localStorage.setItem(token, 'secret'); + resolveServerStorageStart( + { kind: 'server', instance: { ...instance, instanceId: 'i-2' } }, + '/other' + ); + expect(localStorage.getItem(token)).toBeNull(); + }); + it('forgets the token of a server that no longer offers storage', () => { localStorage.setItem('iptvnator-server:access-token', 'old'); diff --git a/apps/web/src/app/server-storage/server-storage-start.ts b/apps/web/src/app/server-storage/server-storage-start.ts index b892c8015..f9f7a5ad6 100644 --- a/apps/web/src/app/server-storage/server-storage-start.ts +++ b/apps/web/src/app/server-storage/server-storage-start.ts @@ -32,9 +32,21 @@ export function resolveServerStorageStart( > | null = safeLocalStorage() ): ServerStorageStart | null { switch (probe.kind) { - case 'server': + case 'server': { + const remembered = read(storage); + // Another server, or another database behind this one: the + // stored token was not given for it. + if ( + !remembered || + backendIdentity(remembered.baseUrl) !== + backendIdentity(baseUrl) || + remembered.instance.instanceId !== probe.instance.instanceId + ) { + clearServerAccessToken(); + } write(storage, { baseUrl, instance: probe.instance }); return { instance: probe.instance, startedOffline: false }; + } case 'none': remove(storage); // The token belonged to that server; another must not see it. diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 1f77be69d..737aae50c 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -272,9 +272,10 @@ settings and guards read them in every build. `localStorage` (`iptvnator-server:unconfirmed::`, one key per page load so tabs never overwrite each other's, renewed every 30 s while it has unsent writes, stale after ten minutes) let a re-read - in one tab keep the cache values another tab is still sending; saved - writes without a fresh record, also when the record was lost, count as a - closed tab's (the storage adapter lists its keys for this). A value too big for `localStorage`, or any + in one tab keep the cache values another tab is still sending, and a + stale record marks a closed tab whose writes another tab adopts (the + storage adapter lists its keys for this). Saved writes without any record + are left to the tab's own reload: that tab may still run. A value too big for `localStorage`, or any value when the browser refuses storage access, stays readable from memory, so callers and the diff baseline agree. A re-read updates the cache; views already open show another device's changes on @@ -298,8 +299,9 @@ settings and guards read them in every build. browser that refuses to store it is told so instead). `ServerAccessTokenInterceptor` adds the header to `HttpClient` calls to the backend, and `PwaService` adds it to its two raw `fetch` calls only while - server mode runs (a backend that answers without server storage also - makes the browser forget the token). Both turn + server mode runs. The browser forgets the token when the backend answers + without server storage, or as another server or database (backend URL or + instance id) than the one it remembers. Both turn the guard's 401 (`isServerAccessChallenge`) into `reportAccessDenied()`, so a token changed on the server sends an open page back to sign-in; a late 401 for a token replaced since (a new sign-in) changes nothing. diff --git a/libs/services/src/lib/server-storage/server-storage-session.spec.ts b/libs/services/src/lib/server-storage/server-storage-session.spec.ts index 03bfd1c9d..9a9925c4c 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.spec.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.spec.ts @@ -91,6 +91,16 @@ describe('ServerStorageSession', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('marks the page closed on page hide also while sign-in is pending', async () => { + const { session } = setup('token'); + const close = jest.spyOn(session.userData, 'close'); + + await session.start(); + window.dispatchEvent(new Event('pagehide')); + + expect(close).toHaveBeenCalled(); + }); + it('starts with a stored token', async () => { localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 't'); const { session, status } = setup('token'); diff --git a/libs/services/src/lib/server-storage/server-storage-session.ts b/libs/services/src/lib/server-storage/server-storage-session.ts index 0d90b259c..257c936c4 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.ts @@ -76,6 +76,9 @@ export class ServerStorageSession { async start(): Promise { this.backend.useServerStore(this.documentStore); this.status.activate(this.config.instance, () => void this.refresh()); + // Also while sign-in is pending: this page may already hold another + // page load's writes, so it must mark itself closed on page hide. + this.listen(); if (this.config.instance.auth === 'token' && !readServerAccessToken()) { this.status.reportUnauthorized(); return; @@ -86,7 +89,6 @@ export class ServerStorageSession { setTimeout(resolve, STARTUP_HYDRATE_WAIT_MS) ), ]); - this.listen(); } /** Re-reads synced user data; also the offline retry. */ diff --git a/libs/services/src/lib/server-storage/server-unsent-writes.ts b/libs/services/src/lib/server-storage/server-unsent-writes.ts index edf9a6351..9e5654eeb 100644 --- a/libs/services/src/lib/server-storage/server-unsent-writes.ts +++ b/libs/services/src/lib/server-storage/server-unsent-writes.ts @@ -120,8 +120,9 @@ export function swapPage( } /** - * Page loads with saved writes or a record but no fresh record: closed - * tabs nobody sends for (also when their record was lost). + * Page loads whose record went stale: closed tabs nobody sends for. Saved + * writes without any record are left alone: a tab that could save them but + * not its record may still run, and only its own reload takes them over. */ export function orphanedPages( local: UserDataStorage, @@ -129,20 +130,13 @@ export function orphanedPages( page: string, now = Date.now() ): string[] { - const registry = readRegistry(local, instanceId); - const pages = new Set(); - for (const prefix of PAGE_PREFIXES) { - for (const owner of pagesUnder(local, `${prefix}:${instanceId}:`)) { - pages.add(owner); - } - } - return [...pages].filter((owner) => { - const record = registry[owner]; - return ( - owner !== page && - !(record && now - Number(record.at) < UNCONFIRMED_TTL_MS) - ); - }); + return Object.entries(readRegistry(local, instanceId)) + .filter( + ([owner, record]) => + owner !== page && + !(now - Number(record.at) < UNCONFIRMED_TTL_MS) + ) + .map(([owner]) => owner); } /** Forgets a page load's saved writes, early edits and record. */ diff --git a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts index 70598b017..abab5aea2 100644 --- a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts @@ -1284,43 +1284,54 @@ describe('ServerUserDataStorage with other devices', () => { ); }); - it("sends a closed tab's saved writes whose record was lost", async () => { + it('leaves saved writes without a record to the tab that saved them', async () => { const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); const shared = memoryStorage(); - const closed = new ServerUserDataStorage( - { - ...server.api, - sendJson: jest - .fn() - .mockRejectedValue( - new ServerApiError(0, 'network', 'offline') - ), - } as never, + const tabStore = memoryStorage(); + const offline = { + ...server.api, + sendJson: jest + .fn() + .mockRejectedValue(new ServerApiError(0, 'network', 'offline')), + }; + const open = new ServerUserDataStorage( + offline as never, shared, jest.fn(), 'default', - memoryStorage() + tabStore ); - await closed.hydrate(); - closed.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); - await closed.flush(); - // Another tab's save once replaced the shared record: now it is gone. + await open.hydrate(); + open.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await open.flush(); + // Storage refused the record but kept the saved writes. for (const key of [...shared.values.keys()]) { if (key.startsWith('iptvnator-server:unconfirmed:')) { shared.removeItem(key); } } - const next = new ServerUserDataStorage( + const other = new ServerUserDataStorage( server.api as never, shared, jest.fn(), 'default', memoryStorage() ); - await next.hydrate(); - await next.flush(); + await other.hydrate(); + await other.flush(); + expect(server.state['xtream-favorites']['p1']).toEqual(['S']); + // The tab's own reload takes them over. + const reloaded = new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + tabStore + ); + await reloaded.hydrate(); + await reloaded.flush(); expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); }); });