From 7d1265d566eee7ad5fcf383d86858b6212cc07c8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:30:22 +0200 Subject: [PATCH] fix(xtream): detect HTTP portals during explicit connection tests (#1588) --- .changes/xtream-http-connection-test.md | 6 + AGENTS.md | 12 + CLAUDE.md | 12 + apps/electron-backend-e2e/src/sources.e2e.ts | 172 +++++++- .../src/xtream-vod-details.e2e.ts | 20 + .../src/app/api/main.preload.ts | 1 + .../src/app/events/xtream.events.spec.ts | 112 +++++ .../src/app/events/xtream.events.ts | 33 +- .../src/app/util/validated-axios.ts | 4 +- .../src/app/web-backend-app.redirects.spec.ts | 91 ++++ apps/web-backend/src/app/web-backend-app.ts | 18 +- apps/web-e2e/src/xtream-connection.e2e.ts | 81 ++++ .../src/app/services/electron.service.spec.ts | 20 + apps/web/src/app/services/electron.service.ts | 3 + apps/web/src/app/services/pwa.service.spec.ts | 21 + apps/web/src/app/services/pwa.service.ts | 4 + apps/web/src/assets/i18n/ar.json | 17 +- apps/web/src/assets/i18n/ary.json | 17 +- apps/web/src/assets/i18n/by.json | 17 +- apps/web/src/assets/i18n/de.json | 17 +- apps/web/src/assets/i18n/el.json | 17 +- apps/web/src/assets/i18n/en.json | 17 +- apps/web/src/assets/i18n/es.json | 17 +- apps/web/src/assets/i18n/fr.json | 17 +- apps/web/src/assets/i18n/hu.json | 17 +- apps/web/src/assets/i18n/it.json | 17 +- apps/web/src/assets/i18n/ja.json | 17 +- apps/web/src/assets/i18n/ko.json | 17 +- apps/web/src/assets/i18n/nl.json | 17 +- apps/web/src/assets/i18n/pl.json | 17 +- apps/web/src/assets/i18n/pt.json | 17 +- apps/web/src/assets/i18n/ru.json | 17 +- apps/web/src/assets/i18n/tr.json | 17 +- apps/web/src/assets/i18n/zh.json | 17 +- apps/web/src/assets/i18n/zhtw.json | 17 +- .../xtream-portal-compatibility.md | 51 ++- .../add-playlist-dialog.component.html | 23 +- .../xtream-code-import.component.html | 17 +- .../xtream-code-import.component.spec.ts | 122 +++++- .../xtream-code-import.component.ts | 73 +--- .../playlist-info.component.html | 31 ++ .../playlist-info/playlist-info.component.ts | 16 +- libs/services/src/index.ts | 3 + .../services/src/lib/portal-status.service.ts | 59 ++- .../lib/xtream-connection-test-state.spec.ts | 94 ++++ .../src/lib/xtream-connection-test-state.ts | 86 ++++ .../xtream-connection-test.service.spec.ts | 400 ++++++++++++++++++ .../src/lib/xtream-connection-test.service.ts | 129 ++++++ libs/shared/interfaces/src/index.ts | 2 + .../src/lib/electron-api.interface.ts | 3 + .../src/lib/xtream-connection-test.spec.ts | 178 ++++++++ .../src/lib/xtream-connection-test.ts | 103 +++++ 52 files changed, 2185 insertions(+), 138 deletions(-) create mode 100644 .changes/xtream-http-connection-test.md create mode 100644 apps/web-e2e/src/xtream-connection.e2e.ts create mode 100644 libs/services/src/lib/xtream-connection-test-state.spec.ts create mode 100644 libs/services/src/lib/xtream-connection-test-state.ts create mode 100644 libs/services/src/lib/xtream-connection-test.service.spec.ts create mode 100644 libs/services/src/lib/xtream-connection-test.service.ts create mode 100644 libs/shared/interfaces/src/lib/xtream-connection-test.spec.ts create mode 100644 libs/shared/interfaces/src/lib/xtream-connection-test.ts diff --git a/.changes/xtream-http-connection-test.md b/.changes/xtream-http-connection-test.md new file mode 100644 index 000000000..ddb44bc45 --- /dev/null +++ b/.changes/xtream-http-connection-test.md @@ -0,0 +1,6 @@ +--- +type: fix +area: xtream +--- + +The explicit HTTPS and HTTP connection test now detects Xtream portals that accept HTTP when HTTPS is unavailable and fills in the working address before you save. The saved address is used for catalog updates, provider EPG and playback. Connection failures now show a more specific explanation. diff --git a/AGENTS.md b/AGENTS.md index 82d8e4ef7..eaf34a452 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -341,6 +341,18 @@ there is no browse return action. Contracts: `docs/architecture/iptvnator-ui-guidelines.md` and `docs/architecture/portal-detail-navigation.md`. +## Xtream Connection Test + +Add/Edit source Test HTTPS and HTTP discloses plaintext credential use before +the click and can replace an unavailable HTTPS base with a +verified active HTTP base in the form. Only initial refused-port or TLS +wrong-version evidence permits the same-host attempt; HTTP errors, certificate +failures and redirect failures do not. Add/Save persists `serverUrl`, and the +routed session observes the metadata change. Passive checks never change the +protocol. Separate XMLTV and already-issued media/download URLs stay independent. +Contract: `docs/architecture/xtream-portal-compatibility.md` +("Explicit protocol discovery"). + ## Xtream Live Auto Format The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit diff --git a/CLAUDE.md b/CLAUDE.md index 5de0694cc..3cb2744a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -809,6 +809,18 @@ categories by provider ID and type. See `docs/architecture/category-management.m ### Key Features +#### Xtream Connection Test + +Add/Edit source Test HTTPS and HTTP discloses plaintext credential use before +the click and can replace an unavailable HTTPS base with a +verified active HTTP base in the form. Only initial refused-port or TLS +wrong-version evidence permits the same-host attempt; HTTP errors, certificate +failures and redirect failures do not. Add/Save persists `serverUrl`, and the +routed session observes the metadata change. Passive checks never change the +protocol. Separate XMLTV and already-issued media/download URLs stay independent. +Contract: `docs/architecture/xtream-portal-compatibility.md` +("Explicit protocol discovery"). + #### Xtream Live Auto Format The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index 7c198e900..9ff60b33b 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -15,6 +15,7 @@ import { importM3uPlaylistFromUrl, launchElectronApp, openSources, + openAddPlaylistDialog, openSourceEditor, refreshSource, resetMockServers, @@ -32,6 +33,13 @@ import { writeTemporaryM3uFile, } from './electron-test-fixtures'; +import { + configureLiveFormat, + expectLiveFormatPlaying, + liveChannels, + liveFormatMock, +} from './xtream-live-format.fixture'; + const localSourceFileName = 'alpha-local-source.m3u'; const localSourceDisplayName = 'alpha-local-source'; const urlSourceFileName = 'omega-url-source.m3u'; @@ -42,6 +50,160 @@ const deletableLocalSourceDisplayName = 'deletable-local-source'; const refreshLocalSourceDisplayName = 'refresh-local-source'; test.describe('Electron Sources View', () => { + test('detects HTTP on Test connection and persists it for refresh, EPG and playback', async ({ + dataDir, + request, + }) => { + test.setTimeout(150_000); + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir, { + args: ['--autoplay-policy=no-user-gesture-required'], + }); + const title = 'HTTP protocol discovery'; + const httpsUrl = liveFormatMock.replace('http:', 'https:'); + try { + let page = app.mainWindow; + await configureLiveFormat(page, 'html5', 'ts'); + await openAddPlaylistDialog(page); + let dialog = page.getByRole('dialog'); + await dialog + .getByRole('radio', { name: /Xtream credentials/i }) + .click(); + await dialog.locator('#title').fill(title); + await dialog.locator('#serverUrl').fill(httpsUrl); + await dialog.locator('#username').fill('live-fallback'); + await dialog.locator('#password').fill('live-fallback'); + await dialog + .getByRole('button', { + name: 'Test HTTPS and HTTP', + exact: true, + }) + .click(); + await expect(dialog.getByRole('status')).toContainText( + 'Connected using HTTP' + ); + await expect(dialog.locator('#serverUrl')).toHaveValue( + liveFormatMock + ); + await dialog.screenshot({ + path: test.info().outputPath('http-connection-add.png'), + }); + await dialog + .getByRole('button', { name: 'Add', exact: true }) + .click(); + await page.waitForURL(/xtreams.*vod/); + await openSources(page); + dialog = await openSourceEditor(page, title); + await dialog.locator('[formControlName="password"]').fill(''); + await dialog + .getByRole('button', { + name: 'Test HTTPS and HTTP', + exact: true, + }) + .click(); + await expect(dialog.getByRole('status')).toContainText( + 'Enter a username and password' + ); + await dialog + .locator('[formControlName="password"]') + .fill('live-fallback'); + // Closing a tested edit must not persist unrelated changes. + await updateSourceDialog(dialog, { + title: 'Discard this edit', + serverUrl: httpsUrl, + }); + await dialog + .getByRole('button', { + name: 'Test HTTPS and HTTP', + exact: true, + }) + .click(); + await expect(dialog.getByRole('status')).toContainText( + 'Connected using HTTP' + ); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .click(); + await dialog.waitFor({ state: 'detached' }); + dialog = await openSourceEditor(page, title); + await expectSourceDialogValues(dialog, { + title, + serverUrl: liveFormatMock, + }); + // Persist a broken HTTPS source, then repair it through Edit. + await updateSourceDialog(dialog, { serverUrl: httpsUrl }); + await saveSourceDialog(page, dialog); + dialog = await openSourceEditor(page, title); + await expectSourceDialogValues(dialog, { serverUrl: httpsUrl }); + await dialog + .getByRole('button', { + name: 'Test HTTPS and HTTP', + exact: true, + }) + .click(); + await expect(dialog.getByRole('status')).toContainText( + 'Connected using HTTP' + ); + await dialog.screenshot({ + path: test.info().outputPath('http-connection-edit.png'), + }); + await saveSourceDialog(page, dialog); + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + page = app.mainWindow; + await openSources(page); + dialog = await openSourceEditor(page, title); + await expectSourceDialogValues(dialog, { + serverUrl: liveFormatMock, + }); + await dialog + .getByRole('button', { name: 'Close', exact: true }) + .click(); + await dialog.waitFor({ state: 'detached' }); + const saved = await page.evaluate( + async (title) => + (await window.electron.dbGetAppPlaylistMetas()).find( + (p) => p.title === title + ), + title + ); + expect(saved?.serverUrl).toBe(liveFormatMock); + await refreshSource(page, title, { confirm: true }); + await waitForSourceRowIdle(page, title); + const refreshed = await waitForPortalDebugEvent(page, { + provider: 'xtream', + operation: 'get_live_streams', + }); + expect(refreshed.request).toMatchObject({ + url: expect.stringMatching(/^http:/), + }); + await openSources(page); + await sourceRowByTitle(page, title).first().click(); + await page + .getByRole('link', { name: 'Live TV', exact: true }) + .click(); + await page.locator('.context-panel .category-item').first().click(); + const media = page.waitForResponse( + (r) => + r.url().startsWith(liveFormatMock + '/live/') && + r.url().endsWith('.ts') + ); + await liveChannels(page).first().click(); + expect((await media).status()).toBe(200); + await expectLiveFormatPlaying(page, 'html5'); + const epg = await waitForPortalDebugEvent(page, { + provider: 'xtream', + operation: 'get_short_epg', + }); + expect(epg.request).toMatchObject({ + url: expect.stringMatching(/^http:/), + }); + } finally { + await closeElectronApp(app); + } + }); + test('filters and sorts sources, including persisted custom order', async ({ dataDir, request, @@ -427,10 +589,7 @@ https://streams.example.test/original-url.m3u8 const app = await launchElectronApp(dataDir); try { - await dropM3uPlaylistOntoWorkspace( - app.mainWindow, - localFilePath - ); + await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath); await waitForM3uCatalog(app.mainWindow); await expect( app.mainWindow.locator( @@ -467,10 +626,7 @@ https://streams.example.test/original-url.m3u8 }); await openSources(app.mainWindow); await expect( - sourceRowByTitle( - app.mainWindow, - refreshLocalSourceDisplayName - ) + sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName) .first() .locator('.refresh-btn') ).toBeVisible(); diff --git a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts index f87ed9687..046c784a6 100644 --- a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts @@ -214,6 +214,26 @@ for (const theme of ['light', 'dark']) { await expect .poll(() => shell.evaluate((el) => el.scrollTop)) .toBeGreaterThan(0); + // PageDown animates on macOS. Sending Home after the first + // changed frame races that animation rather than testing a + // second discrete keyboard action (same fence as web E2E). + await shell.evaluate( + (element) => + new Promise((resolve) => { + let last = element.scrollTop; + let stableFrames = 0; + const frame = () => { + stableFrames = + element.scrollTop === last + ? stableFrames + 1 + : 0; + last = element.scrollTop; + if (stableFrames === 3) resolve(); + else requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); + }) + ); await page.keyboard.press('Home'); await expect .poll(() => shell.evaluate((el) => el.scrollTop)) diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index d11393f60..185427f30 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -725,6 +725,7 @@ const electronApi: ElectronBridgeApi = { resetHostConnectivityGuard: (url: string) => ipcRenderer.invoke('CONNECTIVITY_GUARD_RESET', { url }), xtreamRequest: (payload: { + connectionTest?: boolean; url: string; params: Record; requestId?: string; diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts index 11b7e581e..78577264d 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -1,3 +1,4 @@ +/// import { CONNECTIVITY_GUARD_RESET, XTREAM_CANCEL_SESSION, @@ -67,6 +68,117 @@ describe('XtreamEvents session cancellation', () => { } }); + it.each([false, true])( + 'returns structured refused-port evidence (redirected=%s)', + async (redirected) => { + if (redirected) + axiosMock.mockResolvedValueOnce({ + status: 302, + headers: { + location: 'https://other.example/player_api.php', + }, + }); + axiosMock.mockRejectedValueOnce( + Object.assign(new Error('private credentials'), { + code: 'ECONNREFUSED', + }) + ); + const result = await registeredHandlers.get('XTREAM_REQUEST')?.( + {}, + { + url: 'https://example.com', + params: { username: 'user', password: 'secret' }, + connectionTest: true, + } + ); + expect(result).toMatchObject({ + connectionFailure: { + kind: 'connection', + canTryHttp: !redirected, + }, + }); + expect(JSON.stringify(result)).not.toContain('secret'); + expect(JSON.stringify(result)).not.toContain('private credentials'); + } + ); + + it.each(['ECONNREFUSED', 'ETIMEDOUT', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'])( + 'classifies native aggregate failures before IPC (%s)', + async (code) => { + const failure = Object.assign( + new AggregateError([ + Object.assign(new Error('private address'), { + code: 'ECONNREFUSED', + }), + Object.assign(new Error('other private address'), { code }), + ]), + { code: 'ECONNREFUSED' } + ); + axiosMock.mockRejectedValueOnce(failure); + const result = await registeredHandlers.get('XTREAM_REQUEST')?.( + {}, + { + url: 'https://example.com', + params: {}, + connectionTest: true, + } + ); + expect(result).toMatchObject({ + connectionFailure: { + kind: + code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' + ? 'tls' + : 'connection', + canTryHttp: code === 'ECONNREFUSED', + }, + }); + expect(JSON.stringify(result)).not.toContain('private address'); + } + ); + + it.each<[string | undefined, number]>([ + [undefined, 1], + ['file:///private/secret', 1], + ['http://[', 1], + ['https://example.com/player_api.php', 6], + ])( + 'keeps local redirect failure %s out of provider HTTP evidence', + async (location, requests) => { + axiosMock.mockResolvedValue({ status: 302, headers: { location } }); + const result = await registeredHandlers.get('XTREAM_REQUEST')?.( + {}, + { + url: 'https://example.com', + params: {}, + connectionTest: true, + } + ); + expect(result).toHaveProperty('connectionFailure', { + kind: 'connection', + canTryHttp: false, + }); + expect(axiosMock).toHaveBeenCalledTimes(requests); + expect(JSON.stringify(result)).not.toContain('secret'); + } + ); + + it('returns the provider HTTP error without enabling fallback', async () => { + axiosMock.mockResolvedValueOnce({ + status: 403, + statusText: 'Forbidden', + headers: {}, + data: 'blocked', + }); + expect( + await registeredHandlers.get('XTREAM_REQUEST')?.( + {}, + { url: 'https://example.com', params: {}, connectionTest: true } + ) + ).toMatchObject({ + connectionFailure: { kind: 'http', status: 403, canTryHttp: false }, + }); + }); + it('normalizes full Xtream API URLs before appending player_api.php', async () => { const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); expect(requestHandler).toBeDefined(); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index be51a509a..615d64fe5 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -3,7 +3,7 @@ * between the frontend and the electron backend. */ -import axios, { AxiosRequestConfig } from 'axios'; +import axios from 'axios'; import { ipcMain } from 'electron'; import { PortalDebugEvent, @@ -11,11 +11,16 @@ import { XTREAM_CLIENT_USER_AGENT, XTREAM_MAIN_PERFORMANCE_PHASE, normalizeXtreamServerUrl, + describeXtreamConnectionFailure, } from '@iptvnator/shared/interfaces'; import { redactSensitiveData } from '@iptvnator/shared/logging'; import { emitPortalDebugEvent } from './portal-debug.events'; import { formatPortalRequestError } from './portal-request-error.util'; -import { requestWithValidatedRedirects } from '../util/validated-axios'; +import { UnsafeUrlError } from './url-safety'; +import { + requestWithValidatedRedirects, + ValidatedAxiosRequestConfig, +} from '../util/validated-axios'; import { HostConnectivityGuardError, HostRequestToken, @@ -62,12 +67,14 @@ ipcMain.handle( requestId?: string; sessionId?: string; suppressErrorLog?: boolean; + connectionTest?: boolean; } ) => { const startedAt = Date.now(); const performanceCapture = createXtreamMainPerformanceCaptureForRequest( payload.requestId ); + let initialResponded = false; let activeRequestKey: string | null = null; let requestUrlForLog = payload.url; let guardToken: HostRequestToken | null = null; @@ -93,7 +100,10 @@ ipcMain.handle( } // Configure axios request - const config: AxiosRequestConfig = { + const config: ValidatedAxiosRequestConfig = { + onResponse: () => { + initialResponded = true; + }, method: 'GET', url: apiUrl.toString(), headers: { @@ -209,6 +219,23 @@ ipcMain.handle( emitPortalDebugEvent(debugEvent); } + if (payload.connectionTest) { + reportGuardedHostFailure(guardToken, error, { + requestUrl: requestUrlForLog, + }); + return { + payload: null, + action: payload.params?.action, + connectionFailure: + error instanceof UnsafeUrlError + ? { kind: 'connection', canTryHttp: false } + : describeXtreamConnectionFailure( + error, + initialResponded + ), + }; + } + if (error instanceof HostConnectivityGuardError) { // The failures that tripped the guard were logged when they // happened; a line per skipped request would be the very log diff --git a/apps/electron-backend/src/app/util/validated-axios.ts b/apps/electron-backend/src/app/util/validated-axios.ts index f0a482935..619cae0d9 100644 --- a/apps/electron-backend/src/app/util/validated-axios.ts +++ b/apps/electron-backend/src/app/util/validated-axios.ts @@ -30,6 +30,7 @@ export type ValidatedAxiosRequestConfig = Omit< 'httpAgent' | 'httpsAgent' > & { agentFactory?: ValidatedRequestAgentFactory; + onResponse?: () => void; }; function copyHeadersWithoutSensitiveValues( @@ -166,7 +167,7 @@ function getRedirectValidationPolicy( */ export async function requestWithValidatedRedirects( rawUrl: string, - config: ValidatedAxiosRequestConfig = {}, + { onResponse, ...config }: ValidatedAxiosRequestConfig = {}, policy: RemoteUrlPolicy = {}, maxRedirects = 5 ): Promise> { @@ -209,6 +210,7 @@ export async function requestWithValidatedRedirects( REDIRECT_STATUSES.has(status) || originalValidateStatus(status), }); + onResponse?.(); if (!REDIRECT_STATUSES.has(response.status)) { return response; } diff --git a/apps/web-backend/src/app/web-backend-app.redirects.spec.ts b/apps/web-backend/src/app/web-backend-app.redirects.spec.ts index df2882bfd..3818110fb 100644 --- a/apps/web-backend/src/app/web-backend-app.redirects.spec.ts +++ b/apps/web-backend/src/app/web-backend-app.redirects.spec.ts @@ -236,3 +236,94 @@ describe('redirect routes and admission lifecycle', () => { } ); }); + +describe('Xtream explicit protocol-test evidence', () => { + it.each(['dns', 'private redirect', 'redirect cycle', 'provider HTTP'])( + 'separates %s failures from provider response statuses', + async (mode) => { + const transport = new StubHttpClient(); + let failDns = false; + await withServer( + createWebBackendApp({ + httpClient: transport, + allowPrivateNetworkTargets: false, + resolveHostname: async () => { + if (failDns) + throw Object.assign(new Error('private details'), { + code: 'ENOTFOUND', + }); + return ['93.184.216.34']; + }, + }), + async (backend) => { + const id = await registerProviderTarget( + backend, + 'https://provider.example' + ); + if (mode === 'dns') failDns = true; + else if (mode === 'private redirect') + transport.queueRedirect('http://127.0.0.1/private'); + else if (mode === 'redirect cycle') + transport.queueRedirect( + 'https://provider.example/player_api.php' + ); + else transport.queueFailure(500); + const response = await fetch( + `${backend}/xtream?targetId=${id}&connectionTest=true` + ); + expect(await response.json()).toEqual({ + connectionFailure: + mode === 'provider HTTP' + ? { + kind: 'http', + status: 500, + canTryHttp: false, + } + : { kind: 'connection', canTryHttp: false }, + }); + expect(transport.requests).toHaveLength( + mode === 'dns' ? 0 : 1 + ); + } + ); + } + ); + + it.each([false, true])( + 'preserves initial-response evidence (redirected=%s)', + async (redirected) => { + const transport = new StubHttpClient(); + if (redirected) + transport.queueRedirect('https://other.example/player_api.php'); + transport.queueNetworkError( + Object.assign(new Error('private provider text'), { + code: 'ECONNREFUSED', + }) + ); + await withServer( + createWebBackendApp({ + httpClient: transport, + resolveHostname: resolvePublicHost, + }), + async (backend) => { + const id = await registerProviderTarget( + backend, + 'https://provider.example' + ); + const response = await fetch( + `${backend}/xtream?targetId=${id}&connectionTest=true&username=user&password=secret` + ); + expect(await response.json()).toEqual({ + connectionFailure: { + kind: 'connection', + canTryHttp: !redirected, + }, + }); + expect(transport.requests[0].params).not.toHaveProperty( + 'connectionTest' + ); + } + ); + } + ); +}); diff --git a/apps/web-backend/src/app/web-backend-app.ts b/apps/web-backend/src/app/web-backend-app.ts index f7529eea0..aad42da2d 100644 --- a/apps/web-backend/src/app/web-backend-app.ts +++ b/apps/web-backend/src/app/web-backend-app.ts @@ -12,6 +12,7 @@ import { buildStalkerIdentityRequestContext, buildStalkerRequestUrl, normalizeXtreamServerUrl, + describeXtreamConnectionFailure, } from '@iptvnator/shared/interfaces'; import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils'; import { @@ -281,7 +282,7 @@ export function createWebBackendApp( requestUrl = appendPathSegment(url, 'player_api.php'); const response = await httpClient.get(requestUrl, { - params: getProxyParams(req, ['targetId']), + params: getProxyParams(req, ['targetId', 'connectionTest']), timeout: PROVIDER_REQUEST_TIMEOUT_MS.xtream, }); reportProviderRequestSuccess(hostGuard, guardToken); @@ -296,7 +297,20 @@ export function createWebBackendApp( requestUrl, }); logProviderRequestFailure({ error, route: '/xtream', url }); - res.json(normalizeProviderError(error)); + if (getQueryString(req, 'connectionTest') === 'true') { + const wrapped = + error instanceof ProviderRequestError ? error : null; + res.json({ + connectionFailure: wrapped?.policyError + ? { kind: 'connection', canTryHttp: false } + : describeXtreamConnectionFailure( + wrapped?.cause ?? error, + wrapped?.initialResponded ?? true + ), + }); + } else { + res.json(normalizeProviderError(error)); + } } finally { releaseProviderRequest(hostGuard, guardToken); } diff --git a/apps/web-e2e/src/xtream-connection.e2e.ts b/apps/web-e2e/src/xtream-connection.e2e.ts new file mode 100644 index 000000000..bd360b986 --- /dev/null +++ b/apps/web-e2e/src/xtream-connection.e2e.ts @@ -0,0 +1,81 @@ +import { expect, test } from './fixtures'; +import { + getRegisteredProviderUrl, + interceptProviderTargetRegistration, +} from './provider-target-route'; + +for (const canTryHttp of [true, false]) { + test(`@xtream connection test respects proxy evidence: fallback=${canTryHttp}`, async ({ + page, + }) => { + await page.goto('/'); + const targets = await interceptProviderTargetRegistration(page); + const requested: string[] = []; + await page.route( + '**/localhost:3000/connectivity-guard/reset', + (route) => route.fulfill({ json: { reset: true } }) + ); + await page.route('**/localhost:3000/xtream**', async (route) => { + const url = new URL(route.request().url()); + const base = getRegisteredProviderUrl(url, targets); + if (!base) throw new Error('Missing synthetic provider target'); + requested.push(base); + expect(url.searchParams.get('connectionTest')).toBe('true'); + await route.fulfill({ + json: base.startsWith('https:') + ? { + connectionFailure: { + kind: canTryHttp ? 'connection' : 'tls', + canTryHttp, + }, + } + : { payload: { user_info: { auth: 1, status: 'Active' } } }, + }); + }); + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.getByRole('dialog'); + await dialog + .getByRole('radio', { name: /Xtream credentials/i }) + .click(); + await dialog.locator('#title').fill('Synthetic protocol test'); + await dialog + .locator('#serverUrl') + .fill('https://panel.example/base/get.php?type=m3u'); + await dialog.locator('#username').fill(' '); + await dialog.locator('#password').fill('pass'); + await dialog + .getByRole('button', { name: 'Test HTTPS and HTTP', exact: true }) + .click(); + await expect(dialog.getByRole('status')).toContainText( + 'Enter a username and password' + ); + expect(requested).toEqual([]); + await dialog.locator('#username').fill('user'); + await dialog.locator('#password').fill('pass'); + await expect(dialog.locator('#xtream-http-test-notice')).toBeVisible(); + await expect(dialog.locator('#xtream-http-test-notice')).toContainText( + 'username and password' + ); + await dialog + .getByRole('button', { name: 'Test HTTPS and HTTP', exact: true }) + .click(); + await expect(dialog.getByRole('status')).toContainText( + canTryHttp + ? 'Connected using HTTP' + : 'Could not establish a secure connection' + ); + await expect(dialog.locator('#serverUrl')).toHaveValue( + canTryHttp + ? 'http://panel.example/base' + : 'https://panel.example/base/get.php?type=m3u' + ); + expect(requested).toEqual( + canTryHttp + ? ['https://panel.example/base', 'http://panel.example/base'] + : ['https://panel.example/base'] + ); + await expect( + dialog.getByRole('button', { name: 'Add', exact: true }) + ).toBeEnabled(); + }); +} diff --git a/apps/web/src/app/services/electron.service.spec.ts b/apps/web/src/app/services/electron.service.spec.ts index 08e69427c..1e869d337 100644 --- a/apps/web/src/app/services/electron.service.spec.ts +++ b/apps/web/src/app/services/electron.service.spec.ts @@ -13,6 +13,7 @@ import { AutoUpdatePlaylistsResult, ELECTRON_BRIDGE_SECURITY_ERROR_CODES, PLAYLIST_PARSE_BY_URL, + XTREAM_REQUEST, Playlist, SECURITY_ERROR_PREFIX, } from '@iptvnator/shared/interfaces'; @@ -21,6 +22,7 @@ import { ElectronService } from './electron.service'; describe('ElectronService', () => { const session = { id: 'session-1' }; let electronBridge: { + xtreamRequest: jest.Mock; autoUpdatePlaylists: jest.Mock; fetchPlaylistByUrl: jest.Mock; onPlayerError: jest.Mock; @@ -38,6 +40,7 @@ describe('ElectronService', () => { jest.spyOn(console, 'error').mockImplementation(() => undefined); electronBridge = { + xtreamRequest: jest.fn(), autoUpdatePlaylists: jest.fn(), fetchPlaylistByUrl: jest.fn(), onPlayerError: jest.fn(), @@ -105,6 +108,23 @@ describe('ElectronService', () => { jest.restoreAllMocks(); }); + it('preserves connection-test errors across the renderer bridge', async () => { + const response = { + connectionFailure: { kind: 'tls', canTryHttp: false }, + }; + electronBridge.xtreamRequest.mockResolvedValue(response); + const result = await service.sendIpcEvent(XTREAM_REQUEST, { + url: 'https://provider.example', + params: {}, + connectionTest: true, + }); + expect(result).toEqual(response); + expect(electronBridge.xtreamRequest).toHaveBeenCalledWith( + expect.objectContaining({ connectionTest: true }) + ); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + it('ignores URL imports without a payload instead of calling the Electron bridge', async () => { await service.sendIpcEvent(PLAYLIST_PARSE_BY_URL); diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index bced05a03..6d5c25929 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -605,6 +605,7 @@ export class ElectronService extends DataService { } */ private async forwardXtreamRequest(payload: { + connectionTest?: boolean; url: string; params: Record; requestId?: string; @@ -625,6 +626,8 @@ export class ElectronService extends DataService { requestId: context.requestId, }); + if (payload.connectionTest) return response; + const result = { type: XTREAM_RESPONSE, payload: response.payload, diff --git a/apps/web/src/app/services/pwa.service.spec.ts b/apps/web/src/app/services/pwa.service.spec.ts index 6653c9684..87a861bf4 100644 --- a/apps/web/src/app/services/pwa.service.spec.ts +++ b/apps/web/src/app/services/pwa.service.spec.ts @@ -14,6 +14,7 @@ import { PLAYLIST_PARSE_BY_URL, PLAYLIST_UPDATE, STALKER_REQUEST, + XTREAM_REQUEST, } from '@iptvnator/shared/interfaces'; import { getStalkerRequestErrorStatus, @@ -68,6 +69,26 @@ describe('PwaService', () => { jest.restoreAllMocks(); }); + it('passes explicit connection-test evidence through without a playback response', async () => { + const failure = { + connectionFailure: { kind: 'connection', canTryHttp: true }, + }; + const pending = service.sendIpcEvent(XTREAM_REQUEST, { + url: 'https://provider.example', + params: { username: 'user', password: 'pass' }, + connectionTest: true, + }); + http.expectOne((req) => req.url.endsWith('/provider-targets')).flush({ + targetId: 'test-target', + }); + await new Promise((resolve) => setTimeout(resolve)); + const request = http.expectOne((req) => req.url.endsWith('/xtream')); + expect(request.request.params.get('connectionTest')).toBe('true'); + request.flush(failure); + expect(await pending).toEqual(failure); + expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled(); + }); + it('ignores URL imports without a payload or URL instead of calling the backend', () => { service.sendIpcEvent(PLAYLIST_PARSE_BY_URL); service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, {}); diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 6b60d8a10..497e07e65 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -372,6 +372,7 @@ export class PwaService extends DataService { } async forwardXtreamRequest(payload: { + connectionTest?: boolean; url: string; params: Record; macAddress?: string; @@ -402,6 +403,7 @@ export class PwaService extends DataService { const requestParams = { targetId, ...payload.params, + ...(payload.connectionTest ? { connectionTest: 'true' } : {}), }; const requestPayload = { method: 'GET', @@ -434,6 +436,8 @@ export class PwaService extends DataService { ) )) as PwaXtreamResponse; + if (payload.connectionTest) return response; + if (!response.payload) { const action = payload.params.action; const isSilentAction = diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index ffbbdef41..5d17950b3 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -181,7 +181,22 @@ "PASSWORD": "كلمة المرور", "ADD": "إضافة", "TEST_CREDENTIALS": "اختبار بيانات الاعتماد", - "URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com)" + "URL_VALIDATION_ERROR": "يجب أن يكون الرابط صالحًا مع بروتوكول (مثال: http://example.com)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "أدخل اسم المستخدم وكلمة المرور لاختبار الاتصال.", + "TESTING": "جارٍ اختبار الاتصال…", + "HTTP_CONNECTED": "تم الاتصال عبر HTTP. بروتوكول HTTPS غير متاح. الاتصال غير مشفر.", + "ACTIVE": "تم الاتصال. البوابة نشطة.", + "INACTIVE": "الحساب غير نشط أو بيانات الدخول غير صحيحة.", + "EXPIRED": "انتهت صلاحية الاشتراك.", + "UNAVAILABLE": "لم تُرجع البوابة معلومات حساب صالحة.", + "HTTP_ERROR": "أرجع الخادم HTTP {{status}}.", + "TLS_ERROR": "تعذر إنشاء اتصال آمن. تحقق من إعدادات HTTPS وشهادة الخادم.", + "CONNECTION_ERROR": "تعذر الوصول إلى الخادم. تحقق من العنوان واتصال الشبكة.", + "UNKNOWN_ERROR": "تعذر اختبار الاتصال.", + "TEST_PROTOCOLS": "اختبار HTTPS وHTTP", + "HTTP_PERMISSION": "إذا كان HTTPS غير متاح، فقد يرسل هذا الاختبار اسم المستخدم وكلمة المرور عبر HTTP غير المشفر." + } }, "STALKER_PORTAL": { "TITLE": "العنوان", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index f5b0830d2..0e5d254b5 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -181,7 +181,22 @@ "PASSWORD": "كلمة المرور", "ADD": "زيد", "TEST_CREDENTIALS": "جرب بيانات الاعتماد", - "URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com ولا https://example.com:4324)" + "URL_VALIDATION_ERROR": "خاص يكون رابط صحيح مع البروتوكول (مثلا http://example.com ولا https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "دخل اسم المستخدم وكلمة السر باش تجرّب الاتصال.", + "TESTING": "كنجرّبو الاتصال…", + "HTTP_CONNECTED": "تّاصلنا بـ HTTP. HTTPS ما متوفرش. الاتصال ما مشفّرش.", + "ACTIVE": "تّاصلنا. البوابة خدامة.", + "INACTIVE": "الحساب ما مفعّلش أو معلومات الدخول غالطة.", + "EXPIRED": "الاشتراك سالا.", + "UNAVAILABLE": "البوابة ما رجّعاتش معلومات حساب صحيحة.", + "HTTP_ERROR": "السيرفر رجّع HTTP {{status}}.", + "TLS_ERROR": "ما قدرناش نديرو اتصال آمن. تأكد من إعدادات HTTPS وشهادة السيرفر.", + "CONNECTION_ERROR": "ما قدرناش نوصلو للسيرفر. تأكد من العنوان والاتصال بالشبكة.", + "UNKNOWN_ERROR": "ما قدرناش نجرّبو الاتصال.", + "TEST_PROTOCOLS": "جرّب HTTPS وHTTP", + "HTTP_PERMISSION": "إلا كان HTTPS ما متوفرش، هاد الاختبار يقدر يصيفط اسم المستخدم وكلمة السر بـ HTTP بلا تشفير." + } }, "STALKER_PORTAL": { "TITLE": "العنوان", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 2d51641ee..52a797aa8 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -181,7 +181,22 @@ "PASSWORD": "Пароль", "ADD": "Дадаць", "TEST_CREDENTIALS": "Праверыць", - "URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com або https://example.com:4324)." + "URL_VALIDATION_ERROR": "Павінен быць сапраўдны URL-адрас з пратаколам (напрыклад, http://example.com або https://example.com:4324).", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Увядзіце імя карыстальніка і пароль для праверкі злучэння.", + "TESTING": "Праверка падключэння…", + "HTTP_CONNECTED": "Падключана праз HTTP. HTTPS недаступны. Злучэнне не зашыфравана.", + "ACTIVE": "Падключана. Партал актыўны.", + "INACTIVE": "Уліковы запіс неактыўны або даныя ўваходу няправільныя.", + "EXPIRED": "Тэрмін падпіскі скончыўся.", + "UNAVAILABLE": "Партал не вярнуў карэктныя даныя ўліковага запісу.", + "HTTP_ERROR": "Сервер вярнуў HTTP {{status}}.", + "TLS_ERROR": "Не ўдалося ўсталяваць абароненае злучэнне. Праверце налады HTTPS і сертыфікат сервера.", + "CONNECTION_ERROR": "Сервер недаступны. Праверце адрас і падключэнне да сеткі.", + "UNKNOWN_ERROR": "Не ўдалося праверыць падключэнне.", + "TEST_PROTOCOLS": "Праверыць HTTPS і HTTP", + "HTTP_PERMISSION": "Калі HTTPS недаступны, гэтая праверка можа адправіць імя карыстальніка і пароль праз незашыфраваны HTTP." + } }, "STALKER_PORTAL": { "TITLE": "Назва", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 2e89b8d03..0761eb595 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -181,7 +181,22 @@ "PASSWORD": "Passwort", "ADD": "Hinzufügen", "TEST_CREDENTIALS": "Verbindung testen", - "URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com oder https://example.com:4324)" + "URL_VALIDATION_ERROR": "Sollte eine gültige URL mit Protokoll sein (z. B. http://example.com oder https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Gib einen Benutzernamen und ein Passwort ein, um die Verbindung zu testen.", + "TESTING": "Verbindung wird geprüft…", + "HTTP_CONNECTED": "Über HTTP verbunden. HTTPS ist nicht verfügbar. Die Verbindung ist unverschlüsselt.", + "ACTIVE": "Verbunden. Das Portal ist aktiv.", + "INACTIVE": "Das Konto ist inaktiv oder die Zugangsdaten sind falsch.", + "EXPIRED": "Das Abonnement ist abgelaufen.", + "UNAVAILABLE": "Das Portal hat keine gültigen Kontodaten geliefert.", + "HTTP_ERROR": "Der Server antwortete mit HTTP {{status}}.", + "TLS_ERROR": "Keine sichere Verbindung möglich. Prüfen Sie die HTTPS-Konfiguration und das Zertifikat des Servers.", + "CONNECTION_ERROR": "Server nicht erreichbar. Prüfen Sie die Adresse und Ihre Netzwerkverbindung.", + "UNKNOWN_ERROR": "Verbindung konnte nicht geprüft werden.", + "TEST_PROTOCOLS": "HTTPS und HTTP testen", + "HTTP_PERMISSION": "Wenn HTTPS nicht verfügbar ist, kann dieser Test Ihren Benutzernamen und Ihr Passwort über unverschlüsseltes HTTP senden." + } }, "STALKER_PORTAL": { "TITLE": "Titel", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 441e5c9d3..706dbe232 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -181,7 +181,22 @@ "PASSWORD": "Κωδικός πρόσβασης", "ADD": "Προσθήκη", "TEST_CREDENTIALS": "Δοκιμή διαπιστευτήριων", - "URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com ή https://example.com:4324)" + "URL_VALIDATION_ERROR": "Θα πρέπει να είναι μια έγκυρη διεύθυνση URL με πρωτόκολλο (π.χ. http://example.com ή https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Εισαγάγετε όνομα χρήστη και κωδικό πρόσβασης για να ελέγξετε τη σύνδεση.", + "TESTING": "Έλεγχος σύνδεσης…", + "HTTP_CONNECTED": "Σύνδεση μέσω HTTP. Το HTTPS δεν είναι διαθέσιμο. Η σύνδεση δεν είναι κρυπτογραφημένη.", + "ACTIVE": "Συνδέθηκε. Η πύλη είναι ενεργή.", + "INACTIVE": "Ο λογαριασμός είναι ανενεργός ή τα στοιχεία σύνδεσης είναι λανθασμένα.", + "EXPIRED": "Η συνδρομή έχει λήξει.", + "UNAVAILABLE": "Η πύλη δεν επέστρεψε έγκυρα στοιχεία λογαριασμού.", + "HTTP_ERROR": "Ο διακομιστής επέστρεψε HTTP {{status}}.", + "TLS_ERROR": "Δεν ήταν δυνατή η ασφαλής σύνδεση. Ελέγξτε τις ρυθμίσεις HTTPS και το πιστοποιητικό του διακομιστή.", + "CONNECTION_ERROR": "Ο διακομιστής δεν είναι προσβάσιμος. Ελέγξτε τη διεύθυνση και τη σύνδεση δικτύου.", + "UNKNOWN_ERROR": "Δεν ήταν δυνατός ο έλεγχος σύνδεσης.", + "TEST_PROTOCOLS": "Έλεγχος HTTPS και HTTP", + "HTTP_PERMISSION": "Αν το HTTPS δεν είναι διαθέσιμο, αυτός ο έλεγχος μπορεί να στείλει το όνομα χρήστη και τον κωδικό σας μέσω μη κρυπτογραφημένου HTTP." + } }, "STALKER_PORTAL": { "TITLE": "Τίτλος", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index c3e50f215..f74f6547d 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -181,7 +181,22 @@ "PASSWORD": "Password", "ADD": "Add", "TEST_CREDENTIALS": "Test credentials", - "URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com or https://example.com:4324)" + "URL_VALIDATION_ERROR": "Should be a valid url with protocol (e.g. http://example.com or https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Enter a username and password to test the connection.", + "TESTING": "Testing connection…", + "HTTP_CONNECTED": "Connected using HTTP. HTTPS is unavailable. The connection is not encrypted.", + "ACTIVE": "Connected. The portal is active.", + "INACTIVE": "The account is inactive or the credentials are incorrect.", + "EXPIRED": "The subscription has expired.", + "UNAVAILABLE": "The portal did not return valid account information.", + "HTTP_ERROR": "The server returned HTTP {{status}}.", + "TLS_ERROR": "Could not establish a secure connection. Check the server’s HTTPS configuration and certificate.", + "CONNECTION_ERROR": "Could not reach the server. Check its address and your network connection.", + "UNKNOWN_ERROR": "Could not test the connection.", + "TEST_PROTOCOLS": "Test HTTPS and HTTP", + "HTTP_PERMISSION": "This test may send your username and password over unencrypted HTTP if HTTPS is unavailable." + } }, "STALKER_PORTAL": { "TITLE": "Title", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index a732875c4..b707035e4 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -181,7 +181,22 @@ "PASSWORD": "Contraseña", "ADD": "Agregar", "TEST_CREDENTIALS": "Probar credenciales", - "URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com o https://example.com:4324)." + "URL_VALIDATION_ERROR": "Debe ser una URL válida con protocolo (por ejemplo, http://example.com o https://example.com:4324).", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Introduce un nombre de usuario y una contraseña para probar la conexión.", + "TESTING": "Probando conexión…", + "HTTP_CONNECTED": "Conectado por HTTP. HTTPS no está disponible. La conexión no está cifrada.", + "ACTIVE": "Conectado. El portal está activo.", + "INACTIVE": "La cuenta está inactiva o las credenciales son incorrectas.", + "EXPIRED": "La suscripción ha caducado.", + "UNAVAILABLE": "El portal no devolvió información válida de la cuenta.", + "HTTP_ERROR": "El servidor devolvió HTTP {{status}}.", + "TLS_ERROR": "No se pudo establecer una conexión segura. Comprueba la configuración HTTPS y el certificado del servidor.", + "CONNECTION_ERROR": "No se pudo acceder al servidor. Comprueba la dirección y la conexión de red.", + "UNKNOWN_ERROR": "No se pudo probar la conexión.", + "TEST_PROTOCOLS": "Probar HTTPS y HTTP", + "HTTP_PERMISSION": "Si HTTPS no está disponible, esta prueba puede enviar tu usuario y contraseña por HTTP sin cifrar." + } }, "STALKER_PORTAL": { "TITLE": "Título", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 67df11c6f..4de8cc01f 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -181,7 +181,22 @@ "PASSWORD": "Mot de passe", "ADD": "Ajouter", "TEST_CREDENTIALS": "Tester les identifiants", - "URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com ou https://example.com:4324)" + "URL_VALIDATION_ERROR": "Doit être une URL valide avec un protocole (par exemple http://example.com ou https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Saisissez un nom d’utilisateur et un mot de passe pour tester la connexion.", + "TESTING": "Test de la connexion…", + "HTTP_CONNECTED": "Connecté via HTTP. HTTPS est indisponible. La connexion n’est pas chiffrée.", + "ACTIVE": "Connecté. Le portail est actif.", + "INACTIVE": "Le compte est inactif ou les identifiants sont incorrects.", + "EXPIRED": "L’abonnement a expiré.", + "UNAVAILABLE": "Le portail n’a pas renvoyé de données de compte valides.", + "HTTP_ERROR": "Le serveur a renvoyé HTTP {{status}}.", + "TLS_ERROR": "Impossible d’établir une connexion sécurisée. Vérifiez la configuration HTTPS et le certificat du serveur.", + "CONNECTION_ERROR": "Serveur inaccessible. Vérifiez son adresse et votre connexion réseau.", + "UNKNOWN_ERROR": "Impossible de tester la connexion.", + "TEST_PROTOCOLS": "Tester HTTPS et HTTP", + "HTTP_PERMISSION": "Si HTTPS est indisponible, ce test peut envoyer votre identifiant et votre mot de passe via HTTP non chiffré." + } }, "STALKER_PORTAL": { "TITLE": "Titre", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index c8f854ced..71ba7a563 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -181,7 +181,22 @@ "PASSWORD": "Jelszó", "ADD": "Hozzáadás", "TEST_CREDENTIALS": "Hitelesítési adatok ellenőrzése", - "URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com vagy https://example.com:4324)" + "URL_VALIDATION_ERROR": "Adjon meg protokollt is tartalmazó, érvényes URL-címet (például http://example.com vagy https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "A kapcsolat teszteléséhez adjon meg egy felhasználónevet és jelszót.", + "TESTING": "Kapcsolat tesztelése…", + "HTTP_CONNECTED": "Csatlakoztatva HTTP-n keresztül. A HTTPS nem érhető el. A kapcsolat nem titkosított.", + "ACTIVE": "Csatlakoztatva. A portál aktív.", + "INACTIVE": "A fiók inaktív vagy a bejelentkezési adatok hibásak.", + "EXPIRED": "Az előfizetés lejárt.", + "UNAVAILABLE": "A portál nem adott vissza érvényes fiókadatokat.", + "HTTP_ERROR": "A szerver HTTP {{status}} választ adott.", + "TLS_ERROR": "Nem hozható létre biztonságos kapcsolat. Ellenőrizze a szerver HTTPS-beállításait és tanúsítványát.", + "CONNECTION_ERROR": "A szerver nem érhető el. Ellenőrizze a címet és a hálózati kapcsolatot.", + "UNKNOWN_ERROR": "A kapcsolat nem tesztelhető.", + "TEST_PROTOCOLS": "HTTPS és HTTP tesztelése", + "HTTP_PERMISSION": "Ha a HTTPS nem érhető el, ez a teszt titkosítatlan HTTP-n küldheti el a felhasználónevet és a jelszót." + } }, "STALKER_PORTAL": { "TITLE": "Cím", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 8dba87ea8..3b2035361 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -181,7 +181,22 @@ "PASSWORD": "Password", "ADD": "Aggiungi", "TEST_CREDENTIALS": "Testa credenziali", - "URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it o https://esempio.it:4324)" + "URL_VALIDATION_ERROR": "Dovrebbe essere una URL valida con protocollo (es. http://esempio.it o https://esempio.it:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Inserisci nome utente e password per verificare la connessione.", + "TESTING": "Verifica della connessione…", + "HTTP_CONNECTED": "Connesso tramite HTTP. HTTPS non è disponibile. La connessione non è crittografata.", + "ACTIVE": "Connesso. Il portale è attivo.", + "INACTIVE": "L’account è inattivo o le credenziali sono errate.", + "EXPIRED": "L’abbonamento è scaduto.", + "UNAVAILABLE": "Il portale non ha restituito dati validi dell’account.", + "HTTP_ERROR": "Il server ha restituito HTTP {{status}}.", + "TLS_ERROR": "Impossibile stabilire una connessione sicura. Verifica la configurazione HTTPS e il certificato del server.", + "CONNECTION_ERROR": "Server non raggiungibile. Verifica l’indirizzo e la connessione di rete.", + "UNKNOWN_ERROR": "Impossibile verificare la connessione.", + "TEST_PROTOCOLS": "Verifica HTTPS e HTTP", + "HTTP_PERMISSION": "Se HTTPS non è disponibile, questo test può inviare nome utente e password tramite HTTP non crittografato." + } }, "STALKER_PORTAL": { "TITLE": "Titolo", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 7199d027c..7d3946c53 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -181,7 +181,22 @@ "PASSWORD": "パスワード", "ADD": "追加", "TEST_CREDENTIALS": "認証情報をテスト", - "URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com または https://example.com:4324)" + "URL_VALIDATION_ERROR": "プロトコルを含む有効なURLである必要があります(例:http://example.com または https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "接続をテストするには、ユーザー名とパスワードを入力してください。", + "TESTING": "接続をテスト中…", + "HTTP_CONNECTED": "HTTPで接続しました。HTTPSは利用できません。接続は暗号化されていません。", + "ACTIVE": "接続しました。ポータルは有効です。", + "INACTIVE": "アカウントが無効か、認証情報が正しくありません。", + "EXPIRED": "サブスクリプションの有効期限が切れています。", + "UNAVAILABLE": "ポータルから有効なアカウント情報が返されませんでした。", + "HTTP_ERROR": "サーバーがHTTP {{status}}を返しました。", + "TLS_ERROR": "安全な接続を確立できませんでした。サーバーのHTTPS設定と証明書を確認してください。", + "CONNECTION_ERROR": "サーバーに接続できません。アドレスとネットワーク接続を確認してください。", + "UNKNOWN_ERROR": "接続をテストできませんでした。", + "TEST_PROTOCOLS": "HTTPSとHTTPをテスト", + "HTTP_PERMISSION": "HTTPSが利用できない場合、このテストではユーザー名とパスワードが暗号化されていないHTTPで送信されることがあります。" + } }, "STALKER_PORTAL": { "TITLE": "タイトル", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 0c0145bb5..c4c6bd2d9 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -181,7 +181,22 @@ "PASSWORD": "비밀번호", "ADD": "추가", "TEST_CREDENTIALS": "자격 증명 테스트", - "URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com 또는 https://example.com:4324)." + "URL_VALIDATION_ERROR": "프로토콜이 포함된 유효한 URL이어야 합니다(예: http://example.com 또는 https://example.com:4324).", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "연결을 테스트하려면 사용자 이름과 비밀번호를 입력하세요.", + "TESTING": "연결 테스트 중…", + "HTTP_CONNECTED": "HTTP로 연결되었습니다. HTTPS를 사용할 수 없습니다. 연결이 암호화되지 않습니다.", + "ACTIVE": "연결되었습니다. 포털이 활성 상태입니다.", + "INACTIVE": "계정이 비활성 상태이거나 로그인 정보가 잘못되었습니다.", + "EXPIRED": "구독이 만료되었습니다.", + "UNAVAILABLE": "포털에서 유효한 계정 정보를 반환하지 않았습니다.", + "HTTP_ERROR": "서버에서 HTTP {{status}}를 반환했습니다.", + "TLS_ERROR": "보안 연결을 설정할 수 없습니다. 서버의 HTTPS 설정과 인증서를 확인하세요.", + "CONNECTION_ERROR": "서버에 연결할 수 없습니다. 주소와 네트워크 연결을 확인하세요.", + "UNKNOWN_ERROR": "연결을 테스트할 수 없습니다.", + "TEST_PROTOCOLS": "HTTPS 및 HTTP 테스트", + "HTTP_PERMISSION": "HTTPS를 사용할 수 없는 경우 이 테스트는 사용자 이름과 비밀번호를 암호화되지 않은 HTTP로 전송할 수 있습니다." + } }, "STALKER_PORTAL": { "TITLE": "제목", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 68096604a..bc4a3e494 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -181,7 +181,22 @@ "PASSWORD": "Wachtwoord", "ADD": "Toevoegen", "TEST_CREDENTIALS": "Test de inloggegevens", - "URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com of https://example.com:4324)" + "URL_VALIDATION_ERROR": "Moet een geldige URL zijn met protocol (bijv. http://example.com of https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Voer een gebruikersnaam en wachtwoord in om de verbinding te testen.", + "TESTING": "Verbinding testen…", + "HTTP_CONNECTED": "Verbonden via HTTP. HTTPS is niet beschikbaar. De verbinding is niet versleuteld.", + "ACTIVE": "Verbonden. Het portaal is actief.", + "INACTIVE": "Het account is inactief of de inloggegevens zijn onjuist.", + "EXPIRED": "Het abonnement is verlopen.", + "UNAVAILABLE": "Het portaal gaf geen geldige accountgegevens terug.", + "HTTP_ERROR": "De server gaf HTTP {{status}} terug.", + "TLS_ERROR": "Kan geen beveiligde verbinding maken. Controleer de HTTPS-configuratie en het certificaat van de server.", + "CONNECTION_ERROR": "Server niet bereikbaar. Controleer het adres en uw netwerkverbinding.", + "UNKNOWN_ERROR": "Kan de verbinding niet testen.", + "TEST_PROTOCOLS": "HTTPS en HTTP testen", + "HTTP_PERMISSION": "Als HTTPS niet beschikbaar is, kan deze test uw gebruikersnaam en wachtwoord via onversleuteld HTTP verzenden." + } }, "STALKER_PORTAL": { "TITLE": "Titel", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 2ce0f2273..3a1df2f80 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -181,7 +181,22 @@ "PASSWORD": "Hasło", "ADD": "Dodaj", "TEST_CREDENTIALS": "Testuj dane uwierzytelniające", - "URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com lub https://example.com:4324)" + "URL_VALIDATION_ERROR": "Powinien to być poprawny URL z protokołem (np. http://example.com lub https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Wpisz nazwę użytkownika i hasło, aby sprawdzić połączenie.", + "TESTING": "Sprawdzanie połączenia…", + "HTTP_CONNECTED": "Połączono przez HTTP. HTTPS jest niedostępny. Połączenie nie jest szyfrowane.", + "ACTIVE": "Połączono. Portal jest aktywny.", + "INACTIVE": "Konto jest nieaktywne lub dane logowania są nieprawidłowe.", + "EXPIRED": "Subskrypcja wygasła.", + "UNAVAILABLE": "Portal nie zwrócił prawidłowych danych konta.", + "HTTP_ERROR": "Serwer zwrócił HTTP {{status}}.", + "TLS_ERROR": "Nie można nawiązać bezpiecznego połączenia. Sprawdź konfigurację HTTPS i certyfikat serwera.", + "CONNECTION_ERROR": "Serwer jest nieosiągalny. Sprawdź adres i połączenie sieciowe.", + "UNKNOWN_ERROR": "Nie można sprawdzić połączenia.", + "TEST_PROTOCOLS": "Sprawdź HTTPS i HTTP", + "HTTP_PERMISSION": "Jeśli HTTPS jest niedostępny, ten test może wysłać nazwę użytkownika i hasło przez nieszyfrowane HTTP." + } }, "STALKER_PORTAL": { "TITLE": "Tytuł", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 13ff72d13..35535b9f9 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -181,7 +181,22 @@ "PASSWORD": "Senha", "ADD": "Adicionar", "TEST_CREDENTIALS": "Testar credenciais", - "URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com ou https://example.com:4324)" + "URL_VALIDATION_ERROR": "Deve ser uma URL válida com protocolo (por exemplo, http://example.com ou https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Introduza um nome de utilizador e uma palavra-passe para testar a ligação.", + "TESTING": "A testar ligação…", + "HTTP_CONNECTED": "Ligado por HTTP. HTTPS indisponível. A ligação não está encriptada.", + "ACTIVE": "Ligado. O portal está ativo.", + "INACTIVE": "A conta está inativa ou as credenciais estão incorretas.", + "EXPIRED": "A subscrição expirou.", + "UNAVAILABLE": "O portal não devolveu dados válidos da conta.", + "HTTP_ERROR": "O servidor devolveu HTTP {{status}}.", + "TLS_ERROR": "Não foi possível estabelecer uma ligação segura. Verifique a configuração HTTPS e o certificado do servidor.", + "CONNECTION_ERROR": "Servidor inacessível. Verifique o endereço e a ligação à rede.", + "UNKNOWN_ERROR": "Não foi possível testar a ligação.", + "TEST_PROTOCOLS": "Testar HTTPS e HTTP", + "HTTP_PERMISSION": "Se HTTPS não estiver disponível, este teste pode enviar o nome de utilizador e a palavra-passe por HTTP sem encriptação." + } }, "STALKER_PORTAL": { "TITLE": "Título", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 1afdc1a16..cc8ce18b9 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -181,7 +181,22 @@ "PASSWORD": "Пароль", "ADD": "Добавить", "TEST_CREDENTIALS": "Проверить", - "URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com или https://example.com:4324)." + "URL_VALIDATION_ERROR": "Должен быть действительный URL-адрес с протоколом (например, http://example.com или https://example.com:4324).", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Введите имя пользователя и пароль для проверки подключения.", + "TESTING": "Проверка подключения…", + "HTTP_CONNECTED": "Подключено через HTTP. HTTPS недоступен. Соединение не зашифровано.", + "ACTIVE": "Подключено. Портал активен.", + "INACTIVE": "Аккаунт неактивен или неверно указаны логин и пароль.", + "EXPIRED": "Срок подписки истёк.", + "UNAVAILABLE": "Портал не вернул корректные данные аккаунта.", + "HTTP_ERROR": "Сервер вернул HTTP {{status}}.", + "TLS_ERROR": "Не удалось установить защищённое соединение. Проверьте настройки HTTPS и сертификат сервера.", + "CONNECTION_ERROR": "Сервер недоступен. Проверьте адрес и подключение к сети.", + "UNKNOWN_ERROR": "Не удалось проверить подключение.", + "TEST_PROTOCOLS": "Проверить HTTPS и HTTP", + "HTTP_PERMISSION": "Если HTTPS недоступен, эта проверка может отправить логин и пароль по незашифрованному HTTP." + } }, "STALKER_PORTAL": { "TITLE": "Название", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 95e51f2e3..3ab6243bd 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -181,7 +181,22 @@ "PASSWORD": "Parola", "ADD": "Ekle", "TEST_CREDENTIALS": "Kimlik bilgilerini test et", - "URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com veya https://example.com:4324)" + "URL_VALIDATION_ERROR": "Geçerli bir URL olmalıdır (örn. http://example.com veya https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "Bağlantıyı test etmek için kullanıcı adı ve parola girin.", + "TESTING": "Bağlantı test ediliyor…", + "HTTP_CONNECTED": "HTTP üzerinden bağlanıldı. HTTPS kullanılamıyor. Bağlantı şifrelenmemiştir.", + "ACTIVE": "Bağlanıldı. Portal etkin.", + "INACTIVE": "Hesap etkin değil veya giriş bilgileri yanlış.", + "EXPIRED": "Abonelik süresi dolmuş.", + "UNAVAILABLE": "Portal geçerli hesap bilgileri döndürmedi.", + "HTTP_ERROR": "Sunucu HTTP {{status}} döndürdü.", + "TLS_ERROR": "Güvenli bağlantı kurulamadı. Sunucunun HTTPS yapılandırmasını ve sertifikasını kontrol edin.", + "CONNECTION_ERROR": "Sunucuya ulaşılamadı. Adresi ve ağ bağlantınızı kontrol edin.", + "UNKNOWN_ERROR": "Bağlantı test edilemedi.", + "TEST_PROTOCOLS": "HTTPS ve HTTP test et", + "HTTP_PERMISSION": "HTTPS kullanılamıyorsa bu test kullanıcı adınızı ve parolanızı şifrelenmemiş HTTP üzerinden gönderebilir." + } }, "STALKER_PORTAL": { "TITLE": "Başlık", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 6137521d3..7dc4fb7cc 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -181,7 +181,22 @@ "PASSWORD": "密码", "ADD": "添加", "TEST_CREDENTIALS": "测试凭据", - "URL_VALIDATION_ERROR": "应该是带有协议的有效 url(例如 http://example.com 或 https://example.com:4324)" + "URL_VALIDATION_ERROR": "应该是带有协议的有效 url(例如 http://example.com 或 https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "请输入用户名和密码以测试连接。", + "TESTING": "正在测试连接…", + "HTTP_CONNECTED": "已通过 HTTP 连接。HTTPS 不可用。连接未加密。", + "ACTIVE": "已连接。门户处于活动状态。", + "INACTIVE": "账户未激活或登录信息不正确。", + "EXPIRED": "订阅已过期。", + "UNAVAILABLE": "门户未返回有效的账户信息。", + "HTTP_ERROR": "服务器返回 HTTP {{status}}。", + "TLS_ERROR": "无法建立安全连接。请检查服务器的 HTTPS 配置和证书。", + "CONNECTION_ERROR": "无法连接到服务器。请检查地址和网络连接。", + "UNKNOWN_ERROR": "无法测试连接。", + "TEST_PROTOCOLS": "测试 HTTPS 和 HTTP", + "HTTP_PERMISSION": "如果 HTTPS 不可用,此测试可能通过未加密的 HTTP 发送您的用户名和密码。" + } }, "STALKER_PORTAL": { "TITLE": "标题", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 4bb35a938..2e550f5c7 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -181,7 +181,22 @@ "PASSWORD": "密碼", "ADD": "新增", "TEST_CREDENTIALS": "測試憑證", - "URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com 或 https://example.com:4324)" + "URL_VALIDATION_ERROR": "必須是有效的網址並包含協定(例如 http://example.com 或 https://example.com:4324)", + "CONNECTION_TEST": { + "CREDENTIALS_REQUIRED": "請輸入使用者名稱和密碼以測試連線。", + "TESTING": "正在測試連線…", + "HTTP_CONNECTED": "已透過 HTTP 連線。HTTPS 無法使用。連線未加密。", + "ACTIVE": "已連線。入口網站處於啟用狀態。", + "INACTIVE": "帳戶未啟用或登入資訊不正確。", + "EXPIRED": "訂閱已到期。", + "UNAVAILABLE": "入口網站未傳回有效的帳戶資訊。", + "HTTP_ERROR": "伺服器傳回 HTTP {{status}}。", + "TLS_ERROR": "無法建立安全連線。請檢查伺服器的 HTTPS 設定和憑證。", + "CONNECTION_ERROR": "無法連線至伺服器。請檢查位址和網路連線。", + "UNKNOWN_ERROR": "無法測試連線。", + "TEST_PROTOCOLS": "測試 HTTPS 和 HTTP", + "HTTP_PERMISSION": "如果 HTTPS 無法使用,此測試可能透過未加密的 HTTP 傳送您的使用者名稱和密碼。" + } }, "STALKER_PORTAL": { "TITLE": "標題", diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 6657e7ded..fab3045ef 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -48,7 +48,7 @@ Rules: 2. URL username/password credentials are rejected. 3. Leading and trailing whitespace is ignored. 4. Trailing slashes are removed. -5. Full API or playlist URLs ending in `/player_api.php` or `/get.php` are +5. Full API or playlist URLs ending in `/player_api.php`, `/panel_api.php`, or `/get.php` are reduced to the portal base URL. 6. Provider subpaths are preserved. For example, `https://example.test/panel/player_api.php?...` becomes @@ -58,6 +58,55 @@ The Xtream import form may extract `username` and `password` from full `get.php` or `player_api.php` URLs, but stored playlist metadata should keep the normalized `serverUrl` plus trimmed credentials. +### Explicit protocol discovery + +Add and Edit source share `XtreamConnectionTestService` and form-owned +`createXtreamConnectionTestState` (`@iptvnator/services`). The explicit +**Test HTTPS and HTTP** button has a visible, accessible pre-request notice +that credentials may be sent over unencrypted HTTP. Clicking that action supplies +`allowHttpFallback`; the service defaults it to false, so an ordinary programmatic +test cannot authorize plaintext credentials. No modal or persistent opt-in is +needed. The test first probes the entered base, including the existing account-action variants. +Only an initial `ECONNREFUSED` or TLS wrong-version failure permits one HTTP +candidate on the same hostname/path. Default HTTPS port 443 becomes HTTP 80; +nonstandard explicit ports are preserved, never scanned. DNS, timeout, reset, +certificate, HTTP authorization, and redirected-destination failures cannot +trigger a downgrade. Aggregate and nested cause errors require positive +evidence from every address; mixed failures, cycles and truncated error trees +fail closed. TLS verification diagnostics include incomplete certificate chains. +A response from an earlier account-action variant also +prevents downgrade. HTTP failures (including panels returning 500 for unsupported actions) +still try the remaining account actions on the same candidate. + +An active account on HTTP replaces only the form's `serverUrl`, with localized +copy explaining that HTTP is unencrypted. Add/Save persists through the existing +metadata path; Test never writes storage. Edits, reset, destruction and newer +tests invalidate pending results and prevent a stale fallback request. Add/Save +is disabled while that form's test is running. Empty or whitespace-only +credentials produce a localized validation message without a network request. Passive status checks, startup, +refresh and playback never perform protocol discovery. +Every completed current test refreshes `PortalStatusService` for the exact +connection: account responses publish status and expiration; terminal failures +publish unavailable with no expiration, without another network request. +Older passive checks cannot overwrite this explicit evidence; stale form +results do not publish it. + +Both transports return an optional, credential-free `connectionFailure` envelope +only for `connectionTest` requests. Electron returns it rather than throwing +through IPC (which loses custom error fields); the PWA proxy strips the control +parameter before contacting the provider and preserves validated redirect-chain +evidence. PWA URL/DNS-policy refusals and Electron URL/redirect-policy errors are local +connection failures, never reported as provider HTTP statuses or used to +authorize HTTP. Older backends without the envelope cannot authorize HTTP discovery. +Provider JSON remains nested in `payload` and cannot provide this evidence. + +The saved base drives catalog refresh, provider EPG, live/VOD/series/catch-up URL +construction and fresh Favorites/Recent resolution. The routed Xtream session +observes metadata connection changes and bootstraps the new connection. Separate +XMLTV source URLs, provider-supplied absolute URLs and already-issued download +or playback sessions retain their own URLs; they are not rewritten. No database +schema change or migration is required. + ## Account Status Account status handling uses `resolveXtreamPortalStatus`. diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html index ea6305e60..6163ff592 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html @@ -69,6 +69,14 @@ + @if (playlistType() === 'xtream') { +

+ {{ + 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.HTTP_PERMISSION' + | translate + }} +

+ }
+ @if (connectionTest.messageKey()) { +

+ {{ + connectionTest.messageKey() + | translate: connectionTest.messageParams() + }} +

+ } + } @if (playlist.url) { {{ @@ -364,6 +394,7 @@ [disabled]=" !playlistDetails.valid || playlistDetails.pristine || + connectionTest.testing() || isSaving() || isHydratingStalkerPlaylist() || stalkerPlaylistHydrationFailed() diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 49d1b7a00..5cf2e13c9 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -26,6 +26,7 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { firstValueFrom } from 'rxjs'; import { + createXtreamConnectionTestState, DatabaseService, PlaylistsService, RuntimeCapabilitiesService, @@ -296,6 +297,7 @@ export class PlaylistInfoComponent { /** Form group with playlist details */ playlistDetails!: UntypedFormGroup; + readonly connectionTest: ReturnType; constructor() { this.dialogRef?.beforeClosed().subscribe(() => { @@ -303,6 +305,9 @@ export class PlaylistInfoComponent { }); this.playlist = this.playlistData; this.createForm(); + this.connectionTest = createXtreamConnectionTestState( + this.playlistDetails + ); if (this.playlist.portalUrl) { this.isHydratingStalkerPlaylist.set(true); this.stalkerPlaylistHydration = @@ -375,7 +380,7 @@ export class PlaylistInfoComponent { } async saveChanges(playlist: PlaylistMeta): Promise { - if (this.isSaving()) { + if (this.isSaving() || this.connectionTest.testing()) { return; } @@ -607,13 +612,8 @@ export class PlaylistInfoComponent { throw new Error('Failed to update playlist in database'); } - // TODO: circular dependency - /* this.xtreamStore.updatePlaylist({ - name: playlist.title, - username: playlist.username, - password: playlist.password, - serverUrl: playlist.serverUrl, - }); */ + // The metadata action below updates PlaylistContextFacade; the routed + // Xtream session observes connection changes and bootstraps fresh state. } async refreshPlaylistEpgSource(url: string): Promise { diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index 301cb4b04..81d579c41 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -23,3 +23,6 @@ export * from './lib/stream-probe.service'; export * from './lib/vod-source-pin.service'; export * from './lib/epg-source-settings.service'; + +export * from './lib/xtream-connection-test.service'; +export * from './lib/xtream-connection-test-state'; diff --git a/libs/services/src/lib/portal-status.service.ts b/libs/services/src/lib/portal-status.service.ts index a6c0c9ccc..1f8d1220e 100644 --- a/libs/services/src/lib/portal-status.service.ts +++ b/libs/services/src/lib/portal-status.service.ts @@ -119,8 +119,9 @@ export class PortalStatusService { connection.password ); + const cachedAtStart = this.cache.get(cacheKey); if (!options?.skipCache) { - const cached = this.cache.get(cacheKey); + const cached = cachedAtStart; if ( cached && Date.now() - cached.timestamp < PORTAL_STATUS_CACHE_TTL_MS @@ -148,19 +149,29 @@ export class PortalStatusService { connection.password ) .then((details) => { - this.cache.set(cacheKey, { - details, - timestamp: Date.now(), - }); + if (this.inFlight.get(cacheKey) === request) { + this.cache.set(cacheKey, { + details, + timestamp: Date.now(), + }); + } else { + // Existing callers also receive the newer explicit evidence. + const newer = this.cache.get(cacheKey); + return newer && + newer !== cachedAtStart && + Date.now() - newer.timestamp < + PORTAL_STATUS_CACHE_TTL_MS + ? newer.details + : details; + } return details; }) .finally(() => { - this.inFlight.delete(cacheKey); + if (this.inFlight.get(cacheKey) === request) + this.inFlight.delete(cacheKey); }); - if (!options?.skipCache) { - this.inFlight.set(cacheKey, request); - } + this.inFlight.set(cacheKey, request); return request; } @@ -199,9 +210,39 @@ export class PortalStatusService { return cached.details.status; } + /** Publish explicit probe evidence without another network request. */ + rememberXtreamResponse( + serverUrl: string, + username: string, + password: string, + response: XtreamPortalStatusResponseLike | undefined + ): void { + const connection = this.normalizeConnection( + serverUrl, + username, + password + ); + if (!connection) return; + const key = this.buildCacheKey( + connection.serverUrl, + connection.username, + connection.password + ); + // A passive check started before this evidence cannot overwrite it. + this.inFlight.delete(key); + this.cache.set(key, { + details: { + status: resolveXtreamPortalStatus(response), + expiresAtSeconds: resolveXtreamPortalExpiration(response), + }, + timestamp: Date.now(), + }); + } + /** Clear the entire cache. Useful for log-out or debug flows. */ clearStatusCache(): void { this.cache.clear(); + this.inFlight.clear(); } private buildCacheKey( diff --git a/libs/services/src/lib/xtream-connection-test-state.spec.ts b/libs/services/src/lib/xtream-connection-test-state.spec.ts new file mode 100644 index 000000000..51a4d810a --- /dev/null +++ b/libs/services/src/lib/xtream-connection-test-state.spec.ts @@ -0,0 +1,94 @@ +import { + EnvironmentInjector, + Injector, + createEnvironmentInjector, + runInInjectionContext, +} from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { createXtreamConnectionTestState } from './xtream-connection-test-state'; +import { XtreamConnectionTestService } from './xtream-connection-test.service'; + +describe('Xtream form connection test lifecycle', () => { + it.each([ + ['', 'pass'], + ['user', ''], + [' ', 'pass'], + ['user', ' '], + ])( + 'explains empty credentials without requesting the portal: %j / %j', + async (username, password) => { + const test = jest + .fn() + .mockResolvedValue({ + status: 'active', + serverUrl: 'https://panel.test', + usedHttpFallback: false, + }); + const injector = createEnvironmentInjector( + [{ provide: XtreamConnectionTestService, useValue: { test } }], + Injector.NULL as unknown as EnvironmentInjector + ); + const form = new FormGroup({ + serverUrl: new FormControl('https://panel.test'), + username: new FormControl(username), + password: new FormControl(password), + }); + const state = runInInjectionContext(injector, () => + createXtreamConnectionTestState(form) + ); + try { + await state.test(true); + expect(state.messageKey()).toBe( + 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.CREDENTIALS_REQUIRED' + ); + expect(state.testing()).toBe(false); + expect(test).not.toHaveBeenCalled(); + form.patchValue({ username: 'user', password: 'pass' }); + expect(state.messageKey()).toBe(''); + await state.test(true); + expect(state.messageKey()).toBe( + 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.ACTIVE' + ); + expect(test).toHaveBeenCalledTimes(1); + } finally { + injector.destroy(); + } + } + ); + + it('releases the form subscription and ignores in-flight results on destroy', async () => { + let complete!: (result: unknown) => void; + const test = jest.fn( + () => + new Promise((resolve) => { + complete = resolve; + }) + ); + const injector = createEnvironmentInjector( + [{ provide: XtreamConnectionTestService, useValue: { test } }], + Injector.NULL as unknown as EnvironmentInjector + ); + const form = new FormGroup({ + serverUrl: new FormControl('https://panel.test'), + username: new FormControl('user'), + password: new FormControl('pass'), + }); + const subscribe = jest.spyOn(form.valueChanges, 'subscribe'); + const state = runInInjectionContext(injector, () => + createXtreamConnectionTestState(form) + ); + const pending = state.test(true); + expect(subscribe.mock.results[0].value.closed).toBe(false); + injector.destroy(); + expect(subscribe.mock.results[0].value.closed).toBe(true); + complete({ + status: 'active', + serverUrl: 'http://panel.test', + usedHttpFallback: true, + }); + await pending; + expect(form.controls.serverUrl.value).toBe('https://panel.test'); + expect(state.result()).toBeNull(); + expect(test.mock.calls).toHaveLength(1); + }); +}); diff --git a/libs/services/src/lib/xtream-connection-test-state.ts b/libs/services/src/lib/xtream-connection-test-state.ts new file mode 100644 index 000000000..7a4e988fb --- /dev/null +++ b/libs/services/src/lib/xtream-connection-test-state.ts @@ -0,0 +1,86 @@ +import { computed, DestroyRef, inject, Injector, signal } from '@angular/core'; +import { AbstractControl } from '@angular/forms'; +import { + XtreamConnectionTestResult, + XtreamConnectionTestService, + XtreamTestConnection, +} from './xtream-connection-test.service'; + +/** Form-owned state shared by add and edit; closing or editing invalidates work. */ +export function createXtreamConnectionTestState(form: AbstractControl) { + const injector = inject(Injector); + const destroyRef = inject(DestroyRef); + const testing = signal(false); + const result = signal(null); + const credentialsMissing = signal(false); + let generation = 0; + const changes = form.valueChanges.subscribe(() => { + generation++; + result.set(null); + credentialsMissing.set(false); + testing.set(false); + }); + destroyRef.onDestroy(() => { + generation++; + changes.unsubscribe(); + }); + + const messageKey = computed(() => { + if (testing()) return 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.TESTING'; + if (credentialsMissing()) + return 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.CREDENTIALS_REQUIRED'; + const value = result(); + if (!value) return ''; + const key = value.usedHttpFallback + ? 'HTTP_CONNECTED' + : value.failure + ? value.failure.kind.toUpperCase() + '_ERROR' + : value.status.toUpperCase(); + return 'HOME.XTREAM_PLAYLIST.CONNECTION_TEST.' + key; + }); + + return { + testing, + result, + messageKey, + messageParams: computed(() => ({ status: result()?.failure?.status })), + async test(allowHttpFallback = false): Promise { + if (testing()) return; + const connection = form.getRawValue() as XtreamTestConnection; + if (!connection.username?.trim() || !connection.password?.trim()) { + result.set(null); + credentialsMissing.set(true); + return; + } + if (form.invalid) return; + credentialsMissing.set(false); + const owned = ++generation; + const isCurrent = () => + owned === generation && !destroyRef.destroyed; + testing.set(true); + result.set(null); + try { + const response = await injector + .get(XtreamConnectionTestService) + .test(connection, isCurrent, allowHttpFallback); + if (!isCurrent()) return; + if (response.usedHttpFallback) { + form.get('serverUrl')?.setValue(response.serverUrl, { + emitEvent: false, + }); + form.get('serverUrl')?.markAsDirty(); + } + result.set(response); + } catch { + if (isCurrent()) + result.set({ + status: 'unavailable', + serverUrl: connection.serverUrl, + usedHttpFallback: false, + }); + } finally { + if (isCurrent()) testing.set(false); + } + }, + }; +} diff --git a/libs/services/src/lib/xtream-connection-test.service.spec.ts b/libs/services/src/lib/xtream-connection-test.service.spec.ts new file mode 100644 index 000000000..b77445dfe --- /dev/null +++ b/libs/services/src/lib/xtream-connection-test.service.spec.ts @@ -0,0 +1,400 @@ +import { + EnvironmentInjector, + Injector, + createEnvironmentInjector, + runInInjectionContext, +} from '@angular/core'; +import { DataService } from './data.service'; +import { PortalStatusService } from './portal-status.service'; +import { XtreamConnectionTestService } from './xtream-connection-test.service'; + +describe('explicit Xtream connection test', () => { + const connection = { + serverUrl: 'https://panel.test/base/get.php?type=m3u', + username: ' user ', + password: ' pass ', + }; + const active = { payload: { user_info: { auth: 1, status: 'Active' } } }; + const refused = { + connectionFailure: { kind: 'connection', canTryHttp: true }, + }; + let send: jest.Mock; + let probe: jest.Mock; + let service: XtreamConnectionTestService; + let portalStatus: PortalStatusService; + beforeEach(() => { + probe = jest.fn(); + send = jest.fn((type, payload) => + type === 'XTREAM_REQUEST' ? probe(payload) : Promise.resolve() + ); + const injector = createEnvironmentInjector( + [ + PortalStatusService, + { provide: DataService, useValue: { sendIpcEvent: send } }, + ], + Injector.NULL as unknown as EnvironmentInjector + ); + portalStatus = injector.get(PortalStatusService); + service = runInInjectionContext( + injector, + () => new XtreamConnectionTestService() + ); + }); + + it.each(['https://panel.test/base', 'http://panel.test/base'])( + 'replaces stale status and expiration for the successful address %s', + async (serverUrl) => { + probe.mockResolvedValueOnce({ + payload: { user_info: { auth: 0 } }, + }); + await portalStatus.checkPortalStatus(serverUrl, 'user', 'pass'); + if (serverUrl.startsWith('http:')) + probe.mockResolvedValueOnce(refused); + const expires = Math.floor(Date.now() / 1000) + 3600; + probe.mockResolvedValueOnce({ + payload: { + user_info: { + auth: 1, + status: 'Active', + exp_date: String(expires), + }, + }, + }); + await service.test(connection, () => true, true); + const calls = probe.mock.calls.length; + expect( + await portalStatus.checkPortalStatusDetails( + serverUrl, + ' user ', + ' pass ' + ) + ).toEqual({ status: 'active', expiresAtSeconds: expires }); + expect(probe).toHaveBeenCalledTimes(calls); + } + ); + + it('does not publish a response after the form has changed', async () => { + let current = true; + probe.mockImplementation(() => { + current = false; + return active; + }); + await service.test(connection, () => current, true); + expect( + portalStatus.getCachedStatus( + 'https://panel.test/base', + 'user', + 'pass' + ) + ).toBeNull(); + }); + + it('keeps explicit evidence when an older passive check completes later', async () => { + let finish!: (value: unknown) => void; + probe.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const oldCheck = portalStatus.checkPortalStatus( + 'https://panel.test/base', + 'user', + 'pass' + ); + probe.mockResolvedValueOnce(active); + await service.test(connection); + finish({ payload: { user_info: { auth: 0 } } }); + expect(await oldCheck).toBe('active'); + expect( + portalStatus.getCachedStatus( + 'https://panel.test/base', + 'user', + 'pass' + ) + ).toBe('active'); + }); + + it('does not substitute an expired entry while a replacement check is pending', async () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(0); + try { + probe.mockResolvedValueOnce({ + payload: { user_info: { auth: 0 } }, + }); + await portalStatus.checkPortalStatus( + 'https://panel.test/base', + 'user', + 'pass' + ); + now.mockReturnValue(31_000); + let finishOld!: (value: unknown) => void; + let finishNew!: (value: unknown) => void; + let newStarted!: () => void; + const started = new Promise((resolve) => { + newStarted = resolve; + }); + probe.mockImplementationOnce( + () => + new Promise((resolve) => { + finishOld = resolve; + }) + ); + const oldCheck = portalStatus.checkPortalStatus( + 'https://panel.test/base', + 'user', + 'pass' + ); + probe.mockImplementationOnce( + () => + new Promise((resolve) => { + finishNew = resolve; + newStarted(); + }) + ); + const replacement = portalStatus.checkPortalStatus( + 'https://panel.test/base', + 'user', + 'pass', + { skipCache: true } + ); + await started; + finishOld(active); + expect(await oldCheck).toBe('active'); + finishNew(active); + expect(await replacement).toBe('active'); + } finally { + now.mockRestore(); + } + }); + + it.each(['transport', 'http', 'malformed', 'exception'])( + 'replaces cached active evidence after a final %s failure', + async (kind) => { + probe.mockResolvedValueOnce(active); + await portalStatus.checkPortalStatus( + 'https://panel.test/base', + 'user', + 'pass' + ); + if (kind === 'exception') + probe.mockRejectedValue(new Error('offline')); + else + probe.mockResolvedValue( + kind === 'transport' + ? { + connectionFailure: { + kind: 'tls', + canTryHttp: false, + }, + } + : kind === 'http' + ? { + connectionFailure: { + kind: 'http', + status: 500, + canTryHttp: false, + }, + } + : { payload: 'not an account' } + ); + expect( + (await service.test(connection, () => true, true)).status + ).toBe('unavailable'); + const calls = probe.mock.calls.length; + expect( + await portalStatus.checkPortalStatusDetails( + 'https://panel.test/base', + 'user', + 'pass' + ) + ).toEqual({ status: 'unavailable', expiresAtSeconds: null }); + expect(probe).toHaveBeenCalledTimes(calls); + } + ); + + it('preserves action compatibility for legacy IPC exceptions without authorizing HTTP', async () => { + probe + .mockRejectedValueOnce(new Error('legacy action failed')) + .mockResolvedValueOnce(active); + expect((await service.test(connection, () => true, true)).status).toBe( + 'active' + ); + expect(probe.mock.calls[1][0].params).not.toHaveProperty('action'); + probe.mockReset(); + probe + .mockRejectedValueOnce(new Error('legacy action failed')) + .mockResolvedValueOnce(refused); + expect( + (await service.test(connection, () => true, true)).usedHttpFallback + ).toBe(false); + expect(probe.mock.calls.map(([p]) => p.url)).toEqual([ + 'https://panel.test/base', + 'https://panel.test/base', + ]); + }); + + it('does not downgrade after HTTP 500 followed by a transport failure', async () => { + probe + .mockResolvedValueOnce({ + connectionFailure: { + kind: 'http', + status: 500, + canTryHttp: false, + }, + }) + .mockResolvedValueOnce(refused); + expect( + (await service.test(connection, () => true, true)).usedHttpFallback + ).toBe(false); + expect(probe.mock.calls.map(([p]) => p.url)).toEqual([ + 'https://panel.test/base', + 'https://panel.test/base', + ]); + }); + + it('does not publish a failed test after the form has changed', async () => { + probe.mockResolvedValueOnce(active); + await portalStatus.checkPortalStatus( + 'https://panel.test/base', + 'user', + 'pass' + ); + let current = true; + probe.mockImplementation(() => { + current = false; + return refused; + }); + await service.test(connection, () => current, true); + expect( + portalStatus.getCachedStatus( + 'https://panel.test/base', + 'user', + 'pass' + ) + ).toBe('active'); + }); + + it('prefers working HTTPS and makes no HTTP request', async () => { + probe.mockResolvedValue(active); + expect(await service.test(connection, () => true, true)).toMatchObject({ + status: 'active', + usedHttpFallback: false, + serverUrl: 'https://panel.test/base', + }); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('resets each candidate and returns an authenticated HTTP base for saving', async () => { + probe.mockResolvedValueOnce(refused).mockResolvedValueOnce(active); + expect(await service.test(connection, () => true, true)).toMatchObject({ + status: 'active', + serverUrl: 'http://panel.test/base', + usedHttpFallback: true, + }); + expect(probe.mock.calls.map(([p]) => p.url)).toEqual([ + 'https://panel.test/base', + 'http://panel.test/base', + ]); + expect(probe.mock.calls[1][0]).toMatchObject({ + connectionTest: true, + params: { username: 'user', password: 'pass' }, + }); + expect( + send.mock.calls + .filter(([t]) => t === 'CONNECTIVITY_GUARD_RESET') + .map(([, p]) => p.url) + ).toEqual(['https://panel.test/base', 'http://panel.test/base']); + }); + + it.each([ + { connectionFailure: { kind: 'http', status: 403, canTryHttp: false } }, + { connectionFailure: { kind: 'tls', canTryHttp: false } }, + { payload: { user_info: { auth: 0 } } }, + { payload: { user_info: { auth: 1, status: 'Expired' } } }, + { payload: 'challenge' }, + ])( + 'does not downgrade a responsive or uncertified portal', + async (response) => { + probe.mockResolvedValue(response); + const result = await service.test(connection, () => true, true); + expect(result.usedHttpFallback).toBe(false); + expect( + probe.mock.calls.every(([p]) => p.url.startsWith('https:')) + ).toBe(true); + } + ); + + it('still tries account action variants when the panel omits account info', async () => { + probe + .mockResolvedValueOnce({ payload: [] }) + .mockResolvedValueOnce(active); + expect((await service.test(connection, () => true, true)).status).toBe( + 'active' + ); + expect(probe.mock.calls[1][0].params).not.toHaveProperty('action'); + }); + + it('does not replace the address with an inactive fallback', async () => { + probe + .mockResolvedValueOnce(refused) + .mockResolvedValueOnce({ payload: { user_info: { auth: 0 } } }); + expect(await service.test(connection, () => true, true)).toMatchObject({ + status: 'inactive', + usedHttpFallback: false, + serverUrl: 'https://panel.test/base', + }); + }); + + it('does not probe HTTP after the form is edited', async () => { + let current = true; + probe.mockImplementation(() => { + current = false; + return refused; + }); + await service.test(connection, () => current, true); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('fails closed with an old backend or IPC exception', async () => { + probe.mockRejectedValue(new Error('ECONNREFUSED')); + expect( + (await service.test(connection, () => true, true)).usedHttpFallback + ).toBe(false); + expect(probe).toHaveBeenCalledTimes(3); + }); + it('does not downgrade after an earlier account action received a response', async () => { + probe + .mockResolvedValueOnce({ payload: [] }) + .mockResolvedValueOnce(refused); + const result = await service.test(connection, () => true, true); + expect(result.usedHttpFallback).toBe(false); + expect(probe).toHaveBeenCalledTimes(2); + expect( + probe.mock.calls.every(([p]) => p.url.startsWith('https:')) + ).toBe(true); + }); + + it.each([400, 401, 403, 404, 405, 429, 500, 503])( + 'preserves account-action compatibility for an HTTP %i response', + async (status) => { + probe + .mockResolvedValueOnce({ + connectionFailure: { + kind: 'http', + status, + canTryHttp: false, + }, + }) + .mockResolvedValueOnce(active); + expect( + (await service.test(connection, () => true, true)).status + ).toBe('active'); + expect(probe.mock.calls[1][0].params).not.toHaveProperty('action'); + } + ); + it('does not send credentials over HTTP without explicit permission', async () => { + probe.mockResolvedValueOnce(refused).mockResolvedValueOnce(active); + expect((await service.test(connection)).usedHttpFallback).toBe(false); + expect(probe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/services/src/lib/xtream-connection-test.service.ts b/libs/services/src/lib/xtream-connection-test.service.ts new file mode 100644 index 000000000..0d4ef7fa5 --- /dev/null +++ b/libs/services/src/lib/xtream-connection-test.service.ts @@ -0,0 +1,129 @@ +import { Injectable, inject } from '@angular/core'; +import { + normalizeXtreamServerUrl, + resolveXtreamPortalStatus, + XtreamConnectionFailure, + XtreamPortalStatusResponseLike, + XtreamPortalStatusType, + xtreamHttpAlternative, +} from '@iptvnator/shared/interfaces'; +import { DataService } from './data.service'; +import { resetHostConnectivityGuard } from './host-connectivity-reset'; +import { PortalStatusService } from './portal-status.service'; + +export interface XtreamTestConnection { + serverUrl: string; + username: string; + password: string; +} + +export interface XtreamConnectionTestResult { + status: XtreamPortalStatusType; + serverUrl: string; + usedHttpFallback: boolean; + failure?: XtreamConnectionFailure; +} + +@Injectable({ providedIn: 'root' }) +export class XtreamConnectionTestService { + private readonly data = inject(DataService); + private readonly portalStatus = inject(PortalStatusService); + + /** Explicit form action only. Passive status checks never discover protocols. */ + async test( + connection: XtreamTestConnection, + isCurrent: () => boolean = () => true, + allowHttpFallback = false + ): Promise { + const normalized = { + serverUrl: normalizeXtreamServerUrl(connection.serverUrl), + username: connection.username.trim(), + password: connection.password.trim(), + }; + const first = await this.probe(normalized, isCurrent); + const alternative = xtreamHttpAlternative(normalized.serverUrl); + if ( + !allowHttpFallback || + !alternative || + !first.failure?.canTryHttp || + !isCurrent() + ) { + return first; + } + const second = await this.probe( + { ...normalized, serverUrl: alternative }, + isCurrent + ); + // Never replace a user's address with a failed or unauthenticated one. + return second.status === 'active' + ? { ...second, usedHttpFallback: true } + : { ...second, serverUrl: normalized.serverUrl }; + } + + private async probe( + connection: XtreamTestConnection, + isCurrent: () => boolean + ): Promise { + const result: XtreamConnectionTestResult = { + status: 'unavailable', + serverUrl: connection.serverUrl, + usedHttpFallback: false, + }; + await resetHostConnectivityGuard(this.data, connection.serverUrl); + let responseObserved = false; + let accountResponse: XtreamPortalStatusResponseLike | undefined; + try { + for (const action of ['get_account_info', null, 'get_profile']) { + if (!isCurrent()) return result; + try { + const response = await this.data.sendIpcEvent<{ + payload?: XtreamPortalStatusResponseLike; + connectionFailure?: XtreamConnectionFailure; + }>('XTREAM_REQUEST', { + url: connection.serverUrl, + params: { + username: connection.username, + password: connection.password, + ...(action ? { action } : {}), + }, + connectionTest: true, + suppressErrorLog: true, + }); + if (response?.connectionFailure) { + result.failure = { + ...response.connectionFailure, + canTryHttp: + !responseObserved && + response.connectionFailure.canTryHttp, + }; + if (response.connectionFailure.kind === 'http') { + responseObserved = true; + continue; + } + return result; + } + responseObserved = true; + const status = resolveXtreamPortalStatus(response?.payload); + if (status !== 'unavailable') { + accountResponse = response?.payload; + return { ...result, status, failure: undefined }; + } + } catch { + // Legacy IPC exceptions may hide an unsupported account action. + // Try only same-candidate variants; ambiguous evidence can never + // authorize HTTP, including after a later classified failure. + responseObserved = true; + } + } + return result; + } finally { + if (isCurrent()) + this.portalStatus.rememberXtreamResponse( + connection.serverUrl, + connection.username, + connection.password, + accountResponse + ); + } + } +} diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 3371603cd..0a3e32b37 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -96,3 +96,5 @@ export * from './lib/vod-details-adapters'; export * from './lib/vod-details-item.interface'; export * from './lib/catchup-download.interface'; + +export * from './lib/xtream-connection-test'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 4d49d70e4..853b45c7d 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -1,3 +1,4 @@ +import type { XtreamConnectionFailure } from './xtream-connection-test'; import type { CatchupDownloadMetadata, DownloadRecoveryResult, @@ -306,6 +307,7 @@ export interface ElectronBridgeStalkerRequestPayload { } export interface ElectronBridgeXtreamRequestPayload { + connectionTest?: boolean; url: string; params: Record; requestId?: string; @@ -314,6 +316,7 @@ export interface ElectronBridgeXtreamRequestPayload { } export interface ElectronBridgeXtreamResponse { + connectionFailure?: XtreamConnectionFailure; payload: unknown; action: string; } diff --git a/libs/shared/interfaces/src/lib/xtream-connection-test.spec.ts b/libs/shared/interfaces/src/lib/xtream-connection-test.spec.ts new file mode 100644 index 000000000..efc74cbb0 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-connection-test.spec.ts @@ -0,0 +1,178 @@ +import { + describeXtreamConnectionFailure, + xtreamHttpAlternative, +} from './xtream-connection-test'; + +describe('Xtream connection transport evidence', () => { + it.each([ + [ + 'https://panel.test/get.php?username=x&password=y', + 'http://panel.test', + ], + [ + 'https://panel.test:443/base/player_api.php', + 'http://panel.test/base', + ], + ['https://panel.test:8443/base/', 'http://panel.test:8443/base'], + ['http://panel.test', null], + ])('builds only the same-host alternative for %s', (input, expected) => { + expect(xtreamHttpAlternative(input)).toBe(expected); + }); + + it.each(['ECONNREFUSED', 'ERR_SSL_WRONG_VERSION_NUMBER'])( + 'accepts initial %s but excludes redirect failures', + (code) => { + expect( + describeXtreamConnectionFailure({ code }, false).canTryHttp + ).toBe(true); + expect( + describeXtreamConnectionFailure({ code }, true).canTryHttp + ).toBe(false); + } + ); + + it.each([ + 'ssl3_get_record:wrong version number', + 'OPENSSL_internal:WRONG_VERSION_NUMBER', + ])('recognizes a plaintext HTTP listener: %s', (message) => { + expect( + describeXtreamConnectionFailure( + { + code: 'EPROTO', + message, + }, + false + ).canTryHttp + ).toBe(true); + }); + + it.each([ + 'ECONNRESET', + 'ETIMEDOUT', + 'ENOTFOUND', + 'ERR_CANCELED', + 'CERT_HAS_EXPIRED', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'ERR_TLS_CERT_ALTNAME_INVALID', + 'EPROTO', + ])('never authorizes HTTP for %s', (code) => { + expect( + describeXtreamConnectionFailure({ code }, false).canTryHttp + ).toBe(false); + }); + + it.each([ + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'INVALID_CA', + 'HOSTNAME_MISMATCH', + 'PATH_LENGTH_EXCEEDED', + 'INVALID_PURPOSE', + 'UNABLE_TO_GET_CRL', + ])( + 'recognizes certificate verification failure %s without allowing HTTP', + (code) => { + expect(describeXtreamConnectionFailure({ code }, false)).toEqual({ + kind: 'tls', + canTryHttp: false, + }); + } + ); + + it.each([undefined, 'ECONNREFUSED'])( + 'requires all address failures to be positive (aggregate code=%s)', + (code) => { + const refused = { code: 'ECONNREFUSED' }; + const aggregate = (other: unknown) => ({ + code, + errors: [refused, other], + }); + expect( + describeXtreamConnectionFailure(aggregate(refused), false) + .canTryHttp + ).toBe(true); + for (const other of [ + { code: 'ETIMEDOUT' }, + { code: 'ENOTFOUND' }, + {}, + null, + ]) { + expect( + describeXtreamConnectionFailure(aggregate(other), false) + .canTryHttp + ).toBe(false); + } + expect( + describeXtreamConnectionFailure(aggregate(refused), true) + .canTryHttp + ).toBe(false); + } + ); + + it('traverses wrappers and shared aggregate causes without losing TLS or HTTP evidence', () => { + const errors = [{ code: 'ECONNREFUSED' }, { code: 'ECONNREFUSED' }]; + const aggregate = { errors }; + expect( + describeXtreamConnectionFailure({ errors, cause: aggregate }, false) + .canTryHttp + ).toBe(true); + expect( + describeXtreamConnectionFailure( + { + code: 'ECONNREFUSED', + cause: { code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' }, + }, + false + ) + ).toEqual({ kind: 'tls', canTryHttp: false }); + expect( + describeXtreamConnectionFailure( + { cause: { response: { status: 403 }, cause: aggregate } }, + false + ) + ).toEqual({ kind: 'http', status: 403, canTryHttp: false }); + }); + + it('fails closed for empty aggregates, cycles and oversized cause trees', () => { + expect( + describeXtreamConnectionFailure( + { code: 'ECONNREFUSED', errors: [] }, + false + ).canTryHttp + ).toBe(false); + const cycle: { code: string; cause?: unknown } = { + code: 'ECONNREFUSED', + }; + cycle.cause = cycle; + expect(describeXtreamConnectionFailure(cycle, false).canTryHttp).toBe( + false + ); + let deep: unknown = { code: 'ECONNREFUSED' }; + for (let index = 0; index < 100; index++) deep = { cause: deep }; + expect(describeXtreamConnectionFailure(deep, false).canTryHttp).toBe( + false + ); + expect( + describeXtreamConnectionFailure( + { + errors: Array.from({ length: 100 }, () => ({ + code: 'ECONNREFUSED', + })), + }, + false + ).canTryHttp + ).toBe(false); + }); + + it('keeps HTTP status without provider text or credentials', () => { + expect( + describeXtreamConnectionFailure( + { + response: { status: 403 }, + message: 'secret', + code: 'ECONNREFUSED', + }, + false + ) + ).toEqual({ kind: 'http', status: 403, canTryHttp: false }); + }); +}); diff --git a/libs/shared/interfaces/src/lib/xtream-connection-test.ts b/libs/shared/interfaces/src/lib/xtream-connection-test.ts new file mode 100644 index 000000000..c76dfd0a5 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-connection-test.ts @@ -0,0 +1,103 @@ +import { normalizeXtreamServerUrl } from './xtream-portal.utils'; + +export interface XtreamConnectionFailure { + kind: 'http' | 'tls' | 'connection' | 'unknown'; + status?: number; + canTryHttp: boolean; +} + +interface TransportFailureLike { + code?: unknown; + status?: unknown; + message?: unknown; + response?: { status?: unknown }; + errors?: unknown; + cause?: unknown; +} + +const unknownFailure = (): XtreamConnectionFailure => ({ + kind: 'unknown', + canTryHttp: false, +}); +const TLS_VERIFY_CODES = new Set([ + 'EPROTO', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'INVALID_CA', + 'PATH_LENGTH_EXCEEDED', + 'HOSTNAME_MISMATCH', + 'INVALID_PURPOSE', +]); + +/** Small, credential-free evidence produced by the transport, never by a panel. */ +export function describeXtreamConnectionFailure( + error: unknown, + initialResponded: boolean +): XtreamConnectionFailure { + const active = new WeakSet(); + const memo = new WeakMap(); + let remaining = 64; + const visit = (value: unknown): XtreamConnectionFailure => { + if (!value || typeof value !== 'object') return unknownFailure(); + const cached = memo.get(value); + if (cached) return cached; + if (active.has(value) || --remaining < 0) return unknownFailure(); + active.add(value); + const candidate = value as TransportFailureLike; + const own = describeSingleFailure(candidate); + const evidence: XtreamConnectionFailure[] = own ? [own] : []; + // Node may summarize mixed IPv4/IPv6 failures with the first error's + // code. Require positive evidence from every address and nested cause. + if (candidate.errors !== undefined) { + if (Array.isArray(candidate.errors) && candidate.errors.length) { + if (candidate.errors.length > 64) + evidence.push(unknownFailure()); + evidence.push(...candidate.errors.slice(0, 64).map(visit)); + } else evidence.push(unknownFailure()); + } + if (candidate.cause !== undefined) + evidence.push(visit(candidate.cause)); + const detail = + evidence.find((e) => e.kind === 'http') ?? + evidence.find((e) => e.kind === 'tls') ?? + evidence.find((e) => e.kind === 'connection') ?? + unknownFailure(); + const result = { + ...detail, + canTryHttp: + evidence.length > 0 && evidence.every((e) => e.canTryHttp), + }; + active.delete(value); + memo.set(value, result); + return result; + }; + const result = visit(error); + return { ...result, canTryHttp: !initialResponded && result.canTryHttp }; +} + +function describeSingleFailure( + candidate: TransportFailureLike +): XtreamConnectionFailure | null { + const status = candidate.response?.status ?? candidate.status; + if (typeof status === 'number' && status >= 100 && status <= 599) + return { kind: 'http', status, canTryHttp: false }; + const code = typeof candidate.code === 'string' ? candidate.code : ''; + if (!code) return null; + const wrongVersion = + code === 'ERR_SSL_WRONG_VERSION_NUMBER' || + (code === 'EPROTO' && + typeof candidate.message === 'string' && + /wrong[ _]version[ _]number/i.test(candidate.message)); + const tls = /CERT|TLS|SSL|CRL/.test(code) || TLS_VERIFY_CODES.has(code); + return { + kind: tls ? 'tls' : 'connection', + canTryHttp: code === 'ECONNREFUSED' || wrongVersion, + }; +} + +export function xtreamHttpAlternative(serverUrl: string): string | null { + const url = new URL(normalizeXtreamServerUrl(serverUrl)); + if (url.protocol !== 'https:') return null; + // URL normalizes an explicit :443 away. Nonstandard ports stay explicit. + url.protocol = 'http:'; + return normalizeXtreamServerUrl(url.href); +}