mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(xtream): address portal review feedback
This commit is contained in:
1 parent
254879f92b
commit
20d0f01428
12 files changed
+279
-35
No files matched your search
@@ -31,7 +31,8 @@
|
||||
"PORT": "3333",
|
||||
"CLIENT_URL": "http://localhost:4200",
|
||||
"BACKEND_URL": "/api",
|
||||
"IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS": "1"
|
||||
"IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS": "1",
|
||||
"TSX_TSCONFIG_PATH": "tsconfig.base.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -467,6 +467,36 @@ https://stream.example/news.m3u8`);
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects Xtream proxy calls when a registered target no longer passes the URL policy', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
const resolvedAddresses = [['93.184.216.34'], ['127.0.0.1']];
|
||||
|
||||
await withServer(
|
||||
createWebBackendApp({
|
||||
httpClient,
|
||||
resolveHostname: async () =>
|
||||
resolvedAddresses.shift() ?? ['127.0.0.1'],
|
||||
}),
|
||||
async (baseUrl) => {
|
||||
const targetId = await registerProviderTarget(
|
||||
baseUrl,
|
||||
'http://xtream.example'
|
||||
);
|
||||
const response = await fetch(
|
||||
`${baseUrl}/xtream?targetId=${targetId}&action=get_account_info`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
message:
|
||||
'Provider URL points to a private or local network address',
|
||||
status: 400,
|
||||
});
|
||||
expect(httpClient.requests).toEqual([]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('allows private target URLs when explicitly enabled for local self-hosted testing', async () => {
|
||||
const httpClient = new StubHttpClient();
|
||||
httpClient.queueResponse({ user_info: { username: 'demo' } });
|
||||
|
||||
@@ -177,16 +177,30 @@ export function createWebBackendApp(
|
||||
});
|
||||
|
||||
app.get('/xtream', corsMiddleware, async (req, res) => {
|
||||
const url = getRegisteredProviderUrl(req, res, providerTargets);
|
||||
if (!url) {
|
||||
const registeredUrl = getRegisteredProviderUrl(
|
||||
req,
|
||||
res,
|
||||
providerTargets
|
||||
);
|
||||
if (!registeredUrl) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(registeredUrl.href);
|
||||
|
||||
try {
|
||||
const providerUrlError = await normalizeAndValidateXtreamProviderUrl(
|
||||
url,
|
||||
providerUrlPolicy
|
||||
);
|
||||
if (providerUrlError) {
|
||||
res.status(providerUrlError.status).json(providerUrlError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Provider URLs are validated by /provider-targets before they enter the registry.
|
||||
// codeql[js/request-forgery]
|
||||
const response = await httpClient.get(
|
||||
buildXtreamPlayerApiUrl(url),
|
||||
appendPathSegment(url, 'player_api.php'),
|
||||
{
|
||||
params: getProxyParams(req, ['targetId']),
|
||||
}
|
||||
@@ -395,11 +409,27 @@ function appendPathSegment(url: URL, segment: string): string {
|
||||
return nextUrl.href;
|
||||
}
|
||||
|
||||
function buildXtreamPlayerApiUrl(url: URL): string {
|
||||
return appendPathSegment(
|
||||
new URL(normalizeXtreamServerUrl(url.href)),
|
||||
'player_api.php'
|
||||
async function normalizeAndValidateXtreamProviderUrl(
|
||||
url: URL,
|
||||
policy: ProviderUrlPolicy
|
||||
): Promise<ProviderUrlError | null> {
|
||||
let normalizedUrl: URL;
|
||||
try {
|
||||
normalizedUrl = new URL(normalizeXtreamServerUrl(url.href));
|
||||
} catch {
|
||||
return { message: 'Provider URL is not a valid URL', status: 400 };
|
||||
}
|
||||
|
||||
const validatedUrl = await validateProviderUrl(
|
||||
appendPathSegment(normalizedUrl, 'player_api.php'),
|
||||
policy
|
||||
);
|
||||
if ('message' in validatedUrl) {
|
||||
return validatedUrl;
|
||||
}
|
||||
|
||||
url.href = normalizedUrl.href;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handlePlaylistParse(options: {
|
||||
|
||||
@@ -57,9 +57,18 @@ Electron IPC and the PWA backend both construct API requests by appending
|
||||
|
||||
Credentials sent to the API are trimmed before serialization.
|
||||
|
||||
The PWA backend only proxies Xtream requests through registered provider
|
||||
targets. Those targets are validated when registered and revalidated before the
|
||||
`/xtream` proxy request, including protocol, URL credentials, DNS resolution,
|
||||
and private-network checks.
|
||||
|
||||
## Playback URL Formats
|
||||
|
||||
When account info includes `user_info.allowed_output_formats`, the current
|
||||
Xtream playlist keeps those formats for the active session. Live stream URL
|
||||
construction falls back to the first provider-allowed format when the selected
|
||||
application format is not allowed by the portal.
|
||||
|
||||
If stored Xtream playback credentials contain an invalid server URL or blank
|
||||
username/password, stream URL construction returns an empty URL instead of
|
||||
throwing during playback.
|
||||
+18
@@ -40,6 +40,24 @@ describe('XtreamCodeImportComponent', () => {
|
||||
expect(component.form.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects URLs with inline credentials before add or test actions', async () => {
|
||||
component.form.patchValue({
|
||||
title: 'Portal',
|
||||
serverUrl: 'https://user:pass@example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
|
||||
expect(component.form.valid).toBe(false);
|
||||
|
||||
await component.testConnection();
|
||||
component.addPlaylist();
|
||||
|
||||
expect(component.isTestingConnection).toBe(false);
|
||||
expect(portalStatusService.checkPortalStatus).not.toHaveBeenCalled();
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts and trims username and password from a full Xtream URL', () => {
|
||||
component.extractParams(
|
||||
'https://example.com/get.php?username=%20user%20&password=%20pass%20&type=m3u_plus'
|
||||
|
||||
+33
-2
@@ -1,9 +1,11 @@
|
||||
import { Component, EventEmitter, Output, inject } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
@@ -20,6 +22,22 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
function xtreamServerUrlValidator(
|
||||
control: AbstractControl
|
||||
): ValidationErrors | null {
|
||||
const value = control.value;
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
normalizeXtreamServerUrl(value);
|
||||
return null;
|
||||
} catch {
|
||||
return { xtreamServerUrl: true };
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
FormsModule,
|
||||
@@ -80,6 +98,7 @@ export class XtreamCodeImportComponent {
|
||||
serverUrl: new FormControl('', [
|
||||
Validators.required,
|
||||
Validators.pattern(this.URL_REGEX),
|
||||
xtreamServerUrlValidator,
|
||||
]),
|
||||
importDate: new FormControl(new Date().toISOString()),
|
||||
});
|
||||
@@ -93,9 +112,13 @@ export class XtreamCodeImportComponent {
|
||||
async testConnection(): Promise<void> {
|
||||
if (!this.form.valid) return;
|
||||
|
||||
this.isTestingConnection = true;
|
||||
const connection = this.getNormalizedConnection();
|
||||
if (!connection) {
|
||||
this.connectionStatus = 'unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
this.isTestingConnection = true;
|
||||
try {
|
||||
// User-initiated connection test — bypass the shared cache so the
|
||||
// result reflects the portal's current state, not whatever was
|
||||
@@ -140,6 +163,10 @@ export class XtreamCodeImportComponent {
|
||||
if (!this.form.valid) return;
|
||||
|
||||
const connection = this.getNormalizedConnection();
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.addPlaylist({
|
||||
playlist: {
|
||||
@@ -176,7 +203,8 @@ export class XtreamCodeImportComponent {
|
||||
password: string;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
} {
|
||||
} | null {
|
||||
try {
|
||||
return {
|
||||
password: (this.form.value.password as string).trim(),
|
||||
serverUrl: normalizeXtreamServerUrl(
|
||||
@@ -184,5 +212,8 @@ export class XtreamCodeImportComponent {
|
||||
),
|
||||
username: (this.form.value.username as string).trim(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { DatabaseService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
XtreamSerieEpisode,
|
||||
XtreamVodDetails,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { XtreamCredentials } from './xtream-api.service';
|
||||
import { XtreamUrlService } from './xtream-url.service';
|
||||
|
||||
@@ -69,6 +73,41 @@ describe('XtreamUrlService', () => {
|
||||
expect(url).toBe('http://demo.example/live/demo/secret/101.m3u8');
|
||||
});
|
||||
|
||||
it('returns empty stream URLs instead of throwing for invalid stored server URLs', () => {
|
||||
const invalidCredentials: XtreamCredentials = {
|
||||
...credentials,
|
||||
serverUrl: 'https://demo:secret@demo.example',
|
||||
};
|
||||
const vodItem: XtreamVodDetails = {
|
||||
movie_data: {
|
||||
added: '',
|
||||
category_id: '',
|
||||
container_extension: 'mp4',
|
||||
custom_sid: null,
|
||||
direct_source: '',
|
||||
name: 'Movie',
|
||||
stream_id: 101,
|
||||
},
|
||||
};
|
||||
const episode: XtreamSerieEpisode = {
|
||||
added: '',
|
||||
container_extension: 'mp4',
|
||||
custom_sid: '',
|
||||
direct_source: '',
|
||||
episode_num: 1,
|
||||
id: '202',
|
||||
info: [],
|
||||
season: 1,
|
||||
title: 'Episode',
|
||||
};
|
||||
|
||||
expect(service.constructLiveUrl(invalidCredentials, 101)).toBe('');
|
||||
expect(service.constructVodUrl(invalidCredentials, vodItem)).toBe('');
|
||||
expect(service.constructEpisodeUrl(invalidCredentials, episode)).toBe(
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
it('detects the legacy catchup scheme once and then uses the cached result', async () => {
|
||||
const xtreamProbeUrl = jest
|
||||
.fn()
|
||||
|
||||
@@ -33,6 +33,14 @@ type XtreamVodStreamLike = XtreamVodDetails & {
|
||||
|
||||
type XtreamCatchupScheme = 'rest' | 'legacy';
|
||||
|
||||
interface NormalizedXtreamCredentials {
|
||||
password: string;
|
||||
rawPassword: string;
|
||||
rawUsername: string;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
type XtreamProbeApi = {
|
||||
xtreamProbeUrl?: (
|
||||
url: string,
|
||||
@@ -69,6 +77,10 @@ export class XtreamUrlService {
|
||||
format?: string
|
||||
): string {
|
||||
const normalizedCredentials = this.normalizeCredentials(credentials);
|
||||
if (!normalizedCredentials) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const streamFormat = this.resolveLiveStreamFormat(
|
||||
credentials,
|
||||
format ?? this.settingsStore.streamFormat() ?? 'ts'
|
||||
@@ -91,6 +103,10 @@ export class XtreamUrlService {
|
||||
return '';
|
||||
}
|
||||
const normalizedCredentials = this.normalizeCredentials(credentials);
|
||||
if (!normalizedCredentials) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${normalizedCredentials.serverUrl}/movie/${normalizedCredentials.username}/${normalizedCredentials.password}/${streamId}.${extension}`;
|
||||
}
|
||||
|
||||
@@ -103,6 +119,10 @@ export class XtreamUrlService {
|
||||
episode: XtreamSerieEpisode
|
||||
): string {
|
||||
const normalizedCredentials = this.normalizeCredentials(credentials);
|
||||
if (!normalizedCredentials) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${normalizedCredentials.serverUrl}/series/${normalizedCredentials.username}/${normalizedCredentials.password}/${episode.id}.${episode.container_extension}`;
|
||||
}
|
||||
|
||||
@@ -114,6 +134,11 @@ export class XtreamUrlService {
|
||||
scheme: XtreamCatchupScheme,
|
||||
serverTimezone?: string
|
||||
): string {
|
||||
const normalizedCredentials = this.normalizeCredentials(credentials);
|
||||
if (!normalizedCredentials) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const durationMinutes = Math.max(
|
||||
1,
|
||||
Math.round((stopTimestamp - startTimestamp) / 60)
|
||||
@@ -124,8 +149,6 @@ export class XtreamUrlService {
|
||||
);
|
||||
|
||||
if (scheme === 'legacy') {
|
||||
const normalizedCredentials =
|
||||
this.normalizeCredentials(credentials);
|
||||
const params = new URLSearchParams({
|
||||
username: normalizedCredentials.rawUsername,
|
||||
password: normalizedCredentials.rawPassword,
|
||||
@@ -136,7 +159,6 @@ export class XtreamUrlService {
|
||||
return `${normalizedCredentials.serverUrl}/streaming/timeshift.php?${params.toString()}`;
|
||||
}
|
||||
|
||||
const normalizedCredentials = this.normalizeCredentials(credentials);
|
||||
return `${normalizedCredentials.serverUrl}/timeshift/${normalizedCredentials.username}/${normalizedCredentials.password}/${durationMinutes}/${timeString}/${streamId}.ts`;
|
||||
}
|
||||
|
||||
@@ -232,6 +254,10 @@ export class XtreamUrlService {
|
||||
serverTimezone
|
||||
);
|
||||
|
||||
if (!restUrl || !legacyUrl) {
|
||||
return 'rest';
|
||||
}
|
||||
|
||||
const restStatus = await this.probeCatchupUrl(restUrl);
|
||||
let detectedScheme: XtreamCatchupScheme;
|
||||
|
||||
@@ -276,21 +302,27 @@ export class XtreamUrlService {
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeCredentials(credentials: XtreamCredentials): {
|
||||
password: string;
|
||||
rawPassword: string;
|
||||
rawUsername: string;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
} {
|
||||
private normalizeCredentials(
|
||||
credentials: XtreamCredentials
|
||||
): NormalizedXtreamCredentials | null {
|
||||
const rawUsername = credentials.username.trim();
|
||||
const rawPassword = credentials.password.trim();
|
||||
if (!rawUsername || !rawPassword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let serverUrl: string;
|
||||
try {
|
||||
serverUrl = normalizeXtreamServerUrl(credentials.serverUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
password: encodeURIComponent(rawPassword),
|
||||
rawPassword,
|
||||
rawUsername,
|
||||
serverUrl: normalizeXtreamServerUrl(credentials.serverUrl),
|
||||
serverUrl,
|
||||
username: encodeURIComponent(rawUsername),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,4 +82,22 @@ describe('withPortal', () => {
|
||||
|
||||
expect(store.currentPlaylist()?.allowedOutputFormats).toEqual(['m3u8']);
|
||||
});
|
||||
|
||||
it('clears stale allowed output formats when account info omits them', async () => {
|
||||
store.setCurrentPlaylist({
|
||||
...PLAYLIST,
|
||||
allowedOutputFormats: ['m3u8'],
|
||||
});
|
||||
apiService.getAccountInfo.mockResolvedValue({
|
||||
user_info: {
|
||||
auth: 1,
|
||||
exp_date: '0',
|
||||
status: 'Active',
|
||||
},
|
||||
});
|
||||
|
||||
await store.checkPortalStatus();
|
||||
|
||||
expect(store.currentPlaylist()?.allowedOutputFormats).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -120,25 +120,25 @@ export function withPortal() {
|
||||
resolveXtreamPortalStatus(response);
|
||||
const serverTimezone =
|
||||
response?.server_info?.timezone ?? undefined;
|
||||
const allowedOutputFormats =
|
||||
response?.user_info?.allowed_output_formats;
|
||||
const allowedOutputFormats = response?.user_info
|
||||
?.allowed_output_formats?.length
|
||||
? response.user_info.allowed_output_formats
|
||||
.map((format) => format.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
patchState(store, { portalStatus });
|
||||
if (serverTimezone || allowedOutputFormats?.length) {
|
||||
const current = store.currentPlaylist();
|
||||
if (current) {
|
||||
patchState(store, {
|
||||
currentPlaylist: {
|
||||
...current,
|
||||
allowedOutputFormats,
|
||||
...(serverTimezone
|
||||
? { serverTimezone }
|
||||
: {}),
|
||||
...(allowedOutputFormats?.length
|
||||
? { allowedOutputFormats }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return portalStatus;
|
||||
} catch (error) {
|
||||
logger.error('Error checking portal status', error);
|
||||
|
||||
@@ -110,6 +110,32 @@ describe('PortalStatusService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reads cached status with the same normalized connection key used by checks', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
user_info: {
|
||||
auth: 1,
|
||||
exp_date: '0',
|
||||
status: 'Active',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await service.checkPortalStatus(
|
||||
' https://example.com/get.php?username=old&password=old&type=m3u_plus ',
|
||||
' user ',
|
||||
' pass '
|
||||
);
|
||||
|
||||
expect(
|
||||
service.getCachedStatus(
|
||||
' https://example.com/get.php?username=old&password=old&type=m3u_plus ',
|
||||
' user ',
|
||||
' pass '
|
||||
)
|
||||
).toBe('active');
|
||||
});
|
||||
|
||||
it('falls back to alternate account actions when get_account_info does not return user info', async () => {
|
||||
dataService.sendIpcEvent.mockImplementation(
|
||||
async (_type: string, payload: unknown) => {
|
||||
|
||||
@@ -137,8 +137,21 @@ export class PortalStatusService {
|
||||
username: string,
|
||||
password: string
|
||||
): PortalStatus | null {
|
||||
const connection = this.normalizeConnection(
|
||||
serverUrl,
|
||||
username,
|
||||
password
|
||||
);
|
||||
if (!connection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = this.cache.get(
|
||||
this.buildCacheKey(serverUrl, username, password)
|
||||
this.buildCacheKey(
|
||||
connection.serverUrl,
|
||||
connection.username,
|
||||
connection.password
|
||||
)
|
||||
);
|
||||
if (!cached) {
|
||||
return null;
|
||||
@@ -193,8 +206,6 @@ export class PortalStatusService {
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<PortalStatus> {
|
||||
let fallbackStatus: PortalStatus = 'unavailable';
|
||||
|
||||
for (const action of XTREAM_STATUS_ACTIONS) {
|
||||
try {
|
||||
const response =
|
||||
@@ -214,13 +225,12 @@ export class PortalStatusService {
|
||||
if (status !== 'unavailable') {
|
||||
return status;
|
||||
}
|
||||
fallbackStatus = status;
|
||||
} catch {
|
||||
fallbackStatus = 'unavailable';
|
||||
// Try the next Xtream account-info action variant.
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackStatus;
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user