mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
feat(stalker): load matching saved credentials
This commit is contained in:
1 parent
0022604c47
commit
c042af85db
2 files changed
+298
No files matched your search
+109
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
|
||||
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<StalkerAuthCredentials | undefined> {
|
||||
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<Record<string, unknown>>,
|
||||
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<Record<string, unknown>>,
|
||||
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<Record<string, unknown>>,
|
||||
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<Record<string, unknown>>
|
||||
): 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<Record<string, unknown>>
|
||||
): 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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
Reference in new issue
Block a user