feat(services): server storage session and token interceptor

The session plugs the HTTP document store into PlaylistStorageBackend,
hydrates synced user data before the first read (bounded to 3 s), retries
while offline, flushes on hide, and routes to sign-in when the server
answers 401. The interceptor adds the access token to HttpClient calls to
the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:16:55 +02:00
1 parent 533222d137
commit fadb6e5ae2
5 files changed
+309

No files matched your search

@@ -2,3 +2,5 @@ export * from './server-storage.config';
export * from './server-api-client';
export * from './server-playlist-document-store';
export * from './server-user-data-storage';
export * from './server-access-token.interceptor';
export * from './server-storage-session';
@@ -0,0 +1,56 @@
import {
HTTP_INTERCEPTORS,
HttpClient,
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from '../server-access-token';
import { ServerAccessTokenInterceptor } from './server-access-token.interceptor';
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
describe('ServerAccessTokenInterceptor', () => {
beforeEach(() => {
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 't');
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
{
provide: HTTP_INTERCEPTORS,
useClass: ServerAccessTokenInterceptor,
multi: true,
},
{
provide: SERVER_STORAGE_CONFIG,
useValue: { baseUrl: '/api', instance: {} },
},
],
});
});
afterEach(() => localStorage.clear());
it('adds the token to backend calls only', () => {
const http = TestBed.inject(HttpClient);
const controller = TestBed.inject(HttpTestingController);
http.get('/api/parse').subscribe();
http.get('https://provider.example/list.m3u').subscribe();
expect(
controller
.expectOne('/api/parse')
.request.headers.get('Authorization')
).toBe('Bearer t');
expect(
controller
.expectOne('https://provider.example/list.m3u')
.request.headers.has('Authorization')
).toBe(false);
});
});
@@ -0,0 +1,32 @@
import {
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 { SERVER_STORAGE_CONFIG } from './server-storage.config';
/** Adds the server access token to `HttpClient` calls to the backend. */
@Injectable()
export class ServerAccessTokenInterceptor implements HttpInterceptor {
private readonly baseUrl = inject(SERVER_STORAGE_CONFIG).baseUrl.replace(
/\/+$/,
''
);
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
const token = readServerAccessToken();
if (!token || !request.url.startsWith(this.baseUrl)) {
return next.handle(request);
}
return next.handle(
request.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
);
}
}
@@ -0,0 +1,100 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { provideRouter } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistStorageBackend } from '../playlist-storage-backend';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from '../server-access-token';
import { ServerStorageStatusService } from '../server-storage-status.service';
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
import { ServerStorageSession } from './server-storage-session';
describe('ServerStorageSession', () => {
const fetchMock = jest.fn();
beforeEach(() => {
// start() arms a 3 s startup race and a 30 s retry interval.
jest.useFakeTimers();
(globalThis as { fetch: unknown }).fetch = fetchMock;
fetchMock.mockResolvedValue({
status: 200,
ok: true,
headers: new Headers(),
json: async () => ({ entries: {} }),
});
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
fetchMock.mockReset();
localStorage.clear();
TestBed.resetTestingModule();
});
function setup(auth: 'none' | 'token') {
TestBed.configureTestingModule({
providers: [
provideRouter([{ path: 'server-sign-in', children: [] }]),
{
provide: RuntimeCapabilitiesService,
useValue: { supportsSqlite: false },
},
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
{
provide: SERVER_STORAGE_CONFIG,
useValue: {
baseUrl: '/api',
instance: {
service: 'iptvnator',
apiVersion: 1,
instanceId: 'i-1',
storage: 'server',
auth,
},
},
},
],
});
return {
session: TestBed.inject(ServerStorageSession),
backend: TestBed.inject(PlaylistStorageBackend),
status: TestBed.inject(ServerStorageStatusService),
};
}
it('switches playlists to the server and hydrates user data', async () => {
const { session, backend, status } = setup('none');
await session.start();
expect(backend.kind()).toBe('server');
expect(status.state()).toBe('online');
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/user-state',
expect.anything()
);
});
it('stops at unauthorized when a token is required but missing', async () => {
const { session, status } = setup('token');
await session.start();
expect(status.state()).toBe('unauthorized');
expect(fetchMock).not.toHaveBeenCalled();
});
it('starts with a stored token', async () => {
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 't');
const { session, status } = setup('token');
await session.start();
expect(status.state()).toBe('online');
});
});
@@ -0,0 +1,119 @@
import { effect, inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import type { ServerStorageStatusResponse } from '@iptvnator/shared/interfaces';
import { PlaylistStorageBackend } from '../playlist-storage-backend';
import {
clearServerAccessToken,
readServerAccessToken,
} from '../server-access-token';
import { ServerStorageStatusService } from '../server-storage-status.service';
import { ServerApiClient, type ServerApiError } from './server-api-client';
import { ServerPlaylistDocumentStore } from './server-playlist-document-store';
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
import { ServerUserDataStorage } from './server-user-data-storage';
const RETRY_INTERVAL_MS = 30_000;
const STARTUP_HYDRATE_WAIT_MS = 3_000;
export const SERVER_SIGN_IN_PATH = '/server-sign-in';
/** Server mode for this page: created only when the probe found server storage. */
@Injectable({ providedIn: 'root' })
export class ServerStorageSession {
private readonly config = inject(SERVER_STORAGE_CONFIG);
private readonly status = inject(ServerStorageStatusService);
private readonly backend = inject(PlaylistStorageBackend);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly router = inject(Router);
readonly api = new ServerApiClient({
baseUrl: this.config.baseUrl,
status: this.status,
onWriteFailure: (error) => this.announceWriteFailure(error),
});
readonly documentStore = new ServerPlaylistDocumentStore(this.api);
readonly userData = new ServerUserDataStorage(
this.api,
globalThis.localStorage,
() => this.backend.usesBrowserStorageOverride(),
() => this.announceWriteFailure()
);
constructor() {
effect(() => {
if (
this.status.state() === 'unauthorized' &&
!this.router.url.startsWith(SERVER_SIGN_IN_PATH)
) {
void this.router.navigateByUrl(SERVER_SIGN_IN_PATH);
}
});
}
/** App initializer: must run before the first playlist read. */
async start(): Promise<void> {
this.backend.useServerStore(this.documentStore);
this.status.activate(this.config.instance, () => void this.refresh());
if (this.config.instance.auth === 'token' && !readServerAccessToken()) {
this.status.reportUnauthorized();
return;
}
await Promise.race([
this.refresh(),
new Promise((resolve) =>
setTimeout(resolve, STARTUP_HYDRATE_WAIT_MS)
),
]);
this.listen();
}
/** Re-reads synced user data; also the offline retry. */
async refresh(): Promise<void> {
try {
await this.userData.hydrate();
} catch {
// The API client already reported offline or unauthorized.
}
}
fetchStatus(): Promise<ServerStorageStatusResponse> {
return this.api.getJson<ServerStorageStatusResponse>(
'/instance/status'
);
}
signOut(): void {
clearServerAccessToken();
this.status.reportUnauthorized();
}
private listen(): void {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.userData.flushWithKeepalive();
} else {
void this.refresh();
}
});
window.addEventListener('pagehide', () =>
this.userData.flushWithKeepalive()
);
setInterval(() => {
if (this.status.state() === 'offline') {
void this.refresh();
}
}, RETRY_INTERVAL_MS);
}
private announceWriteFailure(error?: ServerApiError): void {
const key =
error?.status === 413
? 'SERVER_STORAGE.TOO_LARGE'
: 'SERVER_STORAGE.WRITE_FAILED';
this.snackBar.open(this.translate.instant(key), undefined, {
duration: 5000,
});
}
}