diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.spec.ts new file mode 100644 index 000000000..1b475e87b --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.spec.ts @@ -0,0 +1,109 @@ +import type { StalkerSessionConnectionDescriptor } from '@iptvnator/shared/interfaces'; +import { createStalkerSavedCredentialsLoader } from './stalker-saved-credentials'; + +const DESCRIPTOR: StalkerSessionConnectionDescriptor = { + connectionMode: 'persisted-open', + identityOverrides: { + deviceId1: 'DEVICE-1', + serialNumber: 'SERIAL-1', + }, + learnedEndpointHint: + 'https://portal.example/stalker_portal/server/load.php', + macAddress: '00:1A:79:12:34:56', + playlistRef: 'playlist-1', + profilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + sourceUrl: 'https://portal.example/c/', +}; + +const SAVED_PLAYLIST = { + _id: 'playlist-1', + macAddress: '00-1a-79-12-34-56', + password: 'saved password', + stalkerIdentityOverrides: { + deviceId1: 'DEVICE-1', + serialNumber: 'SERIAL-1', + }, + stalkerProfilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + stalkerSourceUrl: 'https://portal.example/c/#ignored', + type: 'stalker', + username: ' saved-user ', +}; + +describe('createStalkerSavedCredentialsLoader', () => { + it('returns an exact matching Stalker credential pair without exposing the row', async () => { + const readPlaylist = jest.fn().mockResolvedValue(SAVED_PLAYLIST); + const load = createStalkerSavedCredentialsLoader(readPlaylist); + + await expect(load(DESCRIPTOR)).resolves.toEqual({ + password: 'saved password', + username: 'saved-user', + }); + expect(readPlaylist).toHaveBeenCalledWith('playlist-1'); + }); + + it.each([ + ['source', { stalkerSourceUrl: 'https://other.example/c/' }], + ['MAC', { macAddress: '00:1A:79:AA:BB:CC' }], + [ + 'profile', + { + stalkerProfilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 2, + }, + }, + ], + [ + 'identity', + { + stalkerIdentityOverrides: { + deviceId1: 'OTHER-DEVICE', + serialNumber: 'SERIAL-1', + }, + }, + ], + ['provider type', { type: 'xtream' }], + ])('rejects a descriptor/%s mismatch', async (_label, patch) => { + const load = createStalkerSavedCredentialsLoader(async () => ({ + ...SAVED_PLAYLIST, + ...patch, + })); + + await expect(load(DESCRIPTOR)).resolves.toBeUndefined(); + }); + + it.each([ + ['missing row', null], + ['missing username', { ...SAVED_PLAYLIST, username: undefined }], + ['blank username', { ...SAVED_PLAYLIST, username: ' ' }], + ['missing password', { ...SAVED_PLAYLIST, password: undefined }], + ['empty password', { ...SAVED_PLAYLIST, password: '' }], + ])('ignores %s', async (_label, row) => { + const load = createStalkerSavedCredentialsLoader(async () => row); + + await expect(load(DESCRIPTOR)).resolves.toBeUndefined(); + }); + + it('matches legacy identity columns when the structured override is absent', async () => { + const { + stalkerIdentityOverrides: _structured, + ...legacyPlaylist + } = SAVED_PLAYLIST; + const load = createStalkerSavedCredentialsLoader(async () => ({ + ...legacyPlaylist, + stalkerDeviceId1: 'DEVICE-1', + stalkerSerialNumber: 'SERIAL-1', + })); + + await expect(load(DESCRIPTOR)).resolves.toEqual({ + password: 'saved password', + username: 'saved-user', + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.ts new file mode 100644 index 000000000..f06ac7e8d --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-saved-credentials.ts @@ -0,0 +1,189 @@ +import { + normalizeStalkerMacAddress, + normalizeStalkerSourceUrl, +} from '@iptvnator/portal/stalker/protocol'; +import { + LEGACY_DEFAULT_STALKER_SERIAL, + type StalkerSessionConnectionDescriptor, + type StalkerSessionIdentityOverrides, +} from '@iptvnator/shared/interfaces'; +import type { StalkerAuthCredentials } from './stalker-auth-session'; + +type SavedPlaylistReader = ( + playlistRef: string +) => Promise; + +const IDENTITY_KEYS = [ + 'apiSignature', + 'deviceId1', + 'deviceId2', + 'firmwareVersion', + 'hardwareVersion', + 'hardwareVersion2', + 'imageVersion', + 'numberOfBanks', + 'prehash', + 'serialNumber', + 'signature1', + 'signature2', + 'videoOutput', +] as const satisfies readonly (keyof StalkerSessionIdentityOverrides)[]; + +export function createStalkerSavedCredentialsLoader( + readPlaylist: SavedPlaylistReader +): ( + descriptor: StalkerSessionConnectionDescriptor +) => Promise { + return async (descriptor) => { + const value = await readPlaylist(descriptor.playlistRef); + if (!isRecord(value) || !matchesDescriptor(value, descriptor)) { + return undefined; + } + const username = presentString(value['username'])?.trim(); + const password = value['password']; + if ( + username === undefined || + typeof password !== 'string' || + password.length === 0 + ) { + return undefined; + } + return { + password, + username, + }; + }; +} + +function matchesDescriptor( + playlist: Readonly>, + descriptor: StalkerSessionConnectionDescriptor +): boolean { + if ( + (playlist['type'] !== undefined && + playlist['type'] !== 'stalker') || + !matchesSource(playlist, descriptor.sourceUrl) || + !matchesMac(playlist['macAddress'], descriptor.macAddress) || + !matchesProfile(playlist['stalkerProfilePreset'], descriptor) || + !matchesIdentity(playlist, descriptor.identityOverrides) + ) { + return false; + } + return true; +} + +function matchesSource( + playlist: Readonly>, + descriptorSourceUrl: string +): boolean { + const source = firstPresentString( + playlist['stalkerSourceUrl'], + playlist['portalUrl'], + playlist['url'] + ); + if (source === undefined) { + return false; + } + try { + return ( + normalizeStalkerSourceUrl(source) === + normalizeStalkerSourceUrl(descriptorSourceUrl) + ); + } catch { + return false; + } +} + +function matchesMac(value: unknown, descriptorMac: string): boolean { + if (typeof value !== 'string') { + return false; + } + try { + return ( + normalizeStalkerMacAddress(value) === + normalizeStalkerMacAddress(descriptorMac) + ); + } catch { + return false; + } +} + +function matchesProfile( + value: unknown, + descriptor: StalkerSessionConnectionDescriptor +): boolean { + if (value === undefined) { + return ( + descriptor.profilePreset.id === + 'mag250-public-5_1-minimal-v1' && + descriptor.profilePreset.version === 1 + ); + } + return ( + isRecord(value) && + value['id'] === descriptor.profilePreset.id && + value['version'] === descriptor.profilePreset.version + ); +} + +function matchesIdentity( + playlist: Readonly>, + descriptorIdentity: StalkerSessionIdentityOverrides | undefined +): boolean { + const saved = readSavedIdentity(playlist); + const descriptor = descriptorIdentity ?? {}; + return IDENTITY_KEYS.every( + (key) => saved[key] === presentString(descriptor[key]) + ); +} + +function readSavedIdentity( + playlist: Readonly> +): StalkerSessionIdentityOverrides { + const structured = playlist['stalkerIdentityOverrides']; + if (isRecord(structured)) { + return copyIdentity(structured); + } + const serialNumber = presentString(playlist['stalkerSerialNumber']); + return copyIdentity({ + deviceId1: playlist['stalkerDeviceId1'], + deviceId2: playlist['stalkerDeviceId2'], + serialNumber: + serialNumber?.toUpperCase() === LEGACY_DEFAULT_STALKER_SERIAL + ? undefined + : serialNumber, + signature1: playlist['stalkerSignature1'], + signature2: playlist['stalkerSignature2'], + }); +} + +function copyIdentity( + value: Readonly> +): StalkerSessionIdentityOverrides { + return Object.fromEntries( + IDENTITY_KEYS.flatMap((key) => { + const present = presentString(value[key]); + return present === undefined ? [] : [[key, present]]; + }) + ) as StalkerSessionIdentityOverrides; +} + +function firstPresentString(...values: readonly unknown[]): string | undefined { + for (const value of values) { + const present = presentString(value); + if (present !== undefined) { + return present; + } + } + return undefined; +} + +function presentString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 + ? value + : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}