mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user