fix(server-storage): token only to backend URLs in raw fetches; Stalker series keys in the move

PwaService joins paths to the backend URL without its trailing slashes,
so BACKEND_URL=/ no longer yields a protocol-relative //… URL that names
another host, and it attaches the server token only when the request URL
resolves to the backend (isBackendUrl), like the interceptor.

The move's item key adds the Stalker series flag to id and category: a
provider category can hold a movie and a series of one id, and the
series favorite was dropped as a duplicate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 23:15:36 +02:00
1 parent fdaa2b5aa0
commit ed956a454c
5 files changed
+110 -24

No files matched your search

@@ -571,6 +571,55 @@ describe('PwaService', () => {
}
});
it('sends the server token to the backend only, also with BACKEND_URL=/', async () => {
writeServerAccessToken('kept-token', 'i');
TestBed.inject(ServerStorageStatusService).activate(
{
service: 'iptvnator',
apiVersion: 1,
instanceId: 'i',
storage: 'server',
auth: 'token',
},
() => undefined
);
window.__IPTVNATOR_CONFIG__ = { BACKEND_URL: '/' };
const atRoot = TestBed.runInInjectionContext(() => new PwaService());
delete window.__IPTVNATOR_CONFIG__;
const originalFetch = globalThis.fetch;
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({ reset: true }),
} as unknown as Response);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const reset = () =>
atRoot.sendIpcEvent(CONNECTIVITY_GUARD_RESET, {
url: 'http://portal.example:8080/portal.php',
});
try {
await reset();
// Not `//connectivity-guard/reset`: that is another host.
expect(fetchMock.mock.calls[0][0]).toBe(
'/connectivity-guard/reset'
);
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({
Authorization: 'Bearer kept-token',
});
atRoot.corsProxyUrl = 'https://elsewhere.example';
await reset();
expect(fetchMock.mock.calls[1][1].headers).not.toHaveProperty(
'Authorization'
);
} finally {
globalThis.fetch = originalFetch;
clearServerAccessToken();
}
});
it('asks the backend to forget a host when the connectivity guard is reset', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
+28 -19
View File
@@ -18,6 +18,7 @@ import {
isServerAccessChallenge,
ServerStorageStatusService,
} from '@iptvnator/services';
import { isBackendUrl } from '@iptvnator/services/server-storage';
import {
CONNECTIVITY_GUARD_RESET,
ERROR,
@@ -100,8 +101,15 @@ export class PwaService extends DataService {
XtreamCodeActions.GetSimpleDateTable,
]);
/** Proxy URL to avoid CORS issues */
corsProxyUrl = getRuntimeBackendUrl();
/** The backend's URL as configured: `/` for the page's own origin. */
private readonly backendUrl = getRuntimeBackendUrl();
/**
* Proxy URL to avoid CORS issues, without trailing slashes: paths are
* appended to it, and `BACKEND_URL=/` must give `/xtream`, never the
* protocol-relative `//xtream` (another host).
*/
corsProxyUrl = this.backendUrl.replace(/\/+$/, '');
constructor() {
super();
@@ -200,19 +208,17 @@ export class PwaService extends DataService {
);
try {
const token = this.serverAccessToken();
const response = await fetch(
`${this.corsProxyUrl}/connectivity-guard/reset`,
{
body: JSON.stringify({ url: payload.url }),
headers: {
'content-type': 'application/json',
...bearer(token),
},
method: 'POST',
signal: controller.signal,
}
);
const url = `${this.corsProxyUrl}/connectivity-guard/reset`;
const token = this.serverAccessToken(url);
const response = await fetch(url, {
body: JSON.stringify({ url: payload.url }),
headers: {
'content-type': 'application/json',
...bearer(token),
},
method: 'POST',
signal: controller.signal,
});
this.noteServerAccess(response, token);
if (!response.ok) {
@@ -647,7 +653,7 @@ export class PwaService extends DataService {
logPortalDebugRequest(context);
// Make the fetch request
const accessToken = this.serverAccessToken();
const accessToken = this.serverAccessToken(requestUrl);
const response = await fetch(requestUrl, {
headers: bearer(accessToken),
});
@@ -806,10 +812,13 @@ export class PwaService extends DataService {
/**
* The stored server token, only while server mode runs for this
* backend: a token kept from another backend must not reach this one.
* backend and only for a request that goes to it: a token kept from
* another backend must not reach this one, nor any other host.
*/
private serverAccessToken(): string | null {
return this.serverStatus.accessToken();
private serverAccessToken(url: string): string | null {
return isBackendUrl(url, this.backendUrl)
? this.serverStatus.accessToken()
: null;
}
/** A token changed on the server shows up on any backend call. */
+7 -2
View File
@@ -311,7 +311,10 @@ settings and guards read them in every build.
does not send the new server's token there, and goes to sign-in instead.
`ServerAccessTokenInterceptor` adds the header to `HttpClient` calls to the
backend, and `PwaService` adds it to its two raw `fetch` calls only while
server mode runs on a verified instance. The browser forgets the token
server mode runs on a verified instance and only when the URL resolves
to the backend (`isBackendUrl`); it joins paths to the backend URL
without its trailing slashes, so `BACKEND_URL=/` never yields a
protocol-relative `//…` URL that names another host. The browser forgets the token
when the backend answers without server storage, or as another server or
database (backend URL or instance id) than the one it remembers; sign-in
therefore remembers the server along with the token, or reports storage
@@ -332,7 +335,9 @@ settings and guards read them in every build.
others upload with their ids. A merged playlist gains the browser
document's favorites and history (M3U favorites name channels by stream
URL; older ones by a channel id that is random per import and is
translated through the URL; nothing is dropped), and the legacy Xtream maps follow under
translated through the URL; Stalker items by id, category and series
flag, since one provider category can hold a movie and a series of one
id; nothing is dropped), and the legacy Xtream maps follow under
the server id; each copy's bare legacy Xtream ids are typed from its own
snapshots first (`typeLegacyXtreamRefs`; snapshots keep their bare key as
an alias), then `combineUserData` unions
@@ -61,6 +61,26 @@ describe('Stalker items', () => {
{ id: 7, category_id: 'vod', name: 'Movie' },
]);
});
it('keep a movie and a series of one provider category apart', () => {
expect(
unionItems<Record<string, unknown>>(
[{ id: 7, category_id: '12', name: 'Movie' }],
[
{ id: 7, category_id: '12', is_series: 1, name: 'Show' },
{
id: 7,
category_id: '12',
is_series: true,
name: 'Again',
},
]
)
).toEqual([
{ id: 7, category_id: '12', name: 'Movie' },
{ id: 7, category_id: '12', is_series: 1, name: 'Show' },
]);
});
});
describe('playback positions', () => {
@@ -1,3 +1,5 @@
import { isStalkerSeriesFlag } from '@iptvnator/shared/interfaces';
/**
* How the move to the server combines user data that two places hold for
* one playlist: the server's value comes first and wins, the browser only
@@ -56,12 +58,13 @@ export function itemKey(item: unknown): string {
) {
return `position:${item['contentType']}:${item['contentXtreamId']}`;
}
// Stalker ids repeat across content types; the category tells them apart.
// Stalker ids repeat across content types; the category tells them
// apart, and the series flag where a provider category holds both.
const category = item['category_id'];
const scope =
typeof category === 'string' || typeof category === 'number'
(typeof category === 'string' || typeof category === 'number'
? `|category:${category}`
: '';
: '') + (isStalkerSeriesFlag(item['is_series']) ? '|series' : '');
for (const field of ['id', 'stream_id', 'series_id', 'movie_id', 'url']) {
const value = item[field];
if (typeof value === 'string' || typeof value === 'number') {