mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in new issue
Block a user