mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
* fix: redact sensitive log data * fix(ci): keep logging preload self-contained * fix(logging): preserve shared diagnostics * fix(logging): close trace redaction gaps * fix(logging): redact Xtream path credentials * fix(logging): close credential redaction gaps * fix(logging): suppress external player arguments * fix(logging): harden URL and date redaction * fix(logging): redact map keys and URL fragments * fix(logging): redact sensitive map values * fix(logging): redact credentials in diagnostic text * fix(logging): close remaining credential leaks
613 lines
19 KiB
TypeScript
613 lines
19 KiB
TypeScript
import { HttpClient } from '@angular/common/http';
|
|
import { inject, Injectable } from '@angular/core';
|
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
|
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,
|
|
from,
|
|
Observable,
|
|
switchMap,
|
|
throwError,
|
|
} from 'rxjs';
|
|
import { DataService } from '@iptvnator/services';
|
|
import {
|
|
ERROR,
|
|
Playlist,
|
|
PLAYLIST_PARSE_BY_URL,
|
|
PLAYLIST_UPDATE,
|
|
STALKER_REQUEST,
|
|
XtreamCodeActions,
|
|
XTREAM_REQUEST,
|
|
XTREAM_RESPONSE,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import { AppConfig } from '../../environments/environment';
|
|
import {
|
|
createPortalDebugErrorEvent,
|
|
createPortalDebugRequestContext,
|
|
createPortalDebugSuccessEvent,
|
|
logPortalDebugEvent,
|
|
logPortalDebugRequest,
|
|
createLogger,
|
|
} from '@iptvnator/portal/shared/util';
|
|
import { getRuntimeBackendUrl } from './runtime-config';
|
|
|
|
interface PwaXtreamResponse {
|
|
readonly payload?: unknown;
|
|
readonly status?: number;
|
|
}
|
|
|
|
interface PwaXtreamResult {
|
|
readonly action: string;
|
|
readonly payload: unknown;
|
|
readonly type: typeof XTREAM_RESPONSE;
|
|
}
|
|
|
|
interface PwaErrorResult {
|
|
readonly message: string;
|
|
readonly status: number;
|
|
readonly type: typeof ERROR;
|
|
}
|
|
|
|
interface ErrorStatus {
|
|
readonly message?: string;
|
|
readonly status?: number;
|
|
}
|
|
|
|
interface ProviderTargetRegistration {
|
|
readonly targetId: string;
|
|
}
|
|
|
|
@Injectable({
|
|
providedIn: 'root',
|
|
})
|
|
export class PwaService extends DataService {
|
|
private messageListeners = new Map<string, EventListener>();
|
|
private readonly http = inject(HttpClient);
|
|
private readonly snackBar = inject(MatSnackBar);
|
|
private readonly store = inject(Store);
|
|
private readonly swUpdate = inject(SwUpdate);
|
|
private readonly translateService = inject(TranslateService);
|
|
private readonly logger = createLogger('PwaService');
|
|
private readonly providerTargetIds = new Map<string, Promise<string>>();
|
|
private readonly silentXtreamActions = new Set<string>([
|
|
XtreamCodeActions.GetAccountInfo,
|
|
XtreamCodeActions.GetLiveCategories,
|
|
XtreamCodeActions.GetVodCategories,
|
|
XtreamCodeActions.GetSeriesCategories,
|
|
XtreamCodeActions.GetShortEpg,
|
|
XtreamCodeActions.GetSimpleDataTable,
|
|
XtreamCodeActions.GetSimpleDateTable,
|
|
]);
|
|
|
|
/** Proxy URL to avoid CORS issues */
|
|
corsProxyUrl = getRuntimeBackendUrl();
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
/** Uses service worker mechanism to check for available application updates */
|
|
checkUpdates() {
|
|
this.swUpdate.versionUpdates.subscribe(() => {
|
|
this.snackBar
|
|
.open(
|
|
this.translateService.instant('UPDATE_AVAILABLE'),
|
|
this.translateService.instant('REFRESH')
|
|
)
|
|
.onAction()
|
|
.subscribe(() => {
|
|
window.location.reload();
|
|
});
|
|
});
|
|
}
|
|
|
|
getAppVersion(): string {
|
|
return AppConfig.version;
|
|
}
|
|
|
|
/**
|
|
* Handles incoming IPC commands
|
|
* @param type ipc command type
|
|
* @param payload payload
|
|
*/
|
|
sendIpcEvent<T = unknown>(type: string, payload?: unknown): T {
|
|
if (type === PLAYLIST_PARSE_BY_URL) {
|
|
this.fetchFromUrl(payload as Partial<Playlist>);
|
|
return undefined as T;
|
|
}
|
|
|
|
if (type === PLAYLIST_UPDATE) {
|
|
this.refreshPlaylist(payload as Partial<Playlist & { id: string }>);
|
|
return undefined as T;
|
|
}
|
|
|
|
if (type === XTREAM_REQUEST) {
|
|
return this.forwardXtreamRequest(
|
|
payload as { url: string; params: Record<string, string> }
|
|
) as T;
|
|
}
|
|
|
|
if (type === STALKER_REQUEST) {
|
|
return this.forwardStalkerRequest(
|
|
payload as {
|
|
url: string;
|
|
macAddress: string;
|
|
params: Record<string, string>;
|
|
token?: string;
|
|
}
|
|
) as T;
|
|
}
|
|
|
|
return undefined as T;
|
|
}
|
|
|
|
refreshPlaylist(payload?: Partial<Playlist & { id: string }>) {
|
|
if (!payload?.url || !payload?.id) {
|
|
return;
|
|
}
|
|
|
|
const playlistId = payload.id;
|
|
|
|
this.getPlaylistFromUrl(payload.url)
|
|
.pipe(
|
|
catchError((error) => {
|
|
this.snackBar.open(
|
|
this.getPlaylistRefreshErrorMessage(error),
|
|
this.translateService.instant('CLOSE'),
|
|
{
|
|
duration: 5000,
|
|
}
|
|
);
|
|
return throwError(() => error);
|
|
})
|
|
)
|
|
.subscribe((playlist: Playlist) => {
|
|
this.store.dispatch(
|
|
PlaylistActions.updatePlaylist({
|
|
playlist,
|
|
playlistId,
|
|
refreshEpg: true,
|
|
})
|
|
);
|
|
|
|
this.snackBar.open(
|
|
this.translateService.instant(
|
|
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
|
|
),
|
|
undefined,
|
|
{ duration: 2000 }
|
|
);
|
|
});
|
|
}
|
|
|
|
private getPlaylistRefreshErrorMessage(error: unknown): string {
|
|
const statusCode =
|
|
this.getErrorDetails(error)?.status ??
|
|
this.extractHttpStatusCode(error);
|
|
|
|
if (statusCode === 404) {
|
|
return this.translateService.instant('HOME.URL_UPLOAD.ERROR_404');
|
|
}
|
|
if (statusCode === 403) {
|
|
return this.translateService.instant('HOME.URL_UPLOAD.ERROR_403');
|
|
}
|
|
if (statusCode === 401) {
|
|
return this.translateService.instant('HOME.URL_UPLOAD.ERROR_401');
|
|
}
|
|
return this.translateService.instant(
|
|
'HOME.URL_UPLOAD.ERROR_FETCH_FAILED'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Fetches playlist from the specified url
|
|
* @param payload playlist payload
|
|
*/
|
|
fetchFromUrl(payload?: Partial<Playlist>): void {
|
|
if (!payload?.url) {
|
|
return;
|
|
}
|
|
|
|
const title = payload.title?.trim() || undefined;
|
|
|
|
this.getPlaylistFromUrl(payload.url)
|
|
.pipe(
|
|
catchError((error) => {
|
|
this.snackBar.open(
|
|
this.getErrorMessageByStatusCode(
|
|
this.extractHttpStatusCode(error)
|
|
),
|
|
'Close',
|
|
{
|
|
duration: 5000,
|
|
}
|
|
);
|
|
return throwError(() => error);
|
|
})
|
|
)
|
|
.subscribe((response: Playlist) => {
|
|
const playlist = title
|
|
? {
|
|
...response,
|
|
filename: title,
|
|
title,
|
|
}
|
|
: response;
|
|
|
|
this.store.dispatch(
|
|
PlaylistActions.handleAddingPlaylistByUrl({
|
|
isTemporary: !!payload?.isTemporary,
|
|
playlist,
|
|
})
|
|
);
|
|
});
|
|
}
|
|
|
|
getErrorMessageByStatusCode(status: number | null | undefined) {
|
|
let messageKey = 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED';
|
|
switch (status) {
|
|
case 413:
|
|
return 'This file is too big. Use standalone or self-hosted version of the app.';
|
|
case 403:
|
|
messageKey = 'HOME.URL_UPLOAD.ERROR_403';
|
|
break;
|
|
case 404:
|
|
messageKey = 'HOME.URL_UPLOAD.ERROR_404';
|
|
break;
|
|
case 401:
|
|
messageKey = 'HOME.URL_UPLOAD.ERROR_401';
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
return this.translateService.instant(messageKey);
|
|
}
|
|
|
|
private extractHttpStatusCode(error: unknown): number | null {
|
|
if (
|
|
error &&
|
|
typeof error === 'object' &&
|
|
'status' in error &&
|
|
typeof error.status === 'number'
|
|
) {
|
|
return error.status;
|
|
}
|
|
|
|
const msg = String((error as { message?: string })?.message ?? error);
|
|
const match = msg.match(/status code (\d{3})/);
|
|
return match ? parseInt(match[1], 10) : null;
|
|
}
|
|
|
|
async forwardXtreamRequest(payload: {
|
|
url: string;
|
|
params: Record<string, string>;
|
|
macAddress?: string;
|
|
requestId?: string;
|
|
sessionId?: string;
|
|
suppressErrorLog?: boolean;
|
|
}) {
|
|
const headers = payload.macAddress
|
|
? {
|
|
headers: {
|
|
Cookie: `mac=${payload.macAddress}`,
|
|
},
|
|
}
|
|
: {};
|
|
let context = createPortalDebugRequestContext({
|
|
provider: 'xtream',
|
|
operation: payload.params?.action ?? 'unknown',
|
|
transport: 'pwa-http',
|
|
request: {
|
|
method: 'GET',
|
|
params: payload.params,
|
|
url: `${this.corsProxyUrl}/xtream`,
|
|
},
|
|
});
|
|
|
|
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: requestParams,
|
|
...headers,
|
|
}
|
|
)
|
|
)) as PwaXtreamResponse;
|
|
|
|
if (!response.payload) {
|
|
const action = payload.params.action;
|
|
const isSilentAction =
|
|
payload.suppressErrorLog === true ||
|
|
this.silentXtreamActions.has(action);
|
|
const normalizedMessage =
|
|
this.getReadableXtreamErrorMessage(response);
|
|
logPortalDebugEvent(
|
|
createPortalDebugErrorEvent(context, response)
|
|
);
|
|
|
|
if (isSilentAction) {
|
|
this.logger.debug(
|
|
`Background Xtream action failed (${action ?? 'unknown'}):`,
|
|
normalizedMessage
|
|
);
|
|
return {
|
|
type: ERROR,
|
|
status: response.status ?? 500,
|
|
message: normalizedMessage,
|
|
};
|
|
}
|
|
|
|
result = {
|
|
type: ERROR,
|
|
status: response.status ?? 500,
|
|
message: normalizedMessage,
|
|
};
|
|
window.postMessage(result);
|
|
} else {
|
|
result = {
|
|
type: XTREAM_RESPONSE,
|
|
payload: response.payload,
|
|
action: payload.params.action,
|
|
};
|
|
logPortalDebugEvent(
|
|
createPortalDebugSuccessEvent(context, response)
|
|
);
|
|
window.postMessage(result);
|
|
}
|
|
return result;
|
|
} catch (error: unknown) {
|
|
logPortalDebugEvent(createPortalDebugErrorEvent(context, error));
|
|
const action = payload.params.action;
|
|
const isSilentAction =
|
|
payload.suppressErrorLog === true ||
|
|
this.silentXtreamActions.has(action);
|
|
const normalizedMessage = this.getReadableXtreamErrorMessage(error);
|
|
const errorInfo = this.getErrorDetails(error);
|
|
|
|
// Log error to console
|
|
if (isSilentAction) {
|
|
this.logger.debug(
|
|
`Background Xtream action failed (${action ?? 'unknown'}):`,
|
|
normalizedMessage
|
|
);
|
|
return {
|
|
type: ERROR,
|
|
status: errorInfo?.status ?? 500,
|
|
message: normalizedMessage,
|
|
};
|
|
}
|
|
|
|
this.logger.error('Xtream request error:', normalizedMessage);
|
|
this.snackBar.open(
|
|
`Xtream request failed: ${normalizedMessage}`,
|
|
'Close',
|
|
{
|
|
duration: 5000,
|
|
}
|
|
);
|
|
return {
|
|
type: ERROR,
|
|
status: errorInfo?.status ?? 500,
|
|
message: normalizedMessage,
|
|
};
|
|
}
|
|
}
|
|
|
|
private getReadableXtreamErrorMessage(error: unknown): string {
|
|
const fallback = 'Failed to connect to Xtream server';
|
|
if (!error) {
|
|
return fallback;
|
|
}
|
|
|
|
const maybeError = error as {
|
|
message?: unknown;
|
|
statusText?: unknown;
|
|
error?: unknown;
|
|
};
|
|
|
|
if (typeof maybeError.message === 'string') {
|
|
if (maybeError.message.includes('[object Object]')) {
|
|
if (typeof maybeError.error === 'string') {
|
|
return maybeError.error;
|
|
}
|
|
if (
|
|
maybeError.error &&
|
|
typeof maybeError.error === 'object' &&
|
|
'message' in
|
|
(maybeError.error as Record<string, unknown>) &&
|
|
typeof (maybeError.error as Record<string, unknown>)
|
|
.message === 'string'
|
|
) {
|
|
return (maybeError.error as Record<string, string>).message;
|
|
}
|
|
return fallback;
|
|
}
|
|
return maybeError.message;
|
|
}
|
|
|
|
if (typeof maybeError.statusText === 'string') {
|
|
return maybeError.statusText;
|
|
}
|
|
|
|
if (typeof error === 'string') {
|
|
return error;
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
private getErrorDetails(error: unknown): ErrorStatus | null {
|
|
if (error && typeof error === 'object') {
|
|
return error as ErrorStatus;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async forwardStalkerRequest(payload: {
|
|
url: string;
|
|
params: Record<string, string>;
|
|
macAddress: string;
|
|
token?: string;
|
|
}) {
|
|
let context = createPortalDebugRequestContext({
|
|
provider: 'stalker',
|
|
operation: payload.params?.action ?? 'unknown',
|
|
transport: 'pwa-http',
|
|
request: {
|
|
method: 'GET',
|
|
params: payload.params,
|
|
url: `${this.corsProxyUrl}/stalker`,
|
|
},
|
|
});
|
|
|
|
try {
|
|
const targetId = await this.getProviderTargetId(payload.url);
|
|
const token = payload.token ?? payload.params.token;
|
|
const requestParams = {
|
|
targetId,
|
|
...payload.params,
|
|
macAddress: payload.macAddress,
|
|
...(token ? { token } : {}),
|
|
};
|
|
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);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Error: ${response.statusText} (Status: ${response.status})`
|
|
);
|
|
}
|
|
|
|
// Parse and return the JSON response
|
|
const responseBody = await response.json();
|
|
logPortalDebugEvent(
|
|
createPortalDebugSuccessEvent(context, responseBody)
|
|
);
|
|
return responseBody.payload;
|
|
} catch (err: unknown) {
|
|
const errorInfo = this.getErrorDetails(err);
|
|
logPortalDebugEvent(createPortalDebugErrorEvent(context, err));
|
|
this.logger.error('Stalker request error:', err);
|
|
|
|
this.snackBar.open(
|
|
`Error: ${errorInfo?.message ?? ' Not found'}, status: ${errorInfo?.status ?? 404}`,
|
|
'Close',
|
|
{
|
|
duration: 5000,
|
|
}
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
getPlaylistFromUrl(url: string): Observable<Playlist> {
|
|
return from(this.getProviderTargetId(url)).pipe(
|
|
switchMap((targetId) =>
|
|
this.http.get<Playlist>(`${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 {
|
|
if (type === 'all') {
|
|
this.messageListeners.forEach((listener) =>
|
|
window.removeEventListener('message', listener)
|
|
);
|
|
this.messageListeners.clear();
|
|
return;
|
|
}
|
|
|
|
const messageListener = this.messageListeners.get(type);
|
|
if (messageListener) {
|
|
window.removeEventListener('message', messageListener);
|
|
this.messageListeners.delete(type);
|
|
}
|
|
}
|
|
|
|
listenOn(command: string, callback: (...args: unknown[]) => void): void {
|
|
// Drop any existing listener for this command so calling listenOn()
|
|
// again rebinds rather than accumulating duplicates.
|
|
const existing = this.messageListeners.get(command);
|
|
if (existing) {
|
|
window.removeEventListener('message', existing);
|
|
}
|
|
|
|
const listener = callback as EventListener;
|
|
window.addEventListener('message', listener);
|
|
this.messageListeners.set(command, listener);
|
|
}
|
|
|
|
getAppEnvironment(): string {
|
|
return 'pwa';
|
|
}
|
|
}
|