diff --git a/libs/services/src/lib/server-storage/index.ts b/libs/services/src/lib/server-storage/index.ts index 6c76c6fd7..8debd0d92 100644 --- a/libs/services/src/lib/server-storage/index.ts +++ b/libs/services/src/lib/server-storage/index.ts @@ -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'; diff --git a/libs/services/src/lib/server-storage/server-access-token.interceptor.spec.ts b/libs/services/src/lib/server-storage/server-access-token.interceptor.spec.ts new file mode 100644 index 000000000..689f66242 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-access-token.interceptor.spec.ts @@ -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); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-access-token.interceptor.ts b/libs/services/src/lib/server-storage/server-access-token.interceptor.ts new file mode 100644 index 000000000..a8d390a84 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-access-token.interceptor.ts @@ -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, + next: HttpHandler + ): Observable> { + const token = readServerAccessToken(); + if (!token || !request.url.startsWith(this.baseUrl)) { + return next.handle(request); + } + return next.handle( + request.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) + ); + } +} 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 new file mode 100644 index 000000000..955c1ec9a --- /dev/null +++ b/libs/services/src/lib/server-storage/server-storage-session.spec.ts @@ -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'); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-storage-session.ts b/libs/services/src/lib/server-storage/server-storage-session.ts new file mode 100644 index 000000000..83138d69e --- /dev/null +++ b/libs/services/src/lib/server-storage/server-storage-session.ts @@ -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 { + 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 { + try { + await this.userData.hydrate(); + } catch { + // The API client already reported offline or unauthorized. + } + } + + fetchStatus(): Promise { + return this.api.getJson( + '/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, + }); + } +}