Files
iptvnator/apps/web/src/app/services/pwa.service.ts
T
4gray 5cae310430 fix(logging): redact sensitive portal and Electron diagnostics (#1182)
* 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
2026-07-19 08:30:21 +02:00

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';
}
}