fix(xtream): address portal review feedback

This commit is contained in:
4gray committed 2026-06-14 13:47:42 +02:00
1 parent 254879f92b
commit 20d0f01428
12 files changed
+295 -51

No files matched your search

+2 -1
View File
@@ -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' } });
+37 -7
View File
@@ -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;
}
// 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.
@@ -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'
@@ -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,13 +203,17 @@ export class XtreamCodeImportComponent {
password: string;
serverUrl: string;
username: string;
} {
return {
password: (this.form.value.password as string).trim(),
serverUrl: normalizeXtreamServerUrl(
this.form.value.serverUrl as string
),
username: (this.form.value.username as string).trim(),
};
} | null {
try {
return {
password: (this.form.value.password as string).trim(),
serverUrl: normalizeXtreamServerUrl(
this.form.value.serverUrl as string
),
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,24 +120,24 @@ 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,
...(serverTimezone
? { serverTimezone }
: {}),
...(allowedOutputFormats?.length
? { allowedOutputFormats }
: {}),
},
});
}
const current = store.currentPlaylist();
if (current) {
patchState(store, {
currentPlaylist: {
...current,
allowedOutputFormats,
...(serverTimezone
? { serverTimezone }
: {}),
},
});
}
return portalStatus;
} catch (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) => {
+16 -6
View File
@@ -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';
}
/**