mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
- User-state entries carry a content tag; PUT/DELETE accept If-Match or If-None-Match: * and answer 412 with the current value and tag. The PWA sends each edit on the tag it read and replays it (before → after) on the server's value after a conflict, so another device's change to the same playlist's favorites or positions is kept instead of overwritten. - The access token goes only to the backend URL (same origin and path), never to another host or a sibling path. - Deleting a playlist (or all) first drops its queued user-data edits and waits for writes in flight, so none recreates deleted data. - The migration copy writes on the tags it read. - The write queue moves out of ServerUserDataStorage into UserDataWriteQueue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
83 lines
2.6 KiB
TypeScript
83 lines
2.6 KiB
TypeScript
import {
|
|
HttpErrorResponse,
|
|
HttpEvent,
|
|
HttpHandler,
|
|
HttpInterceptor,
|
|
HttpRequest,
|
|
} from '@angular/common/http';
|
|
import { inject, Injectable } from '@angular/core';
|
|
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, 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;
|
|
|
|
intercept(
|
|
request: HttpRequest<unknown>,
|
|
next: HttpHandler
|
|
): Observable<HttpEvent<unknown>> {
|
|
if (!isBackendUrl(request.url, this.baseUrl)) {
|
|
return next.handle(request);
|
|
}
|
|
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();
|
|
}
|
|
},
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether `url` goes to the backend: the same origin as the backend URL and
|
|
* a path inside it. A plain prefix check fails for `BACKEND_URL=/`, where
|
|
* every URL, other origins included, would carry the token.
|
|
*/
|
|
export function isBackendUrl(
|
|
url: string,
|
|
baseUrl: string,
|
|
pageUrl = globalThis.location?.href ?? 'http://localhost/'
|
|
): boolean {
|
|
try {
|
|
const base = new URL(baseUrl, pageUrl);
|
|
const target = new URL(url, pageUrl);
|
|
const root = base.pathname.endsWith('/')
|
|
? base.pathname
|
|
: `${base.pathname}/`;
|
|
return (
|
|
target.origin === base.origin &&
|
|
(target.pathname === base.pathname ||
|
|
target.pathname.startsWith(root))
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|