mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(server-storage): positions by content, acknowledged keepalive, serial re-reads, token changes
- Moved playback positions are matched by content type and Xtream id and name the server playlist. - Page-hide writes report whether the server took them; refused ones go back into the queue. Re-reads run one at a time. - The access guard's 401 carries WWW-Authenticate: Bearer realm="iptvnator"; the interceptor and PwaService's raw fetches turn it into sign-in, so a token changed on the server is noticed on provider calls too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc32934ce3
commit
6820b87005
17 files changed
+414
-55
No files matched your search
@@ -30,6 +30,13 @@ describe('createAccessGuard', () => {
|
||||
code: 'unauthorized',
|
||||
});
|
||||
expect(wrong.status).toBe(401);
|
||||
// Lets the PWA tell it from a provider's 401 on a proxy route.
|
||||
expect(wrong.headers.get('www-authenticate')).toBe(
|
||||
'Bearer realm="iptvnator"'
|
||||
);
|
||||
expect(wrong.headers.get('access-control-expose-headers')).toBe(
|
||||
'WWW-Authenticate'
|
||||
);
|
||||
}));
|
||||
|
||||
it('accepts the right token and lets preflight through', () =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import type { RequestHandler } from 'express';
|
||||
import { SERVER_ACCESS_CHALLENGE } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** Routes a browser must reach before it has a token. */
|
||||
const PUBLIC_PATHS = new Set(['/', '/health', '/config.js', '/v1/instance']);
|
||||
@@ -24,11 +25,14 @@ export function createAccessGuard(token: string): RequestHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(401).json({
|
||||
message: 'This IPTVnator server requires an access token.',
|
||||
status: 401,
|
||||
code: 'unauthorized',
|
||||
});
|
||||
res.status(401)
|
||||
.set('WWW-Authenticate', SERVER_ACCESS_CHALLENGE)
|
||||
.set('Access-Control-Expose-Headers', 'WWW-Authenticate')
|
||||
.json({
|
||||
message: 'This IPTVnator server requires an access token.',
|
||||
status: 401,
|
||||
code: 'unauthorized',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getStalkerRequestErrorStatus,
|
||||
isStalkerProbeTimeout,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { ServerStorageStatusService } from '@iptvnator/services';
|
||||
import { PwaService } from './pwa.service';
|
||||
|
||||
describe('PwaService', () => {
|
||||
@@ -279,6 +280,47 @@ describe('PwaService', () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('asks for a new server token when the access guard refuses a Stalker call', async () => {
|
||||
const status = TestBed.inject(ServerStorageStatusService);
|
||||
status.activate(
|
||||
{
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i',
|
||||
storage: 'server',
|
||||
auth: 'token',
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: new Headers({
|
||||
'WWW-Authenticate': 'Bearer realm="iptvnator"',
|
||||
}),
|
||||
json: async () => ({ code: 'unauthorized' }),
|
||||
} as unknown as Response) as unknown as typeof fetch;
|
||||
|
||||
const outcome = (
|
||||
service.sendIpcEvent(STALKER_REQUEST, {
|
||||
url: 'http://portal.example/portal.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
params: { action: 'get_genres' },
|
||||
silent: true,
|
||||
}) as Promise<unknown>
|
||||
).catch((error: unknown) => error);
|
||||
await Promise.resolve();
|
||||
http.expectOne((candidate) =>
|
||||
candidate.url.endsWith('/provider-targets')
|
||||
).flush({ targetId: 'target-1' });
|
||||
await outcome;
|
||||
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('sends Stalker credentials as /stalker control params, including the serial', async () => {
|
||||
// JSDOM ships no fetch; install one for the proxy call.
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
switchMap,
|
||||
throwError,
|
||||
} from 'rxjs';
|
||||
import { DataService, serverAuthorizationHeaders } from '@iptvnator/services';
|
||||
import {
|
||||
DataService,
|
||||
isServerAccessChallenge,
|
||||
serverAuthorizationHeaders,
|
||||
ServerStorageStatusService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
CONNECTIVITY_GUARD_RESET,
|
||||
ERROR,
|
||||
@@ -83,6 +88,7 @@ export class PwaService extends DataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly swUpdate = inject(SwUpdate);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly serverStatus = inject(ServerStorageStatusService);
|
||||
private readonly logger = createLogger('PwaService');
|
||||
private readonly providerTargetIds = new Map<string, Promise<string>>();
|
||||
private readonly silentXtreamActions = new Set<string>([
|
||||
@@ -207,6 +213,7 @@ export class PwaService extends DataService {
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
this.noteServerAccess(response);
|
||||
|
||||
if (!response.ok) {
|
||||
return { reset: false };
|
||||
@@ -643,6 +650,7 @@ export class PwaService extends DataService {
|
||||
const response = await fetch(requestUrl, {
|
||||
headers: serverAuthorizationHeaders(),
|
||||
});
|
||||
this.noteServerAccess(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
@@ -794,4 +802,17 @@ export class PwaService extends DataService {
|
||||
getAppEnvironment(): string {
|
||||
return 'pwa';
|
||||
}
|
||||
|
||||
/** A token changed on the server shows up on any backend call. */
|
||||
private noteServerAccess(response: Response): void {
|
||||
if (
|
||||
response.status === 401 &&
|
||||
isServerAccessChallenge(
|
||||
response.status,
|
||||
response.headers.get('WWW-Authenticate')
|
||||
)
|
||||
) {
|
||||
this.serverStatus.reportAccessDenied();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,9 @@ its database. Storage errors are logged by error name and code only, because
|
||||
Drizzle messages can carry bound values such as portal credentials. With
|
||||
`IPTVNATOR_ACCESS_TOKEN`, every route except `/`, `/health`, `/config.js`
|
||||
and `/v1/instance` requires `Authorization: Bearer <token>`, the proxy
|
||||
routes included; the PWA sends it once its sign-in page has stored it.
|
||||
routes included; its 401 carries `WWW-Authenticate: Bearer
|
||||
realm="iptvnator"`, which a provider error passed on by the proxy never
|
||||
does; the PWA sends it once its sign-in page has stored it.
|
||||
Browser app state written through
|
||||
`/v1/app-state` is stored under a `client:` key prefix, apart from
|
||||
server-owned keys such as the instance id.
|
||||
@@ -197,9 +199,10 @@ settings and guards read them in every build.
|
||||
prefix (the legacy unprefixed keys are never written in server mode) and
|
||||
each change sends only the playlist entries that differ, debounced, with a
|
||||
`keepalive` flush when the page hides. Batches go out one after another
|
||||
(the page-hide flush waits for a batch still travelling), a failed write
|
||||
stays queued for the next flush, and a re-read keeps entries edited while
|
||||
it was in flight or not yet saved. Start-up waits
|
||||
(the page-hide flush waits for a batch still travelling), a failed write,
|
||||
including a refused `keepalive` one, stays queued for the next flush, and
|
||||
re-reads run one at a time and keep entries edited while they were in
|
||||
flight or not yet saved. Start-up waits
|
||||
up to 3 s for the first read.
|
||||
- **Connection state.** `ServerStorageStatusService` is `online`, `offline`
|
||||
(a request did not reach the server, or a proxy answered 502, 503 or 504
|
||||
@@ -215,7 +218,9 @@ settings and guards read them in every build.
|
||||
playlists could not load), which checks the token against `/v1/instance/status`
|
||||
before it stores it under `iptvnator-server:access-token` and reloads.
|
||||
`ServerAccessTokenInterceptor` adds the header to `HttpClient` calls to the
|
||||
backend, and `PwaService` adds it to its two raw `fetch` calls.
|
||||
backend, and `PwaService` adds it to its two raw `fetch` calls. Both turn
|
||||
the guard's 401 (`isServerAccessChallenge`) into `reportAccessDenied()`,
|
||||
so a token changed on the server sends an open page back to sign-in.
|
||||
- **Moving browser data.** `ServerMigrationService` offers, once per server
|
||||
instance (marker `iptvnator-server:migration:<instanceId>`), to move the
|
||||
playlists this browser kept in IndexedDB (`planServerMigration`). A
|
||||
@@ -223,8 +228,9 @@ settings and guards read them in every build.
|
||||
user, Stalker portal and MAC, M3U URL), merges into the server's copy; the
|
||||
others upload with their ids. A merged playlist gains the browser
|
||||
document's favorites and history, and the legacy Xtream maps follow under
|
||||
the server id; `combineUserData` unions lists and merges maps, the
|
||||
server's value winning on a conflict. Browser data is never deleted, and
|
||||
the server id; `combineUserData` unions lists (playback positions by
|
||||
content type and Xtream id, renamed to the server playlist) and merges
|
||||
maps, the server's value winning on a conflict. Browser data is never deleted, and
|
||||
the marker is written only when nothing failed, so Settings → Server
|
||||
keeps offering a partial move.
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { SERVER_ACCESS_CHALLENGE } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const SERVER_ACCESS_TOKEN_STORAGE_KEY = 'iptvnator-server:access-token';
|
||||
|
||||
/** A 401 from the backend's access guard, not one a provider sent. */
|
||||
export function isServerAccessChallenge(
|
||||
status: number,
|
||||
challenge: string | null
|
||||
): boolean {
|
||||
return status === 401 && challenge === SERVER_ACCESS_CHALLENGE;
|
||||
}
|
||||
|
||||
export function readServerAccessToken(): string | null {
|
||||
try {
|
||||
const token = localStorage
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, Injectable, signal } from '@angular/core';
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import { clearServerAccessToken } from './server-access-token';
|
||||
|
||||
export type ServerConnectionState =
|
||||
'inactive' | 'online' | 'offline' | 'unauthorized';
|
||||
@@ -49,6 +50,17 @@ export class ServerStorageStatusService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The access guard refused a request outside the storage API (a
|
||||
* provider proxy call): the stored token is stale, ask for a new one.
|
||||
*/
|
||||
reportAccessDenied(): void {
|
||||
if (this.isActive()) {
|
||||
clearServerAccessToken();
|
||||
this.stateSignal.set('unauthorized');
|
||||
}
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.retryHandler?.();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from '../server-access-token';
|
||||
import { ServerStorageStatusService } from '../server-storage-status.service';
|
||||
import { ServerAccessTokenInterceptor } from './server-access-token.interceptor';
|
||||
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
||||
|
||||
@@ -53,4 +54,41 @@ describe('ServerAccessTokenInterceptor', () => {
|
||||
.request.headers.has('Authorization')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('asks for a new token when the backend refuses the stored one', () => {
|
||||
const status = TestBed.inject(ServerStorageStatusService);
|
||||
status.activate(
|
||||
{
|
||||
service: 'iptvnator',
|
||||
apiVersion: 1,
|
||||
instanceId: 'i',
|
||||
storage: 'server',
|
||||
auth: 'token',
|
||||
},
|
||||
() => undefined
|
||||
);
|
||||
const http = TestBed.inject(HttpClient);
|
||||
const controller = TestBed.inject(HttpTestingController);
|
||||
|
||||
// A provider's own 401 passed on by the proxy changes nothing.
|
||||
http.get('/api/xtream').subscribe({ error: () => undefined });
|
||||
controller
|
||||
.expectOne('/api/xtream')
|
||||
.flush({ message: 'bad login' }, { status: 401, statusText: 'x' });
|
||||
expect(status.state()).toBe('online');
|
||||
|
||||
http.get('/api/xtream').subscribe({ error: () => undefined });
|
||||
controller.expectOne('/api/xtream').flush(
|
||||
{ code: 'unauthorized' },
|
||||
{
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: { 'WWW-Authenticate': 'Bearer realm="iptvnator"' },
|
||||
}
|
||||
);
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
expect(
|
||||
localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,27 @@
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { readServerAccessToken } from '../server-access-token';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import {
|
||||
isServerAccessChallenge,
|
||||
readServerAccessToken,
|
||||
} from '../server-access-token';
|
||||
import { ServerStorageStatusService } from '../server-storage-status.service';
|
||||
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
||||
|
||||
/** Adds the server access token to `HttpClient` calls to the backend. */
|
||||
/**
|
||||
* Adds the server access token to `HttpClient` calls to the backend, and
|
||||
* sends the user to sign-in when the backend refuses it (a token changed
|
||||
* on the server while this page was open).
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServerAccessTokenInterceptor implements HttpInterceptor {
|
||||
private readonly status = inject(ServerStorageStatusService);
|
||||
private readonly baseUrl = inject(SERVER_STORAGE_CONFIG).baseUrl.replace(
|
||||
/\/+$/,
|
||||
''
|
||||
@@ -21,12 +31,29 @@ export class ServerAccessTokenInterceptor implements HttpInterceptor {
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
const token = readServerAccessToken();
|
||||
if (!token || !request.url.startsWith(this.baseUrl)) {
|
||||
if (!request.url.startsWith(this.baseUrl)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
return next.handle(
|
||||
request.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
|
||||
const token = readServerAccessToken();
|
||||
const sent = token
|
||||
? request.clone({
|
||||
setHeaders: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
: request;
|
||||
return next.handle(sent).pipe(
|
||||
tap({
|
||||
error: (error: unknown) => {
|
||||
if (
|
||||
error instanceof HttpErrorResponse &&
|
||||
isServerAccessChallenge(
|
||||
error.status,
|
||||
error.headers.get('WWW-Authenticate')
|
||||
)
|
||||
) {
|
||||
this.status.reportAccessDenied();
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,21 @@ describe('ServerApiClient', () => {
|
||||
expect(status.reportOffline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports whether a keepalive write was taken', async () => {
|
||||
const ok = jest.fn().mockResolvedValue(response(200, {}));
|
||||
await expect(
|
||||
client(ok).sendKeepalive('PUT', '/user-state/a/b', { value: 1 })
|
||||
).resolves.toBe(true);
|
||||
expect(ok.mock.calls[0][1]).toMatchObject({ keepalive: true });
|
||||
|
||||
const refused = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new TypeError('body too large'));
|
||||
await expect(
|
||||
client(refused).sendKeepalive('DELETE', '/user-state/a/b')
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('clears the token and reports unauthorized on 401', async () => {
|
||||
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'old');
|
||||
const fetchFn = jest
|
||||
|
||||
@@ -123,22 +123,31 @@ export class ServerApiClient {
|
||||
}
|
||||
|
||||
/** Best effort while the page is hidden or closed; errors are ignored. */
|
||||
sendKeepalive(
|
||||
/**
|
||||
* A write that survives the page. Resolves whether the server took it;
|
||||
* browsers reject keepalive bodies above 64 KiB, which resolves false.
|
||||
*/
|
||||
async sendKeepalive(
|
||||
method: 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
body?: unknown
|
||||
): void {
|
||||
void this.fetchFn()(this.url(path), {
|
||||
method,
|
||||
keepalive: true,
|
||||
headers: {
|
||||
...serverAuthorizationHeaders(),
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
}).catch(() => undefined);
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.fetchFn()(this.url(path), {
|
||||
method,
|
||||
keepalive: true,
|
||||
headers: {
|
||||
...serverAuthorizationHeaders(),
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private fetchFn(): typeof fetch {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { combineUserData, unionItems } from './server-migration-merge';
|
||||
import {
|
||||
combineUserData,
|
||||
retargetUserData,
|
||||
unionItems,
|
||||
} from './server-migration-merge';
|
||||
|
||||
describe('combineUserData', () => {
|
||||
it('unions lists, server entries first', () => {
|
||||
@@ -40,3 +44,46 @@ describe('unionItems', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playback positions', () => {
|
||||
it('keep the server position of the same item', () => {
|
||||
expect(
|
||||
combineUserData([
|
||||
[
|
||||
{
|
||||
contentType: 'vod',
|
||||
contentXtreamId: 7,
|
||||
positionSeconds: 60,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
contentType: 'vod',
|
||||
contentXtreamId: 7,
|
||||
positionSeconds: 900,
|
||||
},
|
||||
{
|
||||
contentType: 'episode',
|
||||
contentXtreamId: 7,
|
||||
positionSeconds: 5,
|
||||
},
|
||||
],
|
||||
])
|
||||
).toEqual([
|
||||
{ contentType: 'vod', contentXtreamId: 7, positionSeconds: 60 },
|
||||
{ contentType: 'episode', contentXtreamId: 7, positionSeconds: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('name the server playlist after a move', () => {
|
||||
expect(
|
||||
retargetUserData(
|
||||
[{ contentXtreamId: 1, playlistId: 'b' }, 'live:1'],
|
||||
'b',
|
||||
's'
|
||||
)
|
||||
).toEqual([{ contentXtreamId: 1, playlistId: 's' }, 'live:1']);
|
||||
const same = [{ playlistId: 'b' }];
|
||||
expect(retargetUserData(same, 'b', 'b')).toBe(same);
|
||||
});
|
||||
});
|
||||
@@ -41,11 +41,21 @@ export function unionItems<T>(...lists: readonly (readonly T[])[]): T[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Content ids or URLs identify favorites and history entries. */
|
||||
/**
|
||||
* Content ids or URLs identify favorites and history entries; a playback
|
||||
* position is identified by its content type and Xtream id.
|
||||
*/
|
||||
function itemKey(item: unknown): string {
|
||||
if (!isPlainObject(item)) {
|
||||
return `value:${JSON.stringify(item)}`;
|
||||
}
|
||||
if (
|
||||
typeof item['contentType'] === 'string' &&
|
||||
(typeof item['contentXtreamId'] === 'number' ||
|
||||
typeof item['contentXtreamId'] === 'string')
|
||||
) {
|
||||
return `position:${item['contentType']}:${item['contentXtreamId']}`;
|
||||
}
|
||||
for (const field of ['id', 'stream_id', 'series_id', 'movie_id', 'url']) {
|
||||
const value = item[field];
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
@@ -55,6 +65,25 @@ function itemKey(item: unknown): string {
|
||||
return `json:${JSON.stringify(item)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A browser value moved under another playlist id: list items that name
|
||||
* their playlist (playback positions do) name the server's instead.
|
||||
*/
|
||||
export function retargetUserData(
|
||||
value: unknown,
|
||||
browserId: string,
|
||||
serverId: string
|
||||
): unknown {
|
||||
if (browserId === serverId || !Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
return value.map((item) =>
|
||||
isPlainObject(item) && item['playlistId'] === browserId
|
||||
? { ...item, playlistId: serverId }
|
||||
: item
|
||||
);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
type Playlist,
|
||||
type ServerUserStateResponse,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { combineUserData, unionItems } from './server-migration-merge';
|
||||
import {
|
||||
combineUserData,
|
||||
retargetUserData,
|
||||
unionItems,
|
||||
} from './server-migration-merge';
|
||||
import { planServerMigration } from './server-migration-plan';
|
||||
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
||||
import { ServerStorageSession } from './server-storage-session';
|
||||
@@ -180,7 +184,7 @@ export class ServerMigrationService {
|
||||
if (serverId) {
|
||||
incoming.set(serverId, [
|
||||
...(incoming.get(serverId) ?? []),
|
||||
value,
|
||||
retargetUserData(value, browserId, serverId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('ServerUserDataStorage', () => {
|
||||
}),
|
||||
sendJson: jest.fn().mockResolvedValue(undefined),
|
||||
request: jest.fn().mockResolvedValue(undefined),
|
||||
sendKeepalive: jest.fn(),
|
||||
sendKeepalive: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const onWriteFailure = jest.fn();
|
||||
const storage = new ServerUserDataStorage(
|
||||
@@ -259,4 +259,44 @@ describe('ServerUserDataStorage', () => {
|
||||
{ value: ['b'] }
|
||||
);
|
||||
});
|
||||
|
||||
it('queues a page-hide write again when the server did not take it', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
api.sendKeepalive.mockResolvedValueOnce(false);
|
||||
|
||||
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['big'] }));
|
||||
storage.flushWithKeepalive();
|
||||
await settle();
|
||||
|
||||
// Back on the page: the re-read sends it normally and keeps it.
|
||||
api.getJson.mockResolvedValueOnce({
|
||||
entries: { 'xtream-favorites': { p1: ['big'] } },
|
||||
});
|
||||
await storage.hydrate();
|
||||
expect(api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['big'] }
|
||||
);
|
||||
});
|
||||
|
||||
it('runs re-reads one after another', async () => {
|
||||
const { api, storage } = setup();
|
||||
await storage.hydrate();
|
||||
let answerFirst: (value: unknown) => void = () => undefined;
|
||||
api.getJson.mockReturnValueOnce(
|
||||
new Promise((resolve) => (answerFirst = resolve))
|
||||
);
|
||||
|
||||
const first = storage.hydrate();
|
||||
const second = storage.hydrate();
|
||||
await settle();
|
||||
expect(api.getJson).toHaveBeenCalledTimes(2);
|
||||
|
||||
answerFirst({ entries: {} });
|
||||
await first;
|
||||
await second;
|
||||
expect(api.getJson).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,10 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
/** Batches go out one at a time, so an older write never lands last. */
|
||||
private sending: Promise<void> = Promise.resolve();
|
||||
private batchesInFlight = 0;
|
||||
/** Page-hide writes still travelling, by `namespace|playlistId`. */
|
||||
private readonly keepaliveKeys = new Set<string>();
|
||||
/** Re-reads run one at a time, so an older one never lands last. */
|
||||
private hydration: Promise<void> = Promise.resolve();
|
||||
/** Edit counter per `namespace|playlistId`, for hydrate to keep them. */
|
||||
private readonly edits = new Map<string, number>();
|
||||
private editCount = 0;
|
||||
@@ -78,7 +82,16 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
* writes. Entries edited while the read was in flight, and entries whose
|
||||
* write failed, keep their local value: the server does not have it yet.
|
||||
*/
|
||||
async hydrate(): Promise<void> {
|
||||
hydrate(): Promise<void> {
|
||||
const run = this.hydration.then(() => this.hydrateNow());
|
||||
this.hydration = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async hydrateNow(): Promise<void> {
|
||||
// Edits from here on may miss the read: queued, or still travelling.
|
||||
const editsBefore = this.editCount;
|
||||
await this.flush();
|
||||
@@ -91,7 +104,10 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
const current = parseMap(
|
||||
this.local.getItem(SERVER_CACHE_PREFIX + namespace) ?? '{}'
|
||||
);
|
||||
const unsaved = new Set(this.pending.keys());
|
||||
const unsaved = new Set([
|
||||
...this.pending.keys(),
|
||||
...this.keepaliveKeys,
|
||||
]);
|
||||
for (const [key, edit] of this.edits) {
|
||||
if (edit > editsBefore) {
|
||||
unsaved.add(key);
|
||||
@@ -115,7 +131,11 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
);
|
||||
}
|
||||
for (const [key, edit] of this.edits) {
|
||||
if (edit <= editsBefore) {
|
||||
if (
|
||||
edit <= editsBefore &&
|
||||
!this.pending.has(key) &&
|
||||
!this.keepaliveKeys.has(key)
|
||||
) {
|
||||
this.edits.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -153,14 +173,22 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
}
|
||||
const sendAll = () => {
|
||||
for (const entry of entries) {
|
||||
const key = entryKey(entry);
|
||||
const edit = this.edits.get(key);
|
||||
this.keepaliveKeys.add(key);
|
||||
const path = entryPath(entry);
|
||||
if (entry.value === undefined) {
|
||||
this.api.sendKeepalive('DELETE', path);
|
||||
} else {
|
||||
this.api.sendKeepalive('PUT', path, {
|
||||
value: entry.value,
|
||||
});
|
||||
}
|
||||
const sent =
|
||||
entry.value === undefined
|
||||
? this.api.sendKeepalive('DELETE', path)
|
||||
: this.api.sendKeepalive('PUT', path, {
|
||||
value: entry.value,
|
||||
});
|
||||
void sent.then((ok) => {
|
||||
this.keepaliveKeys.delete(key);
|
||||
if (!ok) {
|
||||
this.requeue(entry, edit);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
if (this.batchesInFlight === 0) {
|
||||
@@ -196,7 +224,7 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
}
|
||||
|
||||
private queue(entry: PendingEntry): void {
|
||||
const key = `${entry.namespace}|${entry.playlistId}`;
|
||||
const key = entryKey(entry);
|
||||
this.pending.set(key, entry);
|
||||
this.edits.set(key, ++this.editCount);
|
||||
}
|
||||
@@ -221,7 +249,22 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
return batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back into the queue after a failed write, for the offline retry or
|
||||
* the next edit, unless the entry was edited since it was sent.
|
||||
*/
|
||||
private requeue(
|
||||
entry: PendingEntry,
|
||||
editWhenSent: number | undefined
|
||||
): void {
|
||||
const key = entryKey(entry);
|
||||
if (this.edits.get(key) === editWhenSent && !this.pending.has(key)) {
|
||||
this.pending.set(key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
private async send(entry: PendingEntry): Promise<void> {
|
||||
const edit = this.edits.get(entryKey(entry));
|
||||
try {
|
||||
if (entry.value === undefined) {
|
||||
await this.api.request(entryPath(entry), { method: 'DELETE' });
|
||||
@@ -231,17 +274,16 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Keep it for the next flush (the offline retry or the next
|
||||
// edit) unless a newer value for the entry is already queued.
|
||||
const key = `${entry.namespace}|${entry.playlistId}`;
|
||||
if (!this.pending.has(key)) {
|
||||
this.pending.set(key, entry);
|
||||
}
|
||||
this.requeue(entry, edit);
|
||||
this.onWriteFailure(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function entryKey(entry: PendingEntry): string {
|
||||
return `${entry.namespace}|${entry.playlistId}`;
|
||||
}
|
||||
|
||||
function splitEditKey(key: string): [string, string] {
|
||||
const separator = key.indexOf('|');
|
||||
return [key.slice(0, separator), key.slice(separator + 1)];
|
||||
|
||||
@@ -36,6 +36,12 @@ export interface ServerInstanceInfo {
|
||||
readonly auth: 'none' | 'token';
|
||||
}
|
||||
|
||||
/**
|
||||
* `WWW-Authenticate` value of the access guard's 401. A provider error the
|
||||
* proxy passes on never carries it, so a client can tell the two apart.
|
||||
*/
|
||||
export const SERVER_ACCESS_CHALLENGE = 'Bearer realm="iptvnator"';
|
||||
|
||||
/**
|
||||
* Sent with every `PUT /v1/playlists/:id`: the document's ETag before that
|
||||
* write. A client that kept the stored payload (`keepPayload=1`) may cache
|
||||
|
||||
Reference in new issue
Block a user