mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(web-backend): proxy through registered provider targets
This commit is contained in:
1 parent
7524f46f97
commit
6961ad8b65
4 files changed
+275
-93
No files matched your search
@@ -63,6 +63,21 @@ class StubHttpClient implements WebBackendHttpClient {
|
||||
|
||||
const resolvePublicHost = async () => ['93.184.216.34'];
|
||||
|
||||
async function registerProviderTarget(
|
||||
baseUrl: string,
|
||||
url: string
|
||||
): Promise<string> {
|
||||
const response = await fetch(`${baseUrl}/provider-targets`, {
|
||||
body: JSON.stringify({ url }),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
const body = (await response.json()) as { targetId: string };
|
||||
return body.targetId;
|
||||
}
|
||||
|
||||
async function withServer<T>(
|
||||
app: ReturnType<typeof createWebBackendApp>,
|
||||
callback: (baseUrl: string) => Promise<T>
|
||||
@@ -126,8 +141,12 @@ https://stream.example/news.m3u8`);
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const targetId = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'https://provider.example/list.m3u'
|
||||
);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/parse?url=${encodeURIComponent('https://provider.example/list.m3u')}`,
|
||||
`${baseUrl}/parse?targetId=${targetId}`,
|
||||
{ headers: { Origin: 'http://localhost:4200' } }
|
||||
);
|
||||
const body = (await response.json()) as {
|
||||
@@ -135,9 +154,9 @@ https://stream.example/news.m3u8`);
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe(
|
||||
'http://localhost:4200'
|
||||
);
|
||||
expect(
|
||||
response.headers.get('access-control-allow-origin')
|
||||
).toBe('http://localhost:4200');
|
||||
expect(body).toMatchObject({
|
||||
_id: 'fixed-id',
|
||||
autoRefresh: false,
|
||||
@@ -167,6 +186,29 @@ https://stream.example/news.m3u8`);
|
||||
);
|
||||
});
|
||||
|
||||
it('allows browser preflight checks for provider target registration', async () => {
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
clientOrigins: ['http://localhost:4200'],
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/provider-targets`, {
|
||||
headers: {
|
||||
'Access-Control-Request-Headers': 'content-type',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
Origin: 'http://localhost:4200',
|
||||
},
|
||||
method: 'OPTIONS',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(
|
||||
response.headers.get('access-control-allow-origin')
|
||||
).toBe('http://localhost:4200');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies Xtream requests through the provider player API endpoint', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueResponse({ user_info: { username: 'demo' } });
|
||||
@@ -177,8 +219,12 @@ https://stream.example/news.m3u8`);
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const targetId = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'http://xtream.example'
|
||||
);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&username=demo&password=secret&action=get_account_info`
|
||||
`${baseUrl}/xtream?targetId=${targetId}&username=demo&password=secret&action=get_account_info`
|
||||
);
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
@@ -210,8 +256,12 @@ https://stream.example/news.m3u8`);
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const targetId = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'http://stalker.example/portal.php'
|
||||
);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/stalker?url=${encodeURIComponent('http://stalker.example/portal.php')}&macAddress=00:1A:79:00:00:01&token=abc123&action=get_categories&type=vod`
|
||||
`${baseUrl}/stalker?targetId=${targetId}&macAddress=00:1A:79:00:00:01&token=abc123&action=get_categories&type=vod`
|
||||
);
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
@@ -247,8 +297,12 @@ https://stream.example/news.m3u8`);
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const targetId = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'http://xtream.example'
|
||||
);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/xtream?url=${encodeURIComponent('http://xtream.example')}&action=get_account_info`
|
||||
`${baseUrl}/xtream?targetId=${targetId}&action=get_account_info`
|
||||
);
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
@@ -269,8 +323,12 @@ https://stream.example/news.m3u8`);
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const targetId = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'https://provider.example/list.m3u'
|
||||
);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/parse?url=${encodeURIComponent('https://provider.example/list.m3u')}`
|
||||
`${baseUrl}/parse?targetId=${targetId}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
@@ -291,9 +349,13 @@ https://stream.example/news.m3u8`);
|
||||
await withServer(
|
||||
createWebBackendApp({ httpClient }),
|
||||
async (baseUrl) => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/parse?url=${encodeURIComponent('file:///etc/passwd')}`
|
||||
);
|
||||
const response = await fetch(`${baseUrl}/provider-targets`, {
|
||||
body: JSON.stringify({ url: 'file:///etc/passwd' }),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
@@ -311,9 +373,15 @@ https://stream.example/news.m3u8`);
|
||||
await withServer(
|
||||
createWebBackendApp({ httpClient }),
|
||||
async (baseUrl) => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/xtream?url=${encodeURIComponent('http://127.0.0.1:3211')}&action=get_account_info`
|
||||
);
|
||||
const response = await fetch(`${baseUrl}/provider-targets`, {
|
||||
body: JSON.stringify({
|
||||
url: 'http://127.0.0.1:3211',
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
@@ -336,8 +404,12 @@ https://stream.example/news.m3u8`);
|
||||
httpClient,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const targetId = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'http://127.0.0.1:3211'
|
||||
);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/xtream?url=${encodeURIComponent('http://127.0.0.1:3211')}&username=demo&password=secret&action=get_account_info`
|
||||
`${baseUrl}/xtream?targetId=${targetId}&username=demo&password=secret&action=get_account_info`
|
||||
);
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
@@ -350,4 +422,36 @@ https://stream.example/news.m3u8`);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('requires portal proxy callers to use registered provider targets', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
httpClient,
|
||||
resolveHostname: resolvePublicHost,
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const missingTargetResponse = await fetch(
|
||||
`${baseUrl}/xtream?action=get_account_info`
|
||||
);
|
||||
const unknownTargetResponse = await fetch(
|
||||
`${baseUrl}/xtream?targetId=missing&action=get_account_info`
|
||||
);
|
||||
|
||||
expect(missingTargetResponse.status).toBe(400);
|
||||
await expect(missingTargetResponse.json()).resolves.toEqual({
|
||||
message: 'Missing targetId',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
expect(unknownTargetResponse.status).toBe(404);
|
||||
await expect(unknownTargetResponse.json()).resolves.toEqual({
|
||||
message: 'Provider target not found',
|
||||
status: 404,
|
||||
});
|
||||
expect(httpClient.requests).toEqual([]);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import cors from 'cors';
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import zlib from 'node:zlib';
|
||||
@@ -52,6 +53,8 @@ interface ProviderUrlError {
|
||||
readonly status: number;
|
||||
}
|
||||
|
||||
type ProviderTargetRegistry = Map<string, URL>;
|
||||
|
||||
export function createWebBackendApp(
|
||||
options: WebBackendAppOptions = {}
|
||||
): Express {
|
||||
@@ -68,6 +71,7 @@ export function createWebBackendApp(
|
||||
isPrivateNetworkProxyAllowed(),
|
||||
resolveHostname: options.resolveHostname ?? resolveHostname,
|
||||
};
|
||||
const providerTargets: ProviderTargetRegistry = new Map();
|
||||
|
||||
const corsMiddleware = cors({
|
||||
origin(origin, callback) {
|
||||
@@ -96,12 +100,39 @@ export function createWebBackendApp(
|
||||
);
|
||||
});
|
||||
|
||||
app.options('/provider-targets', corsMiddleware);
|
||||
app.post(
|
||||
'/provider-targets',
|
||||
corsMiddleware,
|
||||
express.json({ limit: '16kb' }),
|
||||
async (req, res) => {
|
||||
const rawUrl =
|
||||
req.body &&
|
||||
typeof req.body === 'object' &&
|
||||
'url' in req.body &&
|
||||
typeof req.body.url === 'string'
|
||||
? req.body.url
|
||||
: undefined;
|
||||
|
||||
if (!rawUrl) {
|
||||
res.status(400).json({ message: 'Missing url', status: 400 });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await validateProviderUrl(rawUrl, providerUrlPolicy);
|
||||
if ('message' in result) {
|
||||
res.status(result.status).json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = createProviderTargetId(result);
|
||||
providerTargets.set(targetId, result);
|
||||
res.json({ targetId });
|
||||
}
|
||||
);
|
||||
|
||||
app.get('/parse', corsMiddleware, async (req, res) => {
|
||||
const url = await getValidatedProviderUrl(
|
||||
req,
|
||||
res,
|
||||
providerUrlPolicy
|
||||
);
|
||||
const url = getRegisteredProviderUrl(req, res, providerTargets);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
@@ -122,11 +153,7 @@ export function createWebBackendApp(
|
||||
});
|
||||
|
||||
app.get('/parse-xml', corsMiddleware, async (req, res) => {
|
||||
const url = await getValidatedProviderUrl(
|
||||
req,
|
||||
res,
|
||||
providerUrlPolicy
|
||||
);
|
||||
const url = getRegisteredProviderUrl(req, res, providerTargets);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
@@ -149,11 +176,7 @@ export function createWebBackendApp(
|
||||
});
|
||||
|
||||
app.get('/xtream', corsMiddleware, async (req, res) => {
|
||||
const url = await getValidatedProviderUrl(
|
||||
req,
|
||||
res,
|
||||
providerUrlPolicy
|
||||
);
|
||||
const url = getRegisteredProviderUrl(req, res, providerTargets);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
@@ -162,7 +185,7 @@ export function createWebBackendApp(
|
||||
const response = await httpClient.get(
|
||||
appendPathSegment(url, 'player_api.php'),
|
||||
{
|
||||
params: getProxyParams(req, ['url']),
|
||||
params: getProxyParams(req, ['targetId']),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -176,11 +199,7 @@ export function createWebBackendApp(
|
||||
});
|
||||
|
||||
app.get('/stalker', corsMiddleware, async (req, res) => {
|
||||
const url = await getValidatedProviderUrl(
|
||||
req,
|
||||
res,
|
||||
providerUrlPolicy
|
||||
);
|
||||
const url = getRegisteredProviderUrl(req, res, providerTargets);
|
||||
const macAddress = getQueryString(req, 'macAddress');
|
||||
const token = getQueryString(req, 'token');
|
||||
if (!url) {
|
||||
@@ -189,7 +208,7 @@ export function createWebBackendApp(
|
||||
|
||||
try {
|
||||
const response = await httpClient.get(url.href, {
|
||||
params: getProxyParams(req, ['url']),
|
||||
params: getProxyParams(req, ['targetId']),
|
||||
headers: {
|
||||
...(macAddress ? { Cookie: `mac=${macAddress}` } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
@@ -208,24 +227,27 @@ export function createWebBackendApp(
|
||||
return app;
|
||||
}
|
||||
|
||||
async function getValidatedProviderUrl(
|
||||
function getRegisteredProviderUrl(
|
||||
req: Request,
|
||||
res: Response,
|
||||
policy: ProviderUrlPolicy
|
||||
): Promise<URL | null> {
|
||||
const rawUrl = getQueryString(req, 'url');
|
||||
if (!rawUrl) {
|
||||
res.status(400).json({ message: 'Missing url', status: 400 });
|
||||
providerTargets: ProviderTargetRegistry
|
||||
): URL | null {
|
||||
const targetId = getQueryString(req, 'targetId');
|
||||
if (!targetId) {
|
||||
res.status(400).json({ message: 'Missing targetId', status: 400 });
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await validateProviderUrl(rawUrl, policy);
|
||||
if ('message' in result) {
|
||||
res.status(result.status).json(result);
|
||||
const targetUrl = providerTargets.get(targetId);
|
||||
if (!targetUrl) {
|
||||
res.status(404).json({
|
||||
message: 'Provider target not found',
|
||||
status: 404,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
async function validateProviderUrl(
|
||||
@@ -299,6 +321,10 @@ async function resolveHostname(hostname: string): Promise<readonly string[]> {
|
||||
return records.map((record) => record.address);
|
||||
}
|
||||
|
||||
function createProviderTargetId(url: URL): string {
|
||||
return createHash('sha256').update(url.href).digest('hex');
|
||||
}
|
||||
|
||||
function isPrivateNetworkProxyAllowed(): boolean {
|
||||
const value = process.env['IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS'];
|
||||
return value === '1' || value === 'true';
|
||||
@@ -398,7 +424,9 @@ async function fetchEpgDataFromUrl(
|
||||
): Promise<unknown> {
|
||||
const href = url.href;
|
||||
const response = await httpClient.get<ArrayBuffer | string>(href, {
|
||||
...(url.pathname.endsWith('.gz') ? { responseType: 'arraybuffer' } : {}),
|
||||
...(url.pathname.endsWith('.gz')
|
||||
? { responseType: 'arraybuffer' }
|
||||
: {}),
|
||||
});
|
||||
const xml = url.pathname.endsWith('.gz')
|
||||
? zlib.gunzipSync(Buffer.from(response.data as ArrayBuffer)).toString()
|
||||
@@ -415,9 +443,9 @@ function isPlaylistParseError(
|
||||
);
|
||||
}
|
||||
|
||||
function parsePlaylist(
|
||||
playlist: string
|
||||
): { items: Array<Record<string, unknown>> } {
|
||||
function parsePlaylist(playlist: string): {
|
||||
items: Array<Record<string, unknown>>;
|
||||
} {
|
||||
return parser.parse(playlist) as unknown as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SwUpdate } from '@angular/service-worker';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { catchError, firstValueFrom, throwError } from 'rxjs';
|
||||
import { catchError, firstValueFrom, from, switchMap, throwError } from 'rxjs';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
ERROR,
|
||||
@@ -49,6 +49,10 @@ interface ErrorStatus {
|
||||
readonly status?: number;
|
||||
}
|
||||
|
||||
interface ProviderTargetRegistration {
|
||||
readonly targetId: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -59,6 +63,7 @@ export class PwaService extends DataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly swUpdate = inject(SwUpdate);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly providerTargetIds = new Map<string, Promise<string>>();
|
||||
private readonly silentXtreamActions = new Set<string>([
|
||||
XtreamCodeActions.GetAccountInfo,
|
||||
XtreamCodeActions.GetLiveCategories,
|
||||
@@ -270,39 +275,49 @@ export class PwaService extends DataService {
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const requestPayload = {
|
||||
method: 'GET',
|
||||
url: `${this.corsProxyUrl}/xtream`,
|
||||
params: {
|
||||
url: payload.url,
|
||||
...payload.params,
|
||||
},
|
||||
...(payload.macAddress
|
||||
? {
|
||||
headers: {
|
||||
Cookie: `mac=${payload.macAddress}`,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const context = createPortalDebugRequestContext({
|
||||
let context = createPortalDebugRequestContext({
|
||||
provider: 'xtream',
|
||||
operation: payload.params?.action ?? 'unknown',
|
||||
transport: 'pwa-http',
|
||||
request: requestPayload,
|
||||
request: {
|
||||
method: 'GET',
|
||||
params: payload.params,
|
||||
url: `${this.corsProxyUrl}/xtream`,
|
||||
},
|
||||
});
|
||||
logPortalDebugRequest(context);
|
||||
|
||||
try {
|
||||
const targetId = await this.getProviderTargetId(payload.url);
|
||||
const requestParams = {
|
||||
targetId,
|
||||
...payload.params,
|
||||
};
|
||||
const requestPayload = {
|
||||
method: 'GET',
|
||||
params: requestParams,
|
||||
url: `${this.corsProxyUrl}/xtream`,
|
||||
...(payload.macAddress
|
||||
? {
|
||||
headers: {
|
||||
Cookie: `mac=${payload.macAddress}`,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
context = createPortalDebugRequestContext({
|
||||
provider: 'xtream',
|
||||
operation: payload.params?.action ?? 'unknown',
|
||||
transport: 'pwa-http',
|
||||
request: requestPayload,
|
||||
});
|
||||
logPortalDebugRequest(context);
|
||||
|
||||
let result: PwaErrorResult | PwaXtreamResult;
|
||||
const response = (await firstValueFrom(
|
||||
this.http.get<PwaXtreamResponse>(
|
||||
`${this.corsProxyUrl}/xtream`,
|
||||
{
|
||||
params: {
|
||||
url: payload.url,
|
||||
...payload.params,
|
||||
},
|
||||
params: requestParams,
|
||||
...headers,
|
||||
}
|
||||
)
|
||||
@@ -442,29 +457,38 @@ export class PwaService extends DataService {
|
||||
params: Record<string, string>;
|
||||
macAddress: string;
|
||||
}) {
|
||||
const params = new URLSearchParams({
|
||||
url: payload.url,
|
||||
...payload.params,
|
||||
macAddress: payload.macAddress,
|
||||
});
|
||||
const requestUrl = `${this.corsProxyUrl}/stalker?${params.toString()}`;
|
||||
const context = createPortalDebugRequestContext({
|
||||
let context = createPortalDebugRequestContext({
|
||||
provider: 'stalker',
|
||||
operation: payload.params?.action ?? 'unknown',
|
||||
transport: 'pwa-http',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: requestUrl,
|
||||
params: {
|
||||
url: payload.url,
|
||||
...payload.params,
|
||||
macAddress: payload.macAddress,
|
||||
},
|
||||
params: payload.params,
|
||||
url: `${this.corsProxyUrl}/stalker`,
|
||||
},
|
||||
});
|
||||
logPortalDebugRequest(context);
|
||||
|
||||
try {
|
||||
const targetId = await this.getProviderTargetId(payload.url);
|
||||
const requestParams = {
|
||||
targetId,
|
||||
...payload.params,
|
||||
macAddress: payload.macAddress,
|
||||
};
|
||||
const params = new URLSearchParams(requestParams);
|
||||
const requestUrl = `${this.corsProxyUrl}/stalker?${params.toString()}`;
|
||||
context = createPortalDebugRequestContext({
|
||||
provider: 'stalker',
|
||||
operation: payload.params?.action ?? 'unknown',
|
||||
transport: 'pwa-http',
|
||||
request: {
|
||||
method: 'GET',
|
||||
params: requestParams,
|
||||
url: requestUrl,
|
||||
},
|
||||
});
|
||||
logPortalDebugRequest(context);
|
||||
|
||||
// Make the fetch request
|
||||
const response = await fetch(requestUrl);
|
||||
|
||||
@@ -497,9 +521,35 @@ export class PwaService extends DataService {
|
||||
}
|
||||
|
||||
getPlaylistFromUrl(url: string) {
|
||||
return this.http.get(`${this.corsProxyUrl}/parse`, {
|
||||
params: { url },
|
||||
});
|
||||
return from(this.getProviderTargetId(url)).pipe(
|
||||
switchMap((targetId) =>
|
||||
this.http.get(`${this.corsProxyUrl}/parse`, {
|
||||
params: { targetId },
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private getProviderTargetId(url: string): Promise<string> {
|
||||
const cachedTargetId = this.providerTargetIds.get(url);
|
||||
if (cachedTargetId) {
|
||||
return cachedTargetId;
|
||||
}
|
||||
|
||||
const targetIdRequest = firstValueFrom(
|
||||
this.http.post<ProviderTargetRegistration>(
|
||||
`${this.corsProxyUrl}/provider-targets`,
|
||||
{ url }
|
||||
)
|
||||
)
|
||||
.then((response) => response.targetId)
|
||||
.catch((error) => {
|
||||
this.providerTargetIds.delete(url);
|
||||
throw error;
|
||||
});
|
||||
|
||||
this.providerTargetIds.set(url, targetIdRequest);
|
||||
return targetIdRequest;
|
||||
}
|
||||
|
||||
removeAllListeners(type: string): void {
|
||||
|
||||
+2
-2
@@ -7,11 +7,11 @@ You can deploy and run the PWA version of IPTVnator on your own machine with `do
|
||||
|
||||
This command will launch the frontend and backend applications. By default, the application will be available at: http://localhost:4333/. The ports can be configured in the `docker-compose.yml` file.
|
||||
|
||||
The web backend proxy accepts only `http` and `https` provider URLs. It blocks loopback, private, link-local, and reserved network targets by default to avoid exposing the self-hosted server as a generic internal-network fetcher. If you intentionally need to test against local mock servers or LAN-only IPTV sources, set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` on the backend container and avoid exposing that instance to untrusted users.
|
||||
The web backend proxy accepts only `http` and `https` provider URLs. The PWA first registers provider URLs through `/provider-targets`, then uses the returned `targetId` for playlist, Xtream, and Stalker proxy calls. The backend blocks loopback, private, link-local, and reserved network targets by default to avoid exposing the self-hosted server as a generic internal-network fetcher. If you intentionally need to test against local mock servers or LAN-only IPTV sources, set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` on the backend container and avoid exposing that instance to untrusted users.
|
||||
|
||||
For providers that use private certificate authorities, keep TLS validation enabled and pass the CA bundle to Node with `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`.
|
||||
|
||||
## Build frontend
|
||||
## Build frontend
|
||||
|
||||
$ docker build -t 4gray/iptvnator -f docker/Dockerfile .
|
||||
|
||||
|
||||
Reference in new issue
Block a user