mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -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
@@ -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)];
|
||||
|
||||
Reference in new issue
Block a user