diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 6a2f87f5d..577b4e7b3 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -6,6 +6,10 @@ import { SettingsStore, } from '@iptvnator/services'; import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; +import { + serverAccessGuard, + serverSignInGuard, +} from './server-storage/server-access.guard'; import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard'; // The workspace activates only once settings AND the parental lock state @@ -77,6 +81,7 @@ export const routes: Routes = [ }, { path: 'workspace', + canActivate: [serverAccessGuard], resolve: { settingsReady: settingsReadyResolver, }, @@ -207,6 +212,14 @@ export const routes: Routes = [ }, ], }, + { + path: 'server-sign-in', + canActivate: [serverSignInGuard], + loadComponent: () => + import('./server-storage/server-sign-in.component').then( + (c) => c.ServerSignInComponent + ), + }, { path: 'settings', redirectTo: '/workspace/settings', diff --git a/apps/web/src/app/server-storage/server-access.guard.spec.ts b/apps/web/src/app/server-storage/server-access.guard.spec.ts new file mode 100644 index 000000000..7d388ecc9 --- /dev/null +++ b/apps/web/src/app/server-storage/server-access.guard.spec.ts @@ -0,0 +1,47 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter, Router, UrlTree } from '@angular/router'; +import { ServerStorageStatusService } from '@iptvnator/services'; +import { serverAccessGuard, serverSignInGuard } from './server-access.guard'; + +describe('server access guards', () => { + function run(guard: typeof serverAccessGuard, state: string) { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: ServerStorageStatusService, + useValue: { state: () => state }, + }, + ], + }); + return TestBed.runInInjectionContext(() => + guard({} as never, {} as never) + ); + } + + const serialize = (result: unknown) => + TestBed.inject(Router).serializeUrl(result as UrlTree); + + afterEach(() => TestBed.resetTestingModule()); + + it('sends an unauthorized workspace visit to the sign-in page', () => { + expect(serialize(run(serverAccessGuard, 'unauthorized'))).toBe( + '/server-sign-in' + ); + }); + + it('lets everyone else into the workspace', () => { + expect(run(serverAccessGuard, 'online')).toBe(true); + expect(run(serverAccessGuard, 'offline')).toBe(true); + expect(run(serverAccessGuard, 'inactive')).toBe(true); + }); + + it('opens the sign-in page only while unauthorized', () => { + expect(run(serverSignInGuard, 'unauthorized')).toBe(true); + expect(serialize(run(serverSignInGuard, 'online'))).toBe('/workspace'); + expect(serialize(run(serverSignInGuard, 'inactive'))).toBe( + '/workspace' + ); + }); +}); diff --git a/apps/web/src/app/server-storage/server-access.guard.ts b/apps/web/src/app/server-storage/server-access.guard.ts new file mode 100644 index 000000000..20a4df69b --- /dev/null +++ b/apps/web/src/app/server-storage/server-access.guard.ts @@ -0,0 +1,14 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { ServerStorageStatusService } from '@iptvnator/services'; + +/** Without a valid token the workspace would show an empty library. */ +export const serverAccessGuard: CanActivateFn = () => + inject(ServerStorageStatusService).state() === 'unauthorized' + ? inject(Router).parseUrl('/server-sign-in') + : true; + +export const serverSignInGuard: CanActivateFn = () => + inject(ServerStorageStatusService).state() === 'unauthorized' + ? true + : inject(Router).parseUrl('/workspace'); diff --git a/apps/web/src/app/server-storage/server-instance-probe.spec.ts b/apps/web/src/app/server-storage/server-instance-probe.spec.ts new file mode 100644 index 000000000..2fd8e3bee --- /dev/null +++ b/apps/web/src/app/server-storage/server-instance-probe.spec.ts @@ -0,0 +1,82 @@ +import { probeServerInstance } from './server-instance-probe'; + +const instance = { + service: 'iptvnator', + apiVersion: 1, + instanceId: 'i-1', + storage: 'server', + auth: 'none', +}; + +describe('probeServerInstance', () => { + const ok = (body: unknown) => + jest.fn().mockResolvedValue({ ok: true, json: async () => body }); + + it('skips the probe without a runtime backend URL', async () => { + const fetchFn = ok(instance); + await expect( + probeServerInstance({ + runtimeConfig: {}, + fetchFn: fetchFn as never, + }) + ).resolves.toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('skips the probe in Electron', async () => { + const fetchFn = ok(instance); + await expect( + probeServerInstance({ + runtimeConfig: { BACKEND_URL: '/api' }, + isElectron: true, + fetchFn: fetchFn as never, + }) + ).resolves.toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('returns the instance when it reports server storage', async () => { + const fetchFn = ok(instance); + await expect( + probeServerInstance({ + runtimeConfig: { BACKEND_URL: '/api/' }, + isElectron: false, + fetchFn: fetchFn as never, + }) + ).resolves.toEqual(instance); + expect(fetchFn.mock.calls[0][0]).toBe('/api/v1/instance'); + }); + + it('returns null for storage none, bad bodies and failures', async () => { + const config = { + runtimeConfig: { BACKEND_URL: '/api' }, + isElectron: false, + }; + await expect( + probeServerInstance({ + ...config, + fetchFn: ok({ ...instance, storage: 'none' }) as never, + }) + ).resolves.toBeNull(); + await expect( + probeServerInstance({ + ...config, + fetchFn: ok({ hello: 1 }) as never, + }) + ).resolves.toBeNull(); + await expect( + probeServerInstance({ + ...config, + fetchFn: jest + .fn() + .mockResolvedValue({ ok: false, status: 404 }) as never, + }) + ).resolves.toBeNull(); + await expect( + probeServerInstance({ + ...config, + fetchFn: jest.fn().mockRejectedValue(new Error('x')) as never, + }) + ).resolves.toBeNull(); + }); +}); diff --git a/apps/web/src/app/server-storage/server-instance-probe.ts b/apps/web/src/app/server-storage/server-instance-probe.ts new file mode 100644 index 000000000..ff5e2c236 --- /dev/null +++ b/apps/web/src/app/server-storage/server-instance-probe.ts @@ -0,0 +1,62 @@ +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +import type { IptvnatorRuntimeConfig } from '../services/runtime-config'; + +const PROBE_TIMEOUT_MS = 2_500; + +export interface ServerInstanceProbeOptions { + readonly runtimeConfig?: IptvnatorRuntimeConfig; + readonly isElectron?: boolean; + readonly fetchFn?: typeof fetch; +} + +/** + * Asks the backend whether it offers server storage. Runs only when the + * deployment wrote a runtime `BACKEND_URL` (the Docker entrypoint does), so + * hosted builds never call an external parser API for it. + */ +export async function probeServerInstance( + options: ServerInstanceProbeOptions = {} +): Promise { + const runtimeConfig = + options.runtimeConfig ?? globalThis.window?.__IPTVNATOR_CONFIG__; + const isElectron = options.isElectron ?? !!globalThis.window?.electron; + const baseUrl = runtimeConfig?.BACKEND_URL?.trim(); + if (isElectron || !baseUrl) { + return null; + } + + try { + const fetchFn = options.fetchFn ?? globalThis.fetch.bind(globalThis); + const response = await fetchFn( + `${baseUrl.replace(/\/+$/, '')}/v1/instance`, + { + cache: 'no-store', + signal: + typeof AbortSignal.timeout === 'function' + ? AbortSignal.timeout(PROBE_TIMEOUT_MS) + : undefined, + } + ); + if (!response.ok) { + return null; + } + const body: unknown = await response.json(); + return isServerInstance(body) && body.storage === 'server' + ? body + : null; + } catch { + return null; + } +} + +function isServerInstance(value: unknown): value is ServerInstanceInfo { + const record = value as Partial | null; + return ( + !!record && + record.service === 'iptvnator' && + typeof record.apiVersion === 'number' && + typeof record.instanceId === 'string' && + (record.storage === 'server' || record.storage === 'none') && + (record.auth === 'none' || record.auth === 'token') + ); +} diff --git a/apps/web/src/app/server-storage/server-migration-prompt.service.ts b/apps/web/src/app/server-storage/server-migration-prompt.service.ts new file mode 100644 index 000000000..0fb3ef18f --- /dev/null +++ b/apps/web/src/app/server-storage/server-migration-prompt.service.ts @@ -0,0 +1,50 @@ +import { inject, Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter, take } from 'rxjs'; +import { ServerStorageStatusService } from '@iptvnator/services'; +import { + ServerMigrationService, + type ServerMigrationPlaylist, +} from '@iptvnator/services/server-storage'; + +/** Opens the migration dialog once, after the first navigation settles. */ +@Injectable({ providedIn: 'root' }) +export class ServerMigrationPrompt { + private readonly router = inject(Router); + private readonly dialog = inject(MatDialog); + private readonly status = inject(ServerStorageStatusService); + private readonly migration = inject(ServerMigrationService); + + schedule(): void { + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + take(1) + ) + .subscribe(() => void this.offer()); + } + + private async offer(): Promise { + if (this.status.state() !== 'online') { + return; + } + let playlists: ServerMigrationPlaylist[] | null; + try { + playlists = await this.migration.offerOnStartup(); + } catch { + return; // IndexedDB or server unreadable: ask again next start. + } + if (!playlists) { + return; + } + const { ServerMigrationDialogComponent } = + await import('./server-migration-dialog.component'); + this.dialog.open(ServerMigrationDialogComponent, { + data: playlists, + width: '540px', + maxWidth: 'calc(100vw - 32px)', + disableClose: true, + }); + } +} diff --git a/apps/web/src/app/server-storage/server-storage.providers.ts b/apps/web/src/app/server-storage/server-storage.providers.ts new file mode 100644 index 000000000..0021017a9 --- /dev/null +++ b/apps/web/src/app/server-storage/server-storage.providers.ts @@ -0,0 +1,36 @@ +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { + EnvironmentProviders, + inject, + provideAppInitializer, + Provider, +} from '@angular/core'; +import { USER_DATA_STORAGE } from '@iptvnator/services'; +import { + SERVER_STORAGE_CONFIG, + ServerAccessTokenInterceptor, + ServerStorageSession, +} from '@iptvnator/services/server-storage'; +import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces'; +import { ServerMigrationPrompt } from './server-migration-prompt.service'; + +/** Loaded with a dynamic import from `main.ts`, only in server mode. */ +export function provideServerStorage( + instance: ServerInstanceInfo, + baseUrl: string +): (Provider | EnvironmentProviders)[] { + return [ + { provide: SERVER_STORAGE_CONFIG, useValue: { instance, baseUrl } }, + { + provide: USER_DATA_STORAGE, + useFactory: () => inject(ServerStorageSession).userData, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: ServerAccessTokenInterceptor, + multi: true, + }, + provideAppInitializer(() => inject(ServerStorageSession).start()), + provideAppInitializer(() => inject(ServerMigrationPrompt).schedule()), + ]; +} diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 1d7152983..e88da7050 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -13,7 +13,7 @@ import { switchMap, throwError, } from 'rxjs'; -import { DataService } from '@iptvnator/services'; +import { DataService, serverAuthorizationHeaders } from '@iptvnator/services'; import { CONNECTIVITY_GUARD_RESET, ERROR, @@ -199,7 +199,10 @@ export class PwaService extends DataService { `${this.corsProxyUrl}/connectivity-guard/reset`, { body: JSON.stringify({ url: payload.url }), - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + ...serverAuthorizationHeaders(), + }, method: 'POST', signal: controller.signal, } @@ -637,7 +640,9 @@ export class PwaService extends DataService { logPortalDebugRequest(context); // Make the fetch request - const response = await fetch(requestUrl); + const response = await fetch(requestUrl, { + headers: serverAuthorizationHeaders(), + }); if (!response.ok) { throw new Error( diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 15ea472b4..927fb9c43 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -1,8 +1,11 @@ +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; import { resolveRestoredRendererRoute } from '@iptvnator/shared/interfaces'; import { registerAppDateLocale } from './app/app-date-locales'; import { AppComponent } from './app/app.component'; import { appConfig, getInitialLanguage } from './app/app.config'; +import { probeServerInstance } from './app/server-storage/server-instance-probe'; +import { getRuntimeBackendUrl } from './app/services/runtime-config'; // A reloaded packaged renderer arrives on index.html with the route it was // on carried in the query string (the Electron main process recovers the @@ -16,12 +19,26 @@ if (restoredHref !== null) { window.history.replaceState(window.history.state, '', restoredHref); } +/** Adds server storage when the self-hosted backend offers it. */ +async function resolveAppConfig(): Promise { + const instance = await probeServerInstance(); + if (!instance) { + return appConfig; + } + const { provideServerStorage } = + await import('./app/server-storage/server-storage.providers'); + return mergeApplicationConfig(appConfig, { + providers: provideServerStorage(instance, getRuntimeBackendUrl()), + }); +} + // Angular ships only English locale data; the preferred language's data is a // lazy chunk. It is awaited before bootstrapping because the first route // renders dates with that locale, and a locale without data throws. English -// (the default) resolves immediately. -registerAppDateLocale(getInitialLanguage()) - .then(() => bootstrapApplication(AppComponent, appConfig)) +// (the default) resolves immediately. The server-storage probe runs in +// parallel and only when the deployment provides a runtime backend URL. +Promise.all([registerAppDateLocale(getInitialLanguage()), resolveAppConfig()]) + .then(([, config]) => bootstrapApplication(AppComponent, config)) .then(() => { // Splash is rendered eagerly by index.html so the user sees something // immediately instead of a blank Material-grey background. Once Angular