mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(server-storage): forget the token for another server, listen before sign-in, adopt only stale records
- A server that answers as another backend URL or instance id than the remembered one makes the browser forget the stored token, so server A's token never reaches server B. - The session installs its page-hide and heartbeat listeners before it returns for sign-in, so a page holding another page load's writes still marks itself closed on unload. - Closed tabs are found by stale records only again: saved writes without a record may belong to a running tab whose record storage refused, and are left to that tab's own reload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cf715a7573
commit
4908803ceb
7 files changed
+94
-41
No files matched your search
@@ -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');
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -272,9 +272,10 @@ settings and guards read them in every build.
|
||||
`localStorage` (`iptvnator-server:unconfirmed:<instanceId>:<page>`, 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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -76,6 +76,9 @@ export class ServerStorageSession {
|
||||
async start(): Promise<void> {
|
||||
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. */
|
||||
|
||||
@@ -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<string>();
|
||||
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. */
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user