mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(stalker): identity hardening (#1370)
MAC addresses are canonicalized to the uppercase colon form a real STB
sends and validated at the input boundary, with a hint when they fall
outside Infomir's OUI — which the stock server's default filter refuses
with a bare {status: 1} no user could diagnose. Normalization applies
only to a value the user actually edits: rewriting stored bytes would
move the session fingerprint for every existing playlist with no user
action, and the MAC is the account key.
Device IDs can optionally be derived from the MAC the way StbEmu and
stalker-to-m3u do — SHA256(MAC) and SHA256(MAC + "stalker"), which a
real box never reports as equal. The portal pins the first non-empty
device_id/device_id2 it sees to the MAC permanently, refuses a different
one, and treats a later empty value as an unrecoverable lockout, so
derived values are written into the visible fields and persisted as
literal strings, never recomputed at request time. The option is offered
at import only; the edit dialog warns instead once an ID has actually
reached the portal.
get_profile now reports one coherent MAG250 (ver, stb_type — previously
empty —, hw_version, image_version, client_type), and a device conflict
gets its own StalkerPortalError kind so the UI can explain it instead of
relaying the portal's "Your STB is damaged".
Closes the identity-fields cluster: #927, #860.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7111942509
commit
9ff1c6ae01
44 files changed
+2326
-72
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import type { StalkerPortalErrorKind } from '@iptvnator/portal/stalker/data-access';
|
||||
import type { StalkerPortalIdentity } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** The `stalker*` playlist columns the import form writes. */
|
||||
export interface StalkerPlaylistIdentityFields {
|
||||
stalkerSerialNumber?: string;
|
||||
stalkerDeviceId1?: string;
|
||||
stalkerDeviceId2?: string;
|
||||
stalkerSignature1?: string;
|
||||
stalkerSignature2?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the form's identity object onto the playlist's `stalker*` columns,
|
||||
* omitting every empty field. Absence is meaningful: an identity value the
|
||||
* portal has already pinned must keep being sent, and one it has not seen must
|
||||
* keep being absent — writing `''` instead of omitting would send an empty
|
||||
* `device_id`, which is how an account gets locked out permanently.
|
||||
*/
|
||||
export function toStalkerPlaylistIdentityFields(
|
||||
identity: StalkerPortalIdentity
|
||||
): StalkerPlaylistIdentityFields {
|
||||
return {
|
||||
...(identity.serialNumber
|
||||
? { stalkerSerialNumber: identity.serialNumber }
|
||||
: {}),
|
||||
...(identity.deviceId1
|
||||
? { stalkerDeviceId1: identity.deviceId1 }
|
||||
: {}),
|
||||
...(identity.deviceId2
|
||||
? { stalkerDeviceId2: identity.deviceId2 }
|
||||
: {}),
|
||||
...(identity.signature1
|
||||
? { stalkerSignature1: identity.signature1 }
|
||||
: {}),
|
||||
...(identity.signature2
|
||||
? { stalkerSignature2: identity.signature2 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Headline shown for each way a portal can refuse the import. */
|
||||
export const STALKER_IMPORT_ERROR_KEY_BY_KIND: Readonly<
|
||||
Record<StalkerPortalErrorKind, string>
|
||||
> = {
|
||||
'login-required': 'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
|
||||
'login-rejected': 'HOME.STALKER_PORTAL.LOGIN_REJECTED',
|
||||
'device-conflict': 'HOME.STALKER_PORTAL.DEVICE_CONFLICT',
|
||||
blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
|
||||
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
|
||||
};
|
||||
+27
-1
@@ -20,7 +20,7 @@
|
||||
}}</mat-hint>
|
||||
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-form-field class="w-full" subscriptSizing="dynamic">
|
||||
<mat-label for="macAddress">{{
|
||||
'HOME.STALKER_PORTAL.MAC_ADDRESS' | translate
|
||||
}}</mat-label>
|
||||
@@ -29,7 +29,20 @@
|
||||
type="text"
|
||||
id="macAddress"
|
||||
formControlName="macAddress"
|
||||
(blur)="onMacAddressBlur()"
|
||||
/>
|
||||
@if (showsForeignOuiHint) {
|
||||
<mat-hint>{{
|
||||
'HOME.STALKER_PORTAL.MAC_ADDRESS_OUI_HINT' | translate
|
||||
}}</mat-hint>
|
||||
} @else {
|
||||
<mat-hint>{{
|
||||
'HOME.STALKER_PORTAL.MAC_ADDRESS_HINT' | translate
|
||||
}}</mat-hint>
|
||||
}
|
||||
<mat-error>{{
|
||||
'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label for="serialNumber">{{
|
||||
@@ -45,6 +58,19 @@
|
||||
'HOME.STALKER_PORTAL.SERIAL_NUMBER_HINT' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
<div class="derive-device-ids">
|
||||
<mat-checkbox
|
||||
[checked]="derivesDeviceIds()"
|
||||
[disabled]="hasManualDeviceIds"
|
||||
(change)="toggleDeriveDeviceIds($event.checked)"
|
||||
>{{
|
||||
'HOME.STALKER_PORTAL.DERIVE_DEVICE_IDS' | translate
|
||||
}}</mat-checkbox
|
||||
>
|
||||
<p class="derive-device-ids__note">
|
||||
{{ 'HOME.STALKER_PORTAL.DERIVE_DEVICE_IDS_HINT' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label for="deviceId1">{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
|
||||
|
||||
+554
@@ -1,3 +1,4 @@
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -16,6 +17,23 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
let store: { dispatch: jest.Mock };
|
||||
let snackBar: { open: jest.Mock };
|
||||
|
||||
// jsdom ships no WebCrypto. Install Node's real implementation rather
|
||||
// than a stub, so the derived device IDs asserted below are the values a
|
||||
// portal would actually be handed.
|
||||
const originalCrypto = globalThis.crypto;
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: webcrypto,
|
||||
});
|
||||
});
|
||||
afterAll(() => {
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: originalCrypto,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
portalDiscovery = {
|
||||
discover: jest.fn().mockResolvedValue({
|
||||
@@ -329,6 +347,542 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('MAC address handling', () => {
|
||||
it('canonicalizes the typed MAC on blur', async () => {
|
||||
component.form.patchValue({ macAddress: '00-1a-79-ab-cd-ef' });
|
||||
|
||||
await component.onMacAddressBlur();
|
||||
|
||||
expect(component.form.controls.macAddress.value).toBe(
|
||||
'00:1A:79:AB:CD:EF'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a malformed MAC before anything reaches the portal', async () => {
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-bad-mac',
|
||||
title: 'Typo Portal',
|
||||
macAddress: '00:1A:79:AA:BB',
|
||||
portalUrl: 'https://portal.example.com/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(component.form.controls.macAddress.valid).toBe(false);
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(portalDiscovery.discover).not.toHaveBeenCalled();
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('canonicalizes a submitted MAC that was never blurred', async () => {
|
||||
// Keyboard users can reach Add without the field losing focus.
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-unblurred',
|
||||
title: 'Panel',
|
||||
macAddress: '001a79aabbcc',
|
||||
portalUrl: 'https://portal.example.com/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(portalDiscovery.discover).toHaveBeenCalledWith(
|
||||
'https://portal.example.com/c',
|
||||
'00:1A:79:AA:BB:CC',
|
||||
expect.any(Object),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(store.dispatch.mock.calls[0][0].playlist.macAddress).toBe(
|
||||
'00:1A:79:AA:BB:CC'
|
||||
);
|
||||
});
|
||||
|
||||
it('imports a MAC outside the Infomir range', async () => {
|
||||
// The stock portal's OUI filter is off on most reseller panels, so
|
||||
// a non-Infomir MAC is a working setup for a lot of users. The
|
||||
// hint explains what stock Ministra will do; it must not block.
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-foreign-oui',
|
||||
title: 'Reseller Panel',
|
||||
macAddress: 'AA:BB:CC:DD:EE:01',
|
||||
portalUrl: 'https://panel.example.com/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(component.form.controls.macAddress.valid).toBe(true);
|
||||
expect(component.showsForeignOuiHint).toBe(true);
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(portalDiscovery.discover).toHaveBeenCalledWith(
|
||||
'https://panel.example.com/c',
|
||||
'AA:BB:CC:DD:EE:01',
|
||||
expect.any(Object),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(store.dispatch.mock.calls[0][0].playlist.macAddress).toBe(
|
||||
'AA:BB:CC:DD:EE:01'
|
||||
);
|
||||
});
|
||||
|
||||
it('hints only when the MAC is valid but outside the Infomir range', () => {
|
||||
expect(component.showsForeignOuiHint).toBe(false);
|
||||
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
|
||||
expect(component.showsForeignOuiHint).toBe(false);
|
||||
|
||||
component.form.patchValue({ macAddress: '00:1B:79:AA:BB:CC' });
|
||||
expect(component.showsForeignOuiHint).toBe(true);
|
||||
|
||||
// A half-typed address is an error, not an OUI warning.
|
||||
component.form.patchValue({ macAddress: '00:1B' });
|
||||
expect(component.showsForeignOuiHint).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device ID derivation', () => {
|
||||
// Uppercase hex SHA-256 of the canonical MAC, and of that MAC plus
|
||||
// the `stalker` salt — the values StbEmu and stalker-to-m3u pin
|
||||
// server-side. Asserted literally: matching those clients byte for
|
||||
// byte is the entire point of the option.
|
||||
const DERIVED_FOR_AABBCC = {
|
||||
deviceId1:
|
||||
'21DA59C248805FDF0F36FA2C4CA4569E10D1F80268D8104C7AF8BB776D657ED8',
|
||||
deviceId2:
|
||||
'C6BA0906206A93A6CC4B6C2E94AC92EBC1A217784B692979DB373FABE0B3D2F5',
|
||||
};
|
||||
|
||||
it('fills both device IDs with the StbEmu-compatible pair', async () => {
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
|
||||
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
|
||||
expect(component.form.controls.deviceId1.value).toBe(
|
||||
DERIVED_FOR_AABBCC.deviceId1
|
||||
);
|
||||
// A real box reports the two from separate firmware calls and they
|
||||
// are never equal; the portal pins them permanently, so an
|
||||
// identical pair could not be corrected later.
|
||||
expect(component.form.controls.deviceId2.value).toBe(
|
||||
DERIVED_FOR_AABBCC.deviceId2
|
||||
);
|
||||
expect(component.form.controls.deviceId2.value).not.toBe(
|
||||
component.form.controls.deviceId1.value
|
||||
);
|
||||
// Derived values are shown, not hidden state — but they are not
|
||||
// hand-editable while derivation owns them.
|
||||
expect(component.form.controls.deviceId1.disabled).toBe(true);
|
||||
expect(component.form.controls.deviceId2.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('persists the derived IDs as literal values', async () => {
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-derived',
|
||||
title: 'Derived Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
// Disabled controls still have to reach the playlist, and they
|
||||
// have to arrive as strings — nothing may recompute them later,
|
||||
// when a MAC edit would turn them into a device conflict.
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.stalkerDeviceId1).toBe(
|
||||
DERIVED_FOR_AABBCC.deviceId1
|
||||
);
|
||||
expect(playlist.stalkerDeviceId2).toBe(
|
||||
DERIVED_FOR_AABBCC.deviceId2
|
||||
);
|
||||
});
|
||||
|
||||
it('follows a corrected MAC while the box is still ticked', async () => {
|
||||
// Nothing is pinned until the import actually runs, so fixing a
|
||||
// typo has to fix the ID it would bind the account to.
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CD' });
|
||||
await component.onMacAddressBlur();
|
||||
|
||||
expect(component.form.controls.deviceId1.value).toBe(
|
||||
'A1474C4E43345F99C018F151C2D401A0231CFADC310E2514944641590F9C4504'
|
||||
);
|
||||
expect(component.form.controls.deviceId2.value).toBe(
|
||||
'EF401CECA8498585809B8D0FC20640A51148B72343D39DBC0A442AD26ED7A8DD'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not submit a MAC paired with the previous MAC\'s IDs', async () => {
|
||||
// Clicking Add blurs the MAC field, so the blur's SHA-256 is
|
||||
// still in flight when the click handler runs. Snapshotting the
|
||||
// form there pairs the corrected MAC with the old MAC's device
|
||||
// IDs — and the portal pins that pairing permanently.
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-race',
|
||||
title: 'Race Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CD' });
|
||||
const blur = component.onMacAddressBlur();
|
||||
await component.addPlaylist();
|
||||
await blur;
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.macAddress).toBe('00:1A:79:AA:BB:CD');
|
||||
expect(playlist.stalkerDeviceId1).toBe(
|
||||
'A1474C4E43345F99C018F151C2D401A0231CFADC310E2514944641590F9C4504'
|
||||
);
|
||||
expect(playlist.stalkerDeviceId2).toBe(
|
||||
'EF401CECA8498585809B8D0FC20640A51148B72343D39DBC0A442AD26ED7A8DD'
|
||||
);
|
||||
});
|
||||
|
||||
it('discards a derivation the next MAC edit superseded', async () => {
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
|
||||
// Two digests end up in flight at once, and nothing guarantees
|
||||
// they settle in the order they started. Node resolves them in
|
||||
// order for inputs this small, which would let this test pass
|
||||
// with no guard at all — so the older pair is explicitly held
|
||||
// back until the newer one has landed.
|
||||
const realDigest = webcrypto.subtle.digest.bind(webcrypto.subtle);
|
||||
let call = 0;
|
||||
const digest = jest
|
||||
.spyOn(globalThis.crypto.subtle, 'digest')
|
||||
.mockImplementation((async (
|
||||
algorithm: AlgorithmIdentifier,
|
||||
data: BufferSource
|
||||
) => {
|
||||
// One derivation is two digests, so the first invocation
|
||||
// is calls 0 and 1.
|
||||
const delayed = call++ < 2;
|
||||
const result = await realDigest(algorithm, data);
|
||||
if (delayed) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 20)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}) as typeof globalThis.crypto.subtle.digest);
|
||||
|
||||
try {
|
||||
const superseded = component.onMacAddressBlur();
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CD' });
|
||||
const latest = component.onMacAddressBlur();
|
||||
await Promise.all([latest, superseded]);
|
||||
} finally {
|
||||
digest.mockRestore();
|
||||
}
|
||||
|
||||
expect(component.form.controls.deviceId1.value).toBe(
|
||||
'A1474C4E43345F99C018F151C2D401A0231CFADC310E2514944641590F9C4504'
|
||||
);
|
||||
expect(component.form.controls.deviceId2.value).toBe(
|
||||
'EF401CECA8498585809B8D0FC20640A51148B72343D39DBC0A442AD26ED7A8DD'
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Holds every digest until `release()` is called, so a toggle can be
|
||||
* observed landing WHILE one is in flight. Without this the digest
|
||||
* settles first and the assertions below hold either way.
|
||||
*/
|
||||
function holdDigests(): {
|
||||
release: () => void;
|
||||
restore: () => void;
|
||||
} {
|
||||
const realDigest = webcrypto.subtle.digest.bind(webcrypto.subtle);
|
||||
let release = (): void => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const spy = jest
|
||||
.spyOn(globalThis.crypto.subtle, 'digest')
|
||||
.mockImplementation((async (
|
||||
algorithm: AlgorithmIdentifier,
|
||||
data: BufferSource
|
||||
) => {
|
||||
await gate;
|
||||
return realDigest(algorithm, data);
|
||||
}) as typeof globalThis.crypto.subtle.digest);
|
||||
|
||||
return { release, restore: () => spy.mockRestore() };
|
||||
}
|
||||
|
||||
it('does not repopulate the fields when the box is unticked mid-digest', async () => {
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
|
||||
const { release, restore } = holdDigests();
|
||||
|
||||
try {
|
||||
const pending = component.toggleDeriveDeviceIds(true);
|
||||
await component.toggleDeriveDeviceIds(false);
|
||||
release();
|
||||
await pending;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// The user opted out; a digest that was already running must not
|
||||
// put IDs back that the portal would then pin permanently.
|
||||
expect(component.derivesDeviceIds()).toBe(false);
|
||||
expect(component.form.controls.deviceId1.value).toBe('');
|
||||
expect(component.form.controls.deviceId2.value).toBe('');
|
||||
});
|
||||
|
||||
it('does not repopulate the fields when the form is cleared mid-digest', async () => {
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
|
||||
const { release, restore } = holdDigests();
|
||||
|
||||
try {
|
||||
const pending = component.toggleDeriveDeviceIds(true);
|
||||
component.clearForm();
|
||||
release();
|
||||
await pending;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
expect(component.form.controls.deviceId1.value).toBe('');
|
||||
expect(component.form.controls.deviceId2.value).toBe('');
|
||||
});
|
||||
|
||||
it('leaves the fields alone once the box is unticked', async () => {
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
await component.toggleDeriveDeviceIds(false);
|
||||
|
||||
expect(component.form.controls.deviceId1.value).toBe('');
|
||||
expect(component.form.controls.deviceId1.enabled).toBe(true);
|
||||
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CD' });
|
||||
await component.onMacAddressBlur();
|
||||
|
||||
expect(component.form.controls.deviceId1.value).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the identity it authenticated with when unticked mid-import', async () => {
|
||||
// Discovery has already sent these IDs to the portal by the time a
|
||||
// slow answer comes back, so the portal has pinned them. Persisting
|
||||
// what the user unticked to instead — nothing — is the permanent
|
||||
// lockout, so the snapshot has to win. The toggle is locked while
|
||||
// the import runs precisely so the UI cannot imply otherwise.
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-slow-discovery',
|
||||
title: 'Slow Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
|
||||
let finishDiscovery = (): void => undefined;
|
||||
portalDiscovery.discover.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishDiscovery = () =>
|
||||
resolve({
|
||||
status: 'resolved',
|
||||
portalUrl: 'https://portal.example.com/c',
|
||||
isFullStalkerPortal: false,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const importing = component.addPlaylist();
|
||||
// Submitting settles the derivation first, so several microtasks
|
||||
// pass before discovery is reached; poll rather than guess.
|
||||
for (
|
||||
let i = 0;
|
||||
i < 100 && portalDiscovery.discover.mock.calls.length === 0;
|
||||
i += 1
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
|
||||
expect(portalDiscovery.discover).toHaveBeenCalledTimes(1);
|
||||
expect(component.isLoading()).toBe(true);
|
||||
expect(component.hasManualDeviceIds).toBe(true);
|
||||
|
||||
// Even if the toggle were reachable, the import must not change.
|
||||
await component.toggleDeriveDeviceIds(false);
|
||||
finishDiscovery();
|
||||
await importing;
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.stalkerDeviceId1).toBe(
|
||||
DERIVED_FOR_AABBCC.deviceId1
|
||||
);
|
||||
expect(playlist.stalkerDeviceId2).toBe(
|
||||
DERIVED_FOR_AABBCC.deviceId2
|
||||
);
|
||||
});
|
||||
|
||||
it('never pairs one MAC with another MAC\'s device IDs', async () => {
|
||||
// The submit-time digest is asynchronous, so a MAC edit can land
|
||||
// while it runs. Reading the MAC from the form afterwards would
|
||||
// ship the new address with the old address's IDs — a mismatch
|
||||
// the portal pins permanently as a device conflict.
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-desync',
|
||||
title: 'Desync Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
|
||||
const { release, restore } = holdDigests();
|
||||
try {
|
||||
const importing = component.addPlaylist();
|
||||
await Promise.resolve();
|
||||
// Simulates the field changing mid-digest.
|
||||
component.form.controls.macAddress.setValue(
|
||||
'00:1A:79:AA:BB:CD'
|
||||
);
|
||||
release();
|
||||
await importing;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.macAddress).toBe('00:1A:79:AA:BB:CC');
|
||||
expect(playlist.stalkerDeviceId1).toBe(
|
||||
DERIVED_FOR_AABBCC.deviceId1
|
||||
);
|
||||
expect(playlist.stalkerDeviceId2).toBe(
|
||||
DERIVED_FOR_AABBCC.deviceId2
|
||||
);
|
||||
expect(portalDiscovery.discover).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'00:1A:79:AA:BB:CC',
|
||||
expect.objectContaining({
|
||||
deviceId1: DERIVED_FOR_AABBCC.deviceId1,
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('freezes the identity fields while the import runs', async () => {
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-frozen',
|
||||
title: 'Frozen Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
|
||||
let finishDiscovery = (): void => undefined;
|
||||
portalDiscovery.discover.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishDiscovery = () =>
|
||||
resolve({
|
||||
status: 'unreachable',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const importing = component.addPlaylist();
|
||||
for (
|
||||
let i = 0;
|
||||
i < 100 && portalDiscovery.discover.mock.calls.length === 0;
|
||||
i += 1
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
|
||||
expect(component.form.controls.macAddress.disabled).toBe(true);
|
||||
expect(component.form.controls.title.disabled).toBe(true);
|
||||
|
||||
finishDiscovery();
|
||||
await importing;
|
||||
|
||||
// Restored afterwards, with derivation keeping its own lock.
|
||||
expect(component.form.controls.macAddress.enabled).toBe(true);
|
||||
expect(component.form.controls.deviceId1.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to overwrite a hand-entered device ID', () => {
|
||||
expect(component.hasManualDeviceIds).toBe(false);
|
||||
|
||||
component.form.patchValue({ deviceId1: 'PROVIDER-SUPPLIED' });
|
||||
|
||||
expect(component.hasManualDeviceIds).toBe(true);
|
||||
});
|
||||
|
||||
it('derives nothing while the MAC is unusable', async () => {
|
||||
component.form.patchValue({ macAddress: 'not-a-mac' });
|
||||
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
|
||||
// Hashing a typo would pin the account to it permanently.
|
||||
expect(component.form.controls.deviceId1.value).toBe('');
|
||||
expect(component.form.controls.deviceId2.value).toBe('');
|
||||
});
|
||||
|
||||
it('clears derivation state on form reset', async () => {
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
|
||||
component.clearForm();
|
||||
|
||||
expect(component.derivesDeviceIds()).toBe(false);
|
||||
expect(component.form.controls.deviceId1.enabled).toBe(true);
|
||||
expect(component.form.controls.deviceId1.value).toBe('');
|
||||
});
|
||||
|
||||
it('is deterministic across input formatting', async () => {
|
||||
component.form.patchValue({ macAddress: '00:1A:79:AA:BB:CC' });
|
||||
await component.toggleDeriveDeviceIds(true);
|
||||
const canonical = component.form.controls.deviceId1.value;
|
||||
|
||||
component.form.patchValue({ macAddress: '00-1a-79-aa-bb-cc' });
|
||||
await component.onMacAddressBlur();
|
||||
|
||||
expect(canonical).toBe(DERIVED_FOR_AABBCC.deviceId1);
|
||||
expect(component.form.controls.deviceId1.value).toBe(canonical);
|
||||
});
|
||||
});
|
||||
|
||||
it('gives a device conflict its own headline', async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'auth-rejected',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
error: new StalkerPortalError(
|
||||
'device-conflict',
|
||||
'device conflict - device_id mismatch'
|
||||
),
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-conflict',
|
||||
title: 'Conflicting Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.STALKER_PORTAL.DEVICE_CONFLICT HOME.STALKER_PORTAL.PORTAL_MESSAGE',
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a query-carrying /c URL in the unreachable-host fallback', async () => {
|
||||
// Offline panel: discovery finds nothing, the legacy guess imports
|
||||
// anyway — but the suffix rewrite must run on the PATH, or
|
||||
|
||||
+257
-43
@@ -6,6 +6,7 @@ import {
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
@@ -18,20 +19,39 @@ import {
|
||||
normalizeStalkerPortalInputUrl,
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
|
||||
StalkerPortalDiscoveryService,
|
||||
StalkerPortalIdentity,
|
||||
normalizeStalkerPortalIdentity,
|
||||
stalkerSessionFingerprint,
|
||||
type StalkerPortalErrorKind,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
createRandomId,
|
||||
deriveStalkerDeviceIdsFromMac,
|
||||
hasInfomirMacOui,
|
||||
isFullStalkerPortalUrl,
|
||||
normalizeStalkerMacAddress,
|
||||
Playlist,
|
||||
type StalkerDerivedDeviceIds,
|
||||
validateStalkerMacAddressControl,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
STALKER_IMPORT_ERROR_KEY_BY_KIND,
|
||||
toStalkerPlaylistIdentityFields,
|
||||
} from './stalker-import-identity';
|
||||
|
||||
/**
|
||||
* A MAC and the device IDs that belong to exactly it. Kept together because
|
||||
* the portal binds the pair permanently on first use — a MAC carrying another
|
||||
* address's IDs is a device conflict nobody can undo.
|
||||
*/
|
||||
interface StalkerSettledIdentity {
|
||||
macAddress: string;
|
||||
deviceId1: string;
|
||||
deviceId2: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
@@ -56,6 +76,17 @@ import {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.derive-device-ids {
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
|
||||
.derive-device-ids__note {
|
||||
margin: 4px 0 0;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
@@ -66,7 +97,10 @@ export class StalkerPortalImportComponent {
|
||||
readonly form = new FormGroup({
|
||||
_id: new FormControl(createRandomId()),
|
||||
title: new FormControl('', [Validators.required]),
|
||||
macAddress: new FormControl('', [Validators.required]),
|
||||
macAddress: new FormControl('', [
|
||||
Validators.required,
|
||||
validateStalkerMacAddressControl,
|
||||
]),
|
||||
serialNumber: new FormControl(''),
|
||||
deviceId1: new FormControl(''),
|
||||
deviceId2: new FormControl(''),
|
||||
@@ -89,7 +123,184 @@ export class StalkerPortalImportComponent {
|
||||
|
||||
readonly isLoading = signal(false);
|
||||
|
||||
/** Whether the device IDs are being generated from the MAC. */
|
||||
readonly derivesDeviceIds = signal(false);
|
||||
|
||||
/** Stamps each derivation so a late one cannot overwrite a newer one. */
|
||||
private deriveGeneration = 0;
|
||||
|
||||
/**
|
||||
* A MAC outside Infomir's range is imported anyway — plenty of resellers
|
||||
* disable the check — but the stock portal answers a bare `{status: 1}`
|
||||
* for it, so the hint has to say where that dead end comes from.
|
||||
*/
|
||||
get showsForeignOuiHint(): boolean {
|
||||
const value = this.form.controls.macAddress.value;
|
||||
return Boolean(normalizeStalkerMacAddress(value)) &&
|
||||
!hasInfomirMacOui(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle is unavailable while an import is running, and while
|
||||
* derivation would overwrite a device ID the user entered by hand.
|
||||
*
|
||||
* Locking it during the import is not cosmetic. `addPlaylist()` snapshots
|
||||
* the identity and then hands it to portal discovery, which authenticates
|
||||
* with it — so by the time a slow discovery returns, the portal has
|
||||
* already pinned those IDs to the MAC. The snapshot is therefore the only
|
||||
* correct thing to persist, and unticking mid-flight cannot change that.
|
||||
* Leaving the box live would show the fields emptying and imply the
|
||||
* opposite.
|
||||
*/
|
||||
get hasManualDeviceIds(): boolean {
|
||||
if (this.isLoading()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
!this.derivesDeviceIds() &&
|
||||
Boolean(
|
||||
this.form.controls.deviceId1.value ||
|
||||
this.form.controls.deviceId2.value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites what the user typed into the canonical `00:1A:79:…` form on
|
||||
* blur. Only input is normalized: the field shows the exact bytes that
|
||||
* will go into the `mac` cookie, so the change is visible and editable
|
||||
* rather than something the transport does silently later.
|
||||
*/
|
||||
async onMacAddressBlur(): Promise<void> {
|
||||
await this.settleMacAddressIdentity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the MAC field and the derived device IDs into agreement, and
|
||||
* resolves only once they are.
|
||||
*
|
||||
* Both the blur handler and the submit path go through here. Submitting
|
||||
* has to re-run it rather than trust the blur: clicking Add moves focus
|
||||
* out of the field, so the blur's `SHA256` is still in flight when Angular
|
||||
* invokes the click handler — and a form read at that moment pairs the
|
||||
* corrected MAC with the PREVIOUS MAC's device IDs (or with empty ones).
|
||||
* The portal pins whatever pair it first receives to that MAC
|
||||
* permanently, so there is no recovering from it afterwards. Re-running
|
||||
* also covers the case where no blur fired at all.
|
||||
*/
|
||||
private async settleMacAddressIdentity(): Promise<StalkerSettledIdentity> {
|
||||
const control = this.form.controls.macAddress;
|
||||
const normalized = normalizeStalkerMacAddress(control.value);
|
||||
|
||||
if (normalized && normalized !== control.value) {
|
||||
control.setValue(normalized);
|
||||
}
|
||||
|
||||
// Read ONCE, before the await. Everything returned below describes
|
||||
// this MAC, so a field edit landing while the digest runs cannot
|
||||
// produce a snapshot whose device IDs belong to a different address —
|
||||
// the pairing the portal would then pin permanently.
|
||||
const macAddress = normalized ?? '';
|
||||
|
||||
// Re-derive while the box is ticked: nothing is pinned until the
|
||||
// import actually runs, so correcting a typo must correct the ID it
|
||||
// would otherwise bind the account to forever.
|
||||
const derived = await this.applyDerivedDeviceIds(macAddress);
|
||||
|
||||
return {
|
||||
macAddress,
|
||||
deviceId1:
|
||||
derived?.deviceId1 ?? this.form.controls.deviceId1.value ?? '',
|
||||
deviceId2:
|
||||
derived?.deviceId2 ?? this.form.controls.deviceId2.value ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills both device ID fields with the StbEmu / stalker-to-m3u pair
|
||||
* (`SHA256(MAC)` and `SHA256(MAC + "stalker")`, which a real box never
|
||||
* reports as equal) — or empties them again.
|
||||
*
|
||||
* The derived values are written into the visible fields and persisted as
|
||||
* literal strings, never recomputed at request time. `device_id` is pinned
|
||||
* to the MAC by the portal on first use: a value that silently followed a
|
||||
* later MAC edit would be refused as a device conflict, and one that
|
||||
* silently disappeared would lock the account out for good.
|
||||
*/
|
||||
async toggleDeriveDeviceIds(enabled: boolean): Promise<void> {
|
||||
this.derivesDeviceIds.set(enabled);
|
||||
const { deviceId1, deviceId2 } = this.form.controls;
|
||||
|
||||
if (!enabled) {
|
||||
// Turning it off has to invalidate work already in flight, or the
|
||||
// digest started a moment ago lands afterwards and writes the IDs
|
||||
// straight back into the fields the user just opted out of —
|
||||
// which then reach the portal and get pinned permanently.
|
||||
this.invalidatePendingDerivation();
|
||||
deviceId1.enable();
|
||||
deviceId2.enable();
|
||||
this.form.patchValue({ deviceId1: '', deviceId2: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
deviceId1.disable();
|
||||
deviceId2.disable();
|
||||
// Through the same single-read path as blur and submit, so the MAC
|
||||
// the IDs are derived from is never read twice.
|
||||
await this.settleMacAddressIdentity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes every derivation currently in flight a no-op.
|
||||
*
|
||||
* `applyDerivedDeviceIds` can only check the toggle before it awaits, so
|
||||
* anything that stops the user from wanting derived IDs — unticking the
|
||||
* box, clearing the form — has to invalidate the outstanding digest here
|
||||
* as well. Otherwise it resolves into fields that were deliberately
|
||||
* emptied, and the portal pins whatever the import then sends.
|
||||
*/
|
||||
private invalidatePendingDerivation(): void {
|
||||
this.deriveGeneration += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the derived pair into the form and returns it, or `null` when
|
||||
* derivation is off or the result was superseded before it landed.
|
||||
*/
|
||||
private async applyDerivedDeviceIds(
|
||||
macAddress: string
|
||||
): Promise<StalkerDerivedDeviceIds | null> {
|
||||
if (!this.derivesDeviceIds()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Two edits in quick succession leave two digests in flight, and
|
||||
// nothing guarantees they resolve in the order they were started. The
|
||||
// generation stamp discards every completion but the newest, so the
|
||||
// fields can never end up holding an older MAC's IDs.
|
||||
const generation = ++this.deriveGeneration;
|
||||
const derived = await deriveStalkerDeviceIdsFromMac(macAddress);
|
||||
|
||||
if (generation !== this.deriveGeneration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.form.patchValue({
|
||||
deviceId1: derived?.deviceId1 ?? '',
|
||||
deviceId2: derived?.deviceId2 ?? '',
|
||||
});
|
||||
|
||||
return derived;
|
||||
}
|
||||
|
||||
clearForm(): void {
|
||||
// Same hazard as unticking the box: a digest still in flight would
|
||||
// resolve into the freshly cleared form.
|
||||
this.invalidatePendingDerivation();
|
||||
this.derivesDeviceIds.set(false);
|
||||
this.form.controls.deviceId1.enable();
|
||||
this.form.controls.deviceId2.enable();
|
||||
this.form.reset({
|
||||
_id: createRandomId(),
|
||||
title: '',
|
||||
@@ -113,14 +324,37 @@ export class StalkerPortalImportComponent {
|
||||
}
|
||||
|
||||
this.isLoading.set(true);
|
||||
// The identity is frozen for the duration: an edit made now cannot
|
||||
// reach the portal (discovery has the snapshot) and cannot be undone
|
||||
// on it either (`get_profile` pins what it was sent), so the fields
|
||||
// must not invite one.
|
||||
this.form.disable({ emitEvent: false });
|
||||
|
||||
try {
|
||||
// Before anything reads the form: clicking Add blurs the MAC
|
||||
// field, so a derivation may still be in flight, and the pairing
|
||||
// this produces is the one the portal pins forever. The MAC and
|
||||
// the device IDs come back TOGETHER from one read — taking them
|
||||
// from `getRawValue()` below would let an edit that landed during
|
||||
// the digest pair a new MAC with the old MAC's IDs.
|
||||
const identity = await this.settleMacAddressIdentity();
|
||||
const macAddress = identity.macAddress;
|
||||
|
||||
// Authoritative for the rest of the import, and deliberately so:
|
||||
// discovery below authenticates with exactly these values, and
|
||||
// `get_profile` is what makes the portal pin them to the MAC.
|
||||
// Re-reading the form after discovery — to pick up an edit made
|
||||
// while it was running — would persist device IDs that differ
|
||||
// from the ones already pinned, or none at all, and sending
|
||||
// nothing after a value was pinned is the permanent lockout. The
|
||||
// identity controls are locked while `isLoading()` so the UI
|
||||
// cannot suggest otherwise.
|
||||
const formValue = this.form.getRawValue();
|
||||
const originalUrl = formValue.portalUrl ?? '';
|
||||
const stalkerIdentity = normalizeStalkerPortalIdentity({
|
||||
serialNumber: formValue.serialNumber ?? undefined,
|
||||
deviceId1: formValue.deviceId1 ?? undefined,
|
||||
deviceId2: formValue.deviceId2 ?? undefined,
|
||||
deviceId1: identity.deviceId1 || undefined,
|
||||
deviceId2: identity.deviceId2 || undefined,
|
||||
signature1: formValue.signature1 ?? undefined,
|
||||
signature2: formValue.signature2 ?? undefined,
|
||||
});
|
||||
@@ -132,7 +366,7 @@ export class StalkerPortalImportComponent {
|
||||
// rewrote `…/c` to a `portal.php` official Ministra never serves.
|
||||
const discovery = await this.portalDiscovery.discover(
|
||||
originalUrl,
|
||||
formValue.macAddress ?? '',
|
||||
macAddress,
|
||||
stalkerIdentity,
|
||||
{
|
||||
credentials: {
|
||||
@@ -242,6 +476,9 @@ export class StalkerPortalImportComponent {
|
||||
|
||||
const playlist: Playlist = {
|
||||
...playlistFormValue,
|
||||
// Canonical form, so the stored MAC is the one that was
|
||||
// validated against the portal a moment ago.
|
||||
macAddress,
|
||||
portalUrl,
|
||||
isFullStalkerPortal,
|
||||
stalkerToken,
|
||||
@@ -255,22 +492,32 @@ export class StalkerPortalImportComponent {
|
||||
? {
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint({
|
||||
portalUrl,
|
||||
macAddress: formValue.macAddress ?? '',
|
||||
macAddress,
|
||||
username: formValue.username ?? '',
|
||||
password: formValue.password ?? '',
|
||||
...this.toPlaylistIdentityFields(stalkerIdentity),
|
||||
...toStalkerPlaylistIdentityFields(
|
||||
stalkerIdentity
|
||||
),
|
||||
} as Playlist),
|
||||
}
|
||||
: {}),
|
||||
stalkerWatchdogTimeout,
|
||||
stalkerTimeslot,
|
||||
stalkerAccountInfo,
|
||||
...this.toPlaylistIdentityFields(stalkerIdentity),
|
||||
...toStalkerPlaylistIdentityFields(stalkerIdentity),
|
||||
} as Playlist;
|
||||
|
||||
this.store.dispatch(PlaylistActions.addPlaylist({ playlist }));
|
||||
this.addClicked.emit();
|
||||
} finally {
|
||||
this.form.enable({ emitEvent: false });
|
||||
// `enable()` clears the derivation toggle's own lock on the
|
||||
// device ID controls, so restore it for a form the user stays on
|
||||
// after a refused import.
|
||||
if (this.derivesDeviceIds()) {
|
||||
this.form.controls.deviceId1.disable({ emitEvent: false });
|
||||
this.form.controls.deviceId2.disable({ emitEvent: false });
|
||||
}
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
@@ -282,15 +529,9 @@ export class StalkerPortalImportComponent {
|
||||
*/
|
||||
private buildAuthErrorMessage(error: unknown): string {
|
||||
const portalError = asStalkerPortalError(error);
|
||||
const keyByKind: Record<StalkerPortalErrorKind, string> = {
|
||||
'login-required': 'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
|
||||
'login-rejected': 'HOME.STALKER_PORTAL.LOGIN_REJECTED',
|
||||
blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
|
||||
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
|
||||
};
|
||||
const base = this.translate.instant(
|
||||
portalError
|
||||
? keyByKind[portalError.kind]
|
||||
? STALKER_IMPORT_ERROR_KEY_BY_KIND[portalError.kind]
|
||||
: 'HOME.STALKER_PORTAL.AUTH_FAILED'
|
||||
);
|
||||
|
||||
@@ -304,31 +545,4 @@ export class StalkerPortalImportComponent {
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
private toPlaylistIdentityFields(identity: StalkerPortalIdentity): {
|
||||
stalkerSerialNumber?: string;
|
||||
stalkerDeviceId1?: string;
|
||||
stalkerDeviceId2?: string;
|
||||
stalkerSignature1?: string;
|
||||
stalkerSignature2?: string;
|
||||
} {
|
||||
return {
|
||||
...(identity.serialNumber
|
||||
? { stalkerSerialNumber: identity.serialNumber }
|
||||
: {}),
|
||||
...(identity.deviceId1
|
||||
? { stalkerDeviceId1: identity.deviceId1 }
|
||||
: {}),
|
||||
...(identity.deviceId2
|
||||
? { stalkerDeviceId2: identity.deviceId2 }
|
||||
: {}),
|
||||
...(identity.signature1
|
||||
? { stalkerSignature1: identity.signature1 }
|
||||
: {}),
|
||||
...(identity.signature2
|
||||
? { stalkerSignature2: identity.signature2 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
+17
-2
@@ -21,14 +21,29 @@
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.macAddress) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-form-field class="w-full" subscriptSizing="dynamic">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="macAddress" />
|
||||
<input
|
||||
matInput
|
||||
formControlName="macAddress"
|
||||
(blur)="onMacAddressBlur()"
|
||||
/>
|
||||
<mat-error>{{
|
||||
'HOME.STALKER_PORTAL.MAC_ADDRESS_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.portalUrl) {
|
||||
@if (hasStoredStalkerDeviceIds) {
|
||||
<p class="stalker-device-id-warning">
|
||||
{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
|
||||
|
||||
+217
-1
@@ -12,7 +12,7 @@ import {
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistInfoComponent } from './playlist-info.component';
|
||||
|
||||
describe('PlaylistInfoComponent', () => {
|
||||
@@ -566,6 +566,222 @@ describe('PlaylistInfoComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Stalker identity fields', () => {
|
||||
function createStalkerComponent(
|
||||
overrides: Partial<Playlist> = {}
|
||||
): void {
|
||||
TestBed.overrideProvider(MAT_DIALOG_DATA, {
|
||||
useValue: {
|
||||
...playlist,
|
||||
url: undefined,
|
||||
portalUrl: 'https://portal.example.com/c',
|
||||
macAddress: '00:1a:79:aa:bb:cc',
|
||||
isFullStalkerPortal: true,
|
||||
...overrides,
|
||||
} as Playlist & { id: string },
|
||||
});
|
||||
createComponent();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('canonicalizes an edited MAC on blur', () => {
|
||||
createStalkerComponent();
|
||||
const control = component.playlistDetails.get('macAddress');
|
||||
control?.setValue('00-1a-79-ab-cd-ef');
|
||||
|
||||
component.onMacAddressBlur();
|
||||
|
||||
expect(control?.value).toBe('00:1A:79:AB:CD:EF');
|
||||
// The rewrite is a change the user has to save deliberately.
|
||||
expect(control?.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a stored MAC untouched until it is edited', () => {
|
||||
// Loading the dialog must not move the session fingerprint: the
|
||||
// stored lowercase MAC is what the portal already accepted.
|
||||
createStalkerComponent();
|
||||
|
||||
expect(component.playlistDetails.get('macAddress')?.value).toBe(
|
||||
'00:1a:79:aa:bb:cc'
|
||||
);
|
||||
expect(component.playlistDetails.pristine).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to save a malformed MAC', () => {
|
||||
createStalkerComponent();
|
||||
const control = component.playlistDetails.get('macAddress');
|
||||
|
||||
control?.setValue('00:1A:79:AA:BB');
|
||||
|
||||
expect(control?.valid).toBe(false);
|
||||
expect(component.playlistDetails.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('canonicalizes the MAC on submit when no blur fired', async () => {
|
||||
// Pressing Enter inside the field submits without the field losing
|
||||
// focus, so `onMacAddressBlur` never runs.
|
||||
createStalkerComponent();
|
||||
component.playlistDetails.get('macAddress')?.setValue(
|
||||
'00-1a-79-ab-cd-ef'
|
||||
);
|
||||
|
||||
await component.saveChanges(
|
||||
component.playlistDetails.value as PlaylistMeta
|
||||
);
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: expect.objectContaining({
|
||||
macAddress: '00:1A:79:AB:CD:EF',
|
||||
}) as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('persists a grandfathered MAC untouched on submit', async () => {
|
||||
createStalkerComponent({ macAddress: 'legacy-device-42' });
|
||||
|
||||
await component.saveChanges(
|
||||
component.playlistDetails.value as PlaylistMeta
|
||||
);
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: expect.objectContaining({
|
||||
macAddress: 'legacy-device-42',
|
||||
}) as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a non-canonical MAC alone when focus passes through it', async () => {
|
||||
// Tabbing through the dialog fires blur with no edit. Rewriting
|
||||
// there would mark the form dirty AND make the value differ from
|
||||
// the stored one, which is what the submit guard reads — so a
|
||||
// later title-only save would carry the rewritten identity.
|
||||
createStalkerComponent({ macAddress: '00-1a-79-aa-bb-cc' });
|
||||
const control = component.playlistDetails.get('macAddress');
|
||||
|
||||
component.onMacAddressBlur();
|
||||
|
||||
expect(control?.value).toBe('00-1a-79-aa-bb-cc');
|
||||
expect(control?.dirty).toBe(false);
|
||||
|
||||
component.playlistDetails.get('title')?.setValue('Renamed');
|
||||
await component.saveChanges(
|
||||
component.playlistDetails.value as PlaylistMeta
|
||||
);
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: expect.objectContaining({
|
||||
macAddress: '00-1a-79-aa-bb-cc',
|
||||
}) as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves an untouched non-canonical MAC alone on an unrelated save', async () => {
|
||||
// Renaming a playlist must not rewrite its MAC: those bytes are
|
||||
// what a permissive portal registered, and changing them moves
|
||||
// the session fingerprint and re-authenticates under a spelling
|
||||
// the portal never saw.
|
||||
createStalkerComponent({ macAddress: '00-1a-79-aa-bb-cc' });
|
||||
component.playlistDetails.get('title')?.setValue('Renamed');
|
||||
|
||||
await component.saveChanges(
|
||||
component.playlistDetails.value as PlaylistMeta
|
||||
);
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: expect.objectContaining({
|
||||
macAddress: '00-1a-79-aa-bb-cc',
|
||||
title: 'Renamed',
|
||||
}) as PlaylistMeta,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not claim a simple portal has pinned its device IDs', () => {
|
||||
// device_id travels only on get_profile/do_auth, which a
|
||||
// panel-style portal never runs — so nothing was pinned and the
|
||||
// lockout warning would be false.
|
||||
createStalkerComponent({
|
||||
isFullStalkerPortal: false,
|
||||
stalkerDeviceId1: 'ABCDEF',
|
||||
});
|
||||
|
||||
expect(component.hasStoredStalkerDeviceIds).toBe(false);
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a MAC outside the Infomir range saveable', () => {
|
||||
// Most reseller panels do not run the stock OUI filter, so this is
|
||||
// a working configuration — the import hint explains the risk, the
|
||||
// form must not block it.
|
||||
createStalkerComponent({ macAddress: 'AA:BB:CC:DD:EE:01' });
|
||||
|
||||
expect(component.playlistDetails.get('macAddress')?.valid).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('grandfathers a stored MAC it would now reject', () => {
|
||||
// Before this validation existed the field accepted anything, and
|
||||
// on a panel that ignores the MAC such a playlist works. Blocking
|
||||
// Save would also strand the title, URL and EPG edits in the same
|
||||
// dialog.
|
||||
createStalkerComponent({ macAddress: 'legacy-device-42' });
|
||||
|
||||
expect(component.playlistDetails.get('macAddress')?.valid).toBe(
|
||||
true
|
||||
);
|
||||
expect(component.playlistDetails.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('still refuses a newly typed malformed MAC on a grandfathered playlist', () => {
|
||||
createStalkerComponent({ macAddress: 'legacy-device-42' });
|
||||
const control = component.playlistDetails.get('macAddress');
|
||||
|
||||
control?.setValue('legacy-device-43');
|
||||
|
||||
expect(control?.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('does not warn about a pinning that has not happened', () => {
|
||||
createStalkerComponent();
|
||||
|
||||
expect(component.hasStoredStalkerDeviceIds).toBe(false);
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a blank stored device ID as never sent', () => {
|
||||
createStalkerComponent({ stalkerDeviceId2: ' ' });
|
||||
|
||||
expect(component.hasStoredStalkerDeviceIds).toBe(false);
|
||||
});
|
||||
|
||||
it('warns once a device ID has been pinned', () => {
|
||||
createStalkerComponent({ stalkerDeviceId1: 'ABCDEF' });
|
||||
|
||||
expect(component.hasStoredStalkerDeviceIds).toBe(true);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_PINNED_WARNING'
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when only the second device ID is pinned', () => {
|
||||
createStalkerComponent({ stalkerDeviceId2: 'FEDCBA' });
|
||||
|
||||
expect(component.hasStoredStalkerDeviceIds).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to browser download when desktop file saving is unavailable', async () => {
|
||||
const clickSpy = jest
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
|
||||
+107
-3
@@ -32,6 +32,9 @@ import {
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
createStalkerMacAddressValidator,
|
||||
normalizeStalkerIdentityValue,
|
||||
normalizeStalkerMacAddress,
|
||||
normalizeXtreamServerUrl,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
@@ -82,6 +85,10 @@ const EPG_URL_PATTERN = /^\s*(http|https|file):\/\/[^ "]+\s*$/;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
mat-dialog-content p.stalker-device-id-warning {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
|
||||
.playlist-epg-sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -195,6 +202,63 @@ export class PlaylistInfoComponent {
|
||||
return !this.playlist.serverUrl && !this.playlist.macAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once a device ID has actually reached the portal, which is the
|
||||
* point of no return: the stock server pins the first non-empty
|
||||
* `device_id`/`device_id2` to the MAC permanently, refuses a different one
|
||||
* as a device conflict, and treats a later empty value as a lockout. The
|
||||
* fields stay editable — a value that was never accepted may well need
|
||||
* correcting — but the consequence has to be on screen.
|
||||
*
|
||||
* Storage is not transmission, so `isFullStalkerPortal` gates it.
|
||||
* `device_id` travels only on `get_profile`/`do_auth`, which simple
|
||||
* panel-style portals never run; the import's offline fallback also
|
||||
* persists whatever was typed and records the playlist as simple. Warning
|
||||
* those users that a change "will lock this source out" would be false,
|
||||
* and would discourage them from fixing an ID that was never pinned.
|
||||
*/
|
||||
get hasStoredStalkerDeviceIds(): boolean {
|
||||
if (!this.playlist.isFullStalkerPortal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
normalizeStalkerIdentityValue(this.playlist.stalkerDeviceId1) ??
|
||||
normalizeStalkerIdentityValue(this.playlist.stalkerDeviceId2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalizes an edited MAC on blur, so the stored value is the one the
|
||||
* portal's own format check accepts.
|
||||
*
|
||||
* Only an EDIT normalizes it. Merely focusing the field and tabbing on
|
||||
* must leave it alone: rewriting it there would mark the form dirty and
|
||||
* make the value differ from the stored one, which is exactly what the
|
||||
* submit-path guard reads — so a later title-only save would carry the
|
||||
* rewritten identity through and move the session fingerprint without the
|
||||
* user having touched the MAC at all.
|
||||
*/
|
||||
onMacAddressBlur(): void {
|
||||
const control = this.playlistDetails.get('macAddress');
|
||||
|
||||
if (!control || !this.isStalkerMacAddressEdited(control.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeStalkerMacAddress(control.value);
|
||||
|
||||
if (normalized && normalized !== control.value) {
|
||||
control.setValue(normalized);
|
||||
control.markAsDirty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a MAC value differs from the one the playlist was loaded with. */
|
||||
private isStalkerMacAddressEdited(value: unknown): boolean {
|
||||
return value !== this.playlist.macAddress;
|
||||
}
|
||||
|
||||
get playlistEpgSourceInputs(): UntypedFormArray {
|
||||
return this.playlistDetails.get(
|
||||
'playlistEpgSourceInputs'
|
||||
@@ -244,7 +308,14 @@ export class PlaylistInfoComponent {
|
||||
serverUrl: new FormControl(this.playlist.serverUrl),
|
||||
username: new FormControl(this.playlist.username),
|
||||
password: new FormControl(this.playlist.password),
|
||||
macAddress: new FormControl(this.playlist.macAddress),
|
||||
macAddress: new FormControl(
|
||||
this.playlist.macAddress,
|
||||
// Grandfathered: a playlist stored before this validation
|
||||
// existed may hold anything, and on a panel that ignores the
|
||||
// MAC it works. Blocking Save over it would strand the user's
|
||||
// title/URL/EPG edits too.
|
||||
createStalkerMacAddressValidator(this.playlist.macAddress)
|
||||
),
|
||||
portalUrl: new FormControl(this.playlist.portalUrl),
|
||||
stalkerSerialNumber: new FormControl(
|
||||
this.playlist.stalkerSerialNumber
|
||||
@@ -261,8 +332,9 @@ export class PlaylistInfoComponent {
|
||||
|
||||
async saveChanges(playlist: PlaylistMeta): Promise<void> {
|
||||
try {
|
||||
const normalizedPlaylist =
|
||||
this.normalizeXtreamPlaylistMeta(playlist);
|
||||
const normalizedPlaylist = this.normalizeStalkerPlaylistMeta(
|
||||
this.normalizeXtreamPlaylistMeta(playlist)
|
||||
);
|
||||
const isXtream =
|
||||
this.playlist &&
|
||||
this.playlist.username &&
|
||||
@@ -300,6 +372,38 @@ export class PlaylistInfoComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalizes the MAC on the submit path as well as on blur. Pressing
|
||||
* Enter inside the field submits the dialog without the field losing
|
||||
* focus, so the blur handler never runs and the raw `00-1a-79-…` the user
|
||||
* typed would be persisted and sent to a portal whose format check
|
||||
* refuses it.
|
||||
*
|
||||
* Only an ACTUAL edit is normalized. A MAC the user never touched is
|
||||
* passed through byte for byte, even when it is non-canonical: those
|
||||
* bytes are what a permissive portal registered, and rewriting them
|
||||
* because someone renamed the playlist would move the session
|
||||
* fingerprint and re-authenticate under a spelling the portal never saw.
|
||||
* That is the same reason a stored MAC is not rewritten on load.
|
||||
*
|
||||
* A value that does not parse is left alone too — the grandfathered case,
|
||||
* where a playlist stored before this validation existed may be working
|
||||
* on a panel that ignores the MAC entirely.
|
||||
*/
|
||||
private normalizeStalkerPlaylistMeta(playlist: PlaylistMeta): PlaylistMeta {
|
||||
if (!this.isStalkerMacAddressEdited(playlist.macAddress)) {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
const normalizedMac = normalizeStalkerMacAddress(playlist.macAddress);
|
||||
|
||||
if (!normalizedMac || normalizedMac === playlist.macAddress) {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
return { ...playlist, macAddress: normalizedMac };
|
||||
}
|
||||
|
||||
private normalizeXtreamPlaylistMeta(playlist: PlaylistMeta): PlaylistMeta {
|
||||
if (!playlist.serverUrl || !playlist.username || !playlist.password) {
|
||||
return playlist;
|
||||
|
||||
@@ -48,6 +48,49 @@ describe('StalkerAuthApi', () => {
|
||||
);
|
||||
}
|
||||
|
||||
it('reports one coherent MAG250 in the profile request', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({ js: { token: 'TOKEN-1', random: 'r1' } })
|
||||
.mockResolvedValueOnce({ js: { status: 0 } });
|
||||
|
||||
await api.authenticate(portalUrl, macAddress);
|
||||
|
||||
const [profile] = callsByAction('get_profile');
|
||||
expect(profile[1].params).toEqual(
|
||||
expect.objectContaining({
|
||||
// `stb_type` used to go out as an empty string.
|
||||
stb_type: 'MAG250',
|
||||
ver: expect.stringContaining('0.2.18-r14-pub-250'),
|
||||
hw_version: '1.7-BD-00',
|
||||
image_version: '218',
|
||||
client_type: 'STB',
|
||||
num_banks: '2',
|
||||
video_out: 'hdmi',
|
||||
hd: '1',
|
||||
})
|
||||
);
|
||||
// Same box as the metrics payload and the MAG User-Agent header.
|
||||
expect(JSON.parse(profile[1].params.metrics).model).toBe('MAG250');
|
||||
});
|
||||
|
||||
it('keeps the box description out of the flow-control params', async () => {
|
||||
// The constants are spread first, so a name collision would let them
|
||||
// silently overwrite a computed value.
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1', not_valid: 1 },
|
||||
})
|
||||
.mockResolvedValueOnce({ js: { status: 0 } });
|
||||
|
||||
await api.authenticate(portalUrl, macAddress);
|
||||
|
||||
const [profile] = callsByAction('get_profile');
|
||||
expect(profile[1].params.not_valid_token).toBe('1');
|
||||
expect(profile[1].params.auth_second_step).toBe('0');
|
||||
expect(profile[1].params.action).toBe('get_profile');
|
||||
expect(profile[1].params.type).toBe('stb');
|
||||
});
|
||||
|
||||
it('walks the login-required flow: status 2 -> do_auth -> profile retry', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
@@ -188,7 +231,7 @@ describe('StalkerAuthApi', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes a blocked profile into the portal explanation', async () => {
|
||||
it('decodes a device conflict into its own kind', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
@@ -204,12 +247,36 @@ describe('StalkerAuthApi', () => {
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
kind: 'blocked',
|
||||
// Not `blocked`: this refusal has a remedy, and the portal's own
|
||||
// "STB is damaged" wording actively points away from it.
|
||||
kind: 'device-conflict',
|
||||
portalText:
|
||||
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes any other blocked profile into the portal explanation', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'TOKEN-1', random: 'r1' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
js: {
|
||||
status: 1,
|
||||
msg: 'Account disabled',
|
||||
block_msg: 'Contact your provider.<br/> Subscription ended.',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
api.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
kind: 'blocked',
|
||||
portalText:
|
||||
'Account disabled — Contact your provider. Subscription ended.',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a bare {status:1} profile as refused', async () => {
|
||||
sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DataService } from '@iptvnator/services';
|
||||
import {
|
||||
extractStalkerAuthFailureBody,
|
||||
STALKER_REQUEST,
|
||||
STALKER_STB_PROFILE_PARAMS,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from './stalker-identity.utils';
|
||||
import {
|
||||
combineStalkerPortalMessages,
|
||||
isStalkerDeviceConflictMessage,
|
||||
StalkerPortalError,
|
||||
} from './stalker-portal-error';
|
||||
|
||||
@@ -278,11 +280,12 @@ export class StalkerAuthApi {
|
||||
const params: Record<string, string> = {
|
||||
type: 'stb',
|
||||
action: 'get_profile',
|
||||
hd: '1',
|
||||
// One coherent MAG250: firmware, hardware revision, image version
|
||||
// and `stb_type` (which used to go out empty), alongside the
|
||||
// `hd`/`video_out`/`num_banks` this request already carried.
|
||||
...STALKER_STB_PROFILE_PARAMS,
|
||||
not_valid_token: options.notValidToken ? '1' : '0',
|
||||
video_out: 'hdmi',
|
||||
auth_second_step: options.authSecondStep ? '1' : '0',
|
||||
num_banks: '2',
|
||||
metrics: JSON.stringify(metrics),
|
||||
...(normalizedIdentity.serialNumber
|
||||
? { sn: normalizedIdentity.serialNumber }
|
||||
@@ -300,7 +303,6 @@ export class StalkerAuthApi {
|
||||
? { signature2: normalizedIdentity.signature2 }
|
||||
: {}),
|
||||
prehash: prehash,
|
||||
stb_type: '',
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
@@ -538,7 +540,15 @@ export class StalkerAuthApi {
|
||||
|
||||
if (toFiniteNumber(js?.status) === 1 || portalText) {
|
||||
this.logger.error('Profile error:', portalText ?? 'status 1');
|
||||
throw new StalkerPortalError('blocked', portalText);
|
||||
// A device conflict is the one refusal the user can act on, and
|
||||
// the portal's own wording for it ("Your STB is damaged") points
|
||||
// at the wrong problem entirely.
|
||||
throw new StalkerPortalError(
|
||||
isStalkerDeviceConflictMessage(portalText)
|
||||
? 'device-conflict'
|
||||
: 'blocked',
|
||||
portalText
|
||||
);
|
||||
}
|
||||
|
||||
return settled;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
asStalkerPortalError,
|
||||
combineStalkerPortalMessages,
|
||||
isStalkerDeviceConflictMessage,
|
||||
StalkerPortalError,
|
||||
stripStalkerPortalMarkup,
|
||||
} from './stalker-portal-error';
|
||||
@@ -32,6 +33,47 @@ describe('combineStalkerPortalMessages', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStalkerDeviceConflictMessage', () => {
|
||||
it.each([
|
||||
// What the stock middleware and the mock server actually send.
|
||||
'device conflict - device_id mismatch',
|
||||
'device conflict - MAC address mismatch',
|
||||
'Device Conflict',
|
||||
'device_id mismatch',
|
||||
'device id does not match the registered one',
|
||||
])('recognizes %p', (message) => {
|
||||
expect(isStalkerDeviceConflictMessage(message)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// Other status-1 refusals that must keep the generic `blocked`
|
||||
// headline — offering "restore your first device ID" for any of
|
||||
// these would send the user down a dead end.
|
||||
'Account disabled',
|
||||
'Your subscription has expired',
|
||||
'Device limit reached',
|
||||
'No device selected',
|
||||
'Your STB is damaged. Call the provider.',
|
||||
])('does not claim %p is a device conflict', (message) => {
|
||||
expect(isStalkerDeviceConflictMessage(message)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false without portal text', () => {
|
||||
expect(isStalkerDeviceConflictMessage(undefined)).toBe(false);
|
||||
expect(isStalkerDeviceConflictMessage('')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not bridge sentence boundaries', () => {
|
||||
// "device_id" in one sentence and "mismatch" in the next are two
|
||||
// unrelated statements.
|
||||
expect(
|
||||
isStalkerDeviceConflictMessage(
|
||||
'Your device_id is recorded. A password mismatch was logged.'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('asStalkerPortalError', () => {
|
||||
it('recognizes real instances', () => {
|
||||
const error = new StalkerPortalError('blocked', 'text');
|
||||
|
||||
@@ -8,8 +8,13 @@ import type { StalkerAuthFailureBody } from '@iptvnator/shared/interfaces';
|
||||
* - `login-rejected` — `do_auth` answered `{js: false}` (the operator billing
|
||||
* script refused the credentials), or the profile still demanded a login
|
||||
* after a successful `do_auth`.
|
||||
* - `blocked` — `get_profile` answered `status: 1`: the account is blocked or
|
||||
* the device identity conflicts. `msg`/`block_msg` explain why.
|
||||
* - `device-conflict` — a `status: 1` refusal whose `msg` names the device
|
||||
* binding: the portal already pinned a different `device_id`/`device_id2` to
|
||||
* this MAC. Split out of `blocked` because it is the one refusal with a
|
||||
* concrete remedy, and because the portal's own words for it ("Your STB is
|
||||
* damaged") describe hardware failure rather than what actually happened.
|
||||
* - `blocked` — any other `get_profile` `status: 1`: the account is disabled,
|
||||
* the MAC is unknown or malformed. `msg`/`block_msg` explain why.
|
||||
* - `auth-failed` — a request came back as one of the plain-text bodies
|
||||
* (`Authorization failed.`, `Access denied.`, `Unauthorized request.`) and
|
||||
* re-authentication did not recover it.
|
||||
@@ -17,9 +22,40 @@ import type { StalkerAuthFailureBody } from '@iptvnator/shared/interfaces';
|
||||
export type StalkerPortalErrorKind =
|
||||
| 'login-required'
|
||||
| 'login-rejected'
|
||||
| 'device-conflict'
|
||||
| 'blocked'
|
||||
| 'auth-failed';
|
||||
|
||||
/**
|
||||
* Device-conflict phrasings seen in the wild, matched against the portal's
|
||||
* `msg`/`block_msg` — a STRUCTURED field the middleware wrote, so a phrase set
|
||||
* is safe here in a way it would not be against a raw HTML body.
|
||||
*
|
||||
* Kept to the binding itself: "device" alone appears in unrelated refusals
|
||||
* ("device limit reached", "no device selected"), and mislabelling one of
|
||||
* those would hand the user a remedy that cannot work.
|
||||
*/
|
||||
const DEVICE_CONFLICT_PATTERNS: readonly RegExp[] = [
|
||||
/device\s*conflict/i,
|
||||
/device[\s_-]?id[^.!?]{0,40}?(mismatch|conflict|does\s*not\s*match|not\s*match)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* True when a `status: 1` refusal is the portal reporting that this MAC is
|
||||
* already bound to a different device ID.
|
||||
*/
|
||||
export function isStalkerDeviceConflictMessage(
|
||||
portalText: string | undefined
|
||||
): boolean {
|
||||
if (!portalText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DEVICE_CONFLICT_PATTERNS.some((pattern) =>
|
||||
pattern.test(portalText)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `block_msg` routinely carries markup ("Your STB is damaged.<br/> Call the
|
||||
* provider."); strip it before the text reaches a snackbar or error view.
|
||||
|
||||
@@ -1288,7 +1288,7 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
});
|
||||
|
||||
it('throws StalkerPortalError with the portal text when auth is refused', async () => {
|
||||
// Blocked account: get_profile answers status 1 with msg/block_msg.
|
||||
// Device conflict: get_profile answers status 1 with msg/block_msg.
|
||||
dataService.sendIpcEvent
|
||||
.mockResolvedValueOnce({
|
||||
js: { token: 'token-1', random: 'random-1' },
|
||||
@@ -1305,7 +1305,7 @@ describe('StalkerSessionService identity payloads', () => {
|
||||
service.authenticate(portalUrl, macAddress)
|
||||
).rejects.toMatchObject({
|
||||
name: 'StalkerPortalError',
|
||||
kind: 'blocked',
|
||||
kind: 'device-conflict',
|
||||
portalText:
|
||||
'device conflict - device_id mismatch — Your STB is damaged. Call the provider.',
|
||||
});
|
||||
|
||||
@@ -71,7 +71,9 @@ export * from './lib/xtream-vod-stream.interface';
|
||||
export * from './lib/stalker-item.normalizer';
|
||||
export * from './lib/stalker-item-tmdb-hints';
|
||||
export * from './lib/stalker-identity.utils';
|
||||
export * from './lib/stalker-mac-address.util';
|
||||
export * from './lib/stalker-request-identity.util';
|
||||
export * from './lib/stalker-stb-profile.const';
|
||||
export * from './lib/stalker-request-url.util';
|
||||
export * from './lib/stalker-portal-item.interface';
|
||||
export * from './lib/stalker-stream-profile.util';
|
||||
|
||||
@@ -77,9 +77,16 @@ export interface Playlist {
|
||||
stalkerTimeslot?: number;
|
||||
/** Serial number for stalker portal - generated once and stored for consistency */
|
||||
stalkerSerialNumber?: string;
|
||||
/** Optional device ID 1 for stalker portal - if not provided, auto-generated from MAC */
|
||||
/**
|
||||
* Optional device ID 1 for stalker portal. Absent means absent — nothing
|
||||
* generates one at request time. The import dialog can pre-fill a
|
||||
* MAC-derived value on request, but it is stored here as a literal string
|
||||
* from then on: the portal pins the first non-empty value to the MAC
|
||||
* permanently, so a value that silently followed a later MAC edit would be
|
||||
* refused as a device conflict.
|
||||
*/
|
||||
stalkerDeviceId1?: string;
|
||||
/** Optional device ID 2 for stalker portal - if not provided, auto-generated from MAC */
|
||||
/** Optional device ID 2 for stalker portal - same pinning rules as `stalkerDeviceId1`. */
|
||||
stalkerDeviceId2?: string;
|
||||
/** Optional signature 1 for stalker portal - required by some portals for device verification */
|
||||
stalkerSignature1?: string;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { deriveStalkerDeviceIdsFromMac } from './stalker-identity.utils';
|
||||
|
||||
describe('deriveStalkerDeviceIdsFromMac', () => {
|
||||
// Uppercase hex SHA-256 of the canonical MAC, and of that MAC plus the
|
||||
// `stalker` salt — the values StbEmu and stalker-to-m3u pin server-side.
|
||||
// Asserted literally: matching those clients byte for byte is the entire
|
||||
// point of the option, so recomputing them with the implementation's own
|
||||
// algorithm would assert nothing.
|
||||
const EXPECTED = {
|
||||
deviceId1:
|
||||
'A446559A63A6A489959198534E649760C6A9A6474DEE7C20314C2F1903B36422',
|
||||
deviceId2:
|
||||
'BBD059367A90B0166654E6D4F9E09786CE64EB97674D1AF8D1EE2AE335D7205B',
|
||||
};
|
||||
|
||||
it('derives the reference SHA-256 pair', async () => {
|
||||
await expect(
|
||||
deriveStalkerDeviceIdsFromMac('00:1A:79:AB:CD:EF')
|
||||
).resolves.toEqual(EXPECTED);
|
||||
});
|
||||
|
||||
it('never produces an identical pair', async () => {
|
||||
// A real box reports device_id and device_id2 from two separate
|
||||
// firmware calls and they are never equal, so an identical pair is a
|
||||
// fingerprint no STB produces — and the portal pins the first value it
|
||||
// sees permanently, so it cannot be corrected afterwards.
|
||||
const derived = await deriveStalkerDeviceIdsFromMac(
|
||||
'00:1A:79:00:00:01'
|
||||
);
|
||||
|
||||
expect(derived?.deviceId1).not.toBe(derived?.deviceId2);
|
||||
});
|
||||
|
||||
it('hashes the canonical form, so input formatting cannot change the ids', async () => {
|
||||
// A user who types the MAC with hyphens must not end up bound to
|
||||
// different device ids than one who types colons — the portal pins the
|
||||
// first values it sees, permanently.
|
||||
await expect(
|
||||
deriveStalkerDeviceIdsFromMac('00-1a-79-ab-cd-ef')
|
||||
).resolves.toEqual(EXPECTED);
|
||||
await expect(
|
||||
deriveStalkerDeviceIdsFromMac('001A79ABCDEF')
|
||||
).resolves.toEqual(EXPECTED);
|
||||
});
|
||||
|
||||
it('produces 64 uppercase hex characters for both', async () => {
|
||||
const derived = await deriveStalkerDeviceIdsFromMac('00:1A:79:00:00:01');
|
||||
|
||||
expect(derived?.deviceId1).toMatch(/^[0-9A-F]{64}$/);
|
||||
expect(derived?.deviceId2).toMatch(/^[0-9A-F]{64}$/);
|
||||
});
|
||||
|
||||
it('refuses to hash something that is not a MAC', async () => {
|
||||
// Hashing a typo would pin the account to it forever.
|
||||
await expect(
|
||||
deriveStalkerDeviceIdsFromMac('00:1A:79')
|
||||
).resolves.toBeNull();
|
||||
await expect(deriveStalkerDeviceIdsFromMac('')).resolves.toBeNull();
|
||||
await expect(
|
||||
deriveStalkerDeviceIdsFromMac(undefined)
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('derives for a MAC outside the Infomir range', async () => {
|
||||
// The OUI is a portal-side policy, not a precondition for hashing.
|
||||
await expect(
|
||||
deriveStalkerDeviceIdsFromMac('AA:BB:CC:DD:EE:01')
|
||||
).resolves.toEqual({
|
||||
deviceId1: expect.stringMatching(/^[0-9A-F]{64}$/),
|
||||
deviceId2: expect.stringMatching(/^[0-9A-F]{64}$/),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeStalkerMacAddress } from './stalker-mac-address.util';
|
||||
|
||||
export const LEGACY_DEFAULT_STALKER_SERIAL = 'BEDACD4569BAF';
|
||||
const STALKER_CFDUID_LENGTH = 32;
|
||||
const STALKER_SERIAL_CFDUID_SUFFIX = 'e030245495acd6ebfc1';
|
||||
@@ -49,6 +51,76 @@ export function normalizeStalkerPortalIdentity(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Salt that separates `device_id2` from `device_id`, matching the
|
||||
* `stalker-to-m3u` reference client.
|
||||
*/
|
||||
const STALKER_DEVICE_ID2_SALT = 'stalker';
|
||||
|
||||
export interface StalkerDerivedDeviceIds {
|
||||
deviceId1: string;
|
||||
deviceId2: string;
|
||||
}
|
||||
|
||||
async function sha256Upper(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(value)
|
||||
);
|
||||
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the MAC-based device ID pair that StbEmu and the `stalker-to-m3u`
|
||||
* reference client generate: uppercase hex `SHA256` of the canonical
|
||||
* `00:1A:79:…` MAC for `device_id`, and of that MAC plus a `stalker` salt for
|
||||
* `device_id2`. A user who already reached the portal from one of those
|
||||
* clients has these exact values pinned server-side, which is the only reason
|
||||
* deriving them is useful at all.
|
||||
*
|
||||
* The two must differ. On a real box they come from separate firmware calls
|
||||
* (`gSTB.GetUID()` and `gSTB.GetUID('device_id', token)`) and are never equal,
|
||||
* so an identical pair is a fingerprint no STB produces — and since the first
|
||||
* non-empty value is pinned to the MAC permanently, it cannot be corrected
|
||||
* afterwards. They are derived together for that reason: nothing should be
|
||||
* able to fill one without the other.
|
||||
*
|
||||
* This is a PREFILL helper, never a runtime fallback. `device_id`/`device_id2`
|
||||
* are the one identity pair the stock server enforces: the first non-empty
|
||||
* value it sees is bound to the MAC permanently, a later mismatch is refused
|
||||
* as a device conflict, and going back to sending nothing locks the account
|
||||
* out for good. So derived IDs are written into the form as literal values the
|
||||
* user can see and edit, and persisted as literal strings — never recomputed
|
||||
* behind their back, where a MAC edit would silently re-derive them into a
|
||||
* conflict.
|
||||
*
|
||||
* Returns `null` whenever it cannot produce trustworthy IDs: when the MAC is
|
||||
* not a valid address (hashing whatever happens to be in the field would bind
|
||||
* the account to a typo, permanently), and when the runtime has no WebCrypto
|
||||
* — an insecure-context PWA, where the handshake's own SHA-1 prehash cannot
|
||||
* run either, so full-portal auth is already out of reach. Both cases fail
|
||||
* closed: nothing is written, so nothing is pinned.
|
||||
*/
|
||||
export async function deriveStalkerDeviceIdsFromMac(
|
||||
macAddress: string | null | undefined
|
||||
): Promise<StalkerDerivedDeviceIds | null> {
|
||||
const normalizedMac = normalizeStalkerMacAddress(macAddress);
|
||||
if (!normalizedMac || !globalThis.crypto?.subtle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [deviceId1, deviceId2] = await Promise.all([
|
||||
sha256Upper(normalizedMac),
|
||||
sha256Upper(`${normalizedMac}${STALKER_DEVICE_ID2_SALT}`),
|
||||
]);
|
||||
|
||||
return { deviceId1, deviceId2 };
|
||||
}
|
||||
|
||||
export function buildStalkerSerialCfduid(serialNumber: string): string {
|
||||
const serialPrefix = serialNumber.toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
createStalkerMacAddressValidator,
|
||||
hasInfomirMacOui,
|
||||
INFOMIR_MAC_OUI,
|
||||
normalizeStalkerMacAddress,
|
||||
STALKER_MAC_ADDRESS_ERROR,
|
||||
validateStalkerMacAddressControl,
|
||||
} from './stalker-mac-address.util';
|
||||
|
||||
describe('normalizeStalkerMacAddress', () => {
|
||||
it('upper-cases an already canonical address', () => {
|
||||
expect(normalizeStalkerMacAddress('00:1a:79:ab:cd:ef')).toBe(
|
||||
'00:1A:79:AB:CD:EF'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['hyphens', '00-1A-79-AB-CD-EF'],
|
||||
['dots', '001a.79ab.cdef'],
|
||||
['no separator', '001A79ABCDEF'],
|
||||
['surrounding whitespace', ' 00:1A:79:AB:CD:EF '],
|
||||
['embedded whitespace', '00 : 1A : 79 : AB : CD : EF'],
|
||||
])('accepts %s', (_label, input) => {
|
||||
expect(normalizeStalkerMacAddress(input)).toBe('00:1A:79:AB:CD:EF');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['too short', '00:1A:79:AB:CD'],
|
||||
['too long', '00:1A:79:AB:CD:EF:01'],
|
||||
['non-hex digits', '00:1A:79:AB:CD:GG'],
|
||||
['empty', ''],
|
||||
['separators only', '::::::'],
|
||||
])('rejects %s', (_label, input) => {
|
||||
expect(normalizeStalkerMacAddress(input)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([[null], [undefined]])('rejects %p', (input) => {
|
||||
expect(normalizeStalkerMacAddress(input)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasInfomirMacOui', () => {
|
||||
it('accepts the Infomir range regardless of input formatting', () => {
|
||||
expect(hasInfomirMacOui('001a79abcdef')).toBe(true);
|
||||
expect(INFOMIR_MAC_OUI).toBe('00:1A:79');
|
||||
});
|
||||
|
||||
it('reports a foreign OUI without rejecting the address', () => {
|
||||
expect(hasInfomirMacOui('00:1B:79:AB:CD:EF')).toBe(false);
|
||||
expect(normalizeStalkerMacAddress('00:1B:79:AB:CD:EF')).toBe(
|
||||
'00:1B:79:AB:CD:EF'
|
||||
);
|
||||
});
|
||||
|
||||
it('is false for a malformed address', () => {
|
||||
expect(hasInfomirMacOui('00:1A:79')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateStalkerMacAddressControl', () => {
|
||||
it('passes a valid address', () => {
|
||||
expect(
|
||||
validateStalkerMacAddressControl({ value: '00-1a-79-ab-cd-ef' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves emptiness to the required validator', () => {
|
||||
expect(validateStalkerMacAddressControl({ value: '' })).toBeNull();
|
||||
expect(validateStalkerMacAddressControl({ value: ' ' })).toBeNull();
|
||||
expect(validateStalkerMacAddressControl({ value: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('reports a malformed address', () => {
|
||||
expect(validateStalkerMacAddressControl({ value: 'not-a-mac' })).toEqual(
|
||||
{ [STALKER_MAC_ADDRESS_ERROR]: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a MAC outside the Infomir range', () => {
|
||||
// The stock portal's OUI filter is off on most reseller panels, so a
|
||||
// non-Infomir MAC is a working configuration for a lot of users.
|
||||
// Refusing it here would stop them adding or editing a portal that
|
||||
// works today; `hasInfomirMacOui` only drives a hint.
|
||||
expect(
|
||||
validateStalkerMacAddressControl({ value: 'AA:BB:CC:DD:EE:01' })
|
||||
).toBeNull();
|
||||
expect(hasInfomirMacOui('AA:BB:CC:DD:EE:01')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStalkerMacAddressValidator', () => {
|
||||
it('grandfathers the value a playlist already stored', () => {
|
||||
// A playlist saved before this validation existed may hold anything,
|
||||
// and on a panel that ignores the MAC it works. Marking the form
|
||||
// invalid on open would disable Save and strand the user's title, URL
|
||||
// and EPG edits over a field the portal may not even read.
|
||||
const validate = createStalkerMacAddressValidator('legacy-device-42');
|
||||
|
||||
expect(validate({ value: 'legacy-device-42' })).toBeNull();
|
||||
});
|
||||
|
||||
it('still refuses a newly typed malformed value', () => {
|
||||
const validate = createStalkerMacAddressValidator('legacy-device-42');
|
||||
|
||||
expect(validate({ value: 'legacy-device-43' })).toEqual({
|
||||
[STALKER_MAC_ADDRESS_ERROR]: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('is the plain validator when there is nothing to grandfather', () => {
|
||||
expect(
|
||||
createStalkerMacAddressValidator(undefined)({ value: 'nope' })
|
||||
).toEqual({ [STALKER_MAC_ADDRESS_ERROR]: true });
|
||||
expect(
|
||||
createStalkerMacAddressValidator(null)({
|
||||
value: '00:1A:79:AA:BB:CC',
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not let an undefined exemption match an empty control', () => {
|
||||
// `control.value === grandfatheredValue` would be true for two
|
||||
// undefineds, which would exempt every untouched control.
|
||||
const validate = createStalkerMacAddressValidator(undefined);
|
||||
|
||||
expect(validate({ value: undefined })).toBeNull();
|
||||
expect(validate({ value: 'not-a-mac' })).toEqual({
|
||||
[STALKER_MAC_ADDRESS_ERROR]: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Infomir's OUI. The stock Stalker/Ministra MAC validator is enabled by
|
||||
* default and only accepts addresses in this range
|
||||
* (`/^00:1A:79:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}$/`). A MAC outside it is
|
||||
* refused with a bare `{status: 1}` and no explanation, which is
|
||||
* indistinguishable from a blocked account — hence the hint in the import UI.
|
||||
*/
|
||||
export const INFOMIR_MAC_OUI = '00:1A:79';
|
||||
|
||||
const MAC_SEPARATORS = /[\s:.-]/g;
|
||||
const TWELVE_HEX_DIGITS = /^[0-9A-F]{12}$/;
|
||||
|
||||
/**
|
||||
* Canonicalizes a MAC address to the uppercase colon form a real STB sends
|
||||
* (`00:1A:79:12:34:56`), or returns `null` when the input is not a MAC.
|
||||
*
|
||||
* Accepts the shapes users actually paste — colons, hyphens, dots, embedded
|
||||
* whitespace, or no separator at all — because a portal that validates the
|
||||
* format answers a bare `{status: 1}`, and "your MAC has hyphens" is not a
|
||||
* diagnosis anyone can make from that.
|
||||
*
|
||||
* Note this is an INPUT-boundary helper. Stored MAC addresses are deliberately
|
||||
* not rewritten on read: the MAC is the account key, and silently changing the
|
||||
* bytes an already-working playlist puts on the wire is exactly the kind of
|
||||
* unattended identity change the Stalker pinning semantics punish. Normalizing
|
||||
* what the user types (and only that) keeps the change visible and reversible.
|
||||
*/
|
||||
export function normalizeStalkerMacAddress(
|
||||
value: string | null | undefined
|
||||
): string | null {
|
||||
const digits = (value ?? '').replace(MAC_SEPARATORS, '').toUpperCase();
|
||||
|
||||
if (!TWELVE_HEX_DIGITS.test(digits)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (digits.match(/.{2}/g) as string[]).join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the address sits in Infomir's OUI, i.e. the stock portal's default
|
||||
* MAC filter would accept it. A `false` verdict is a hint, never an error:
|
||||
* plenty of resellers disable the check, and refusing to import would lock
|
||||
* those users out of a portal that works.
|
||||
*/
|
||||
export function hasInfomirMacOui(value: string | null | undefined): boolean {
|
||||
const normalized = normalizeStalkerMacAddress(value);
|
||||
|
||||
return normalized !== null && normalized.startsWith(`${INFOMIR_MAC_OUI}:`);
|
||||
}
|
||||
|
||||
/** Error key an invalid MAC control reports. */
|
||||
export const STALKER_MAC_ADDRESS_ERROR = 'stalkerMacAddress';
|
||||
|
||||
/**
|
||||
* Form validator for a Stalker MAC field, shared by the import dialog and the
|
||||
* playlist edit dialog so both accept exactly what `normalizeStalkerMacAddress`
|
||||
* accepts.
|
||||
*
|
||||
* Deliberately structural rather than typed as Angular's `ValidatorFn`: this
|
||||
* library is the contract layer the Electron main process imports, and it must
|
||||
* stay free of framework dependencies. `AbstractControl` satisfies
|
||||
* `{ value: unknown }`, so the function is assignable wherever a `ValidatorFn`
|
||||
* is expected.
|
||||
*
|
||||
* An empty control is left alone — requiredness is a separate concern. The OUI
|
||||
* is not checked at all: a MAC outside Infomir's range is refused by the stock
|
||||
* portal but accepted by most reseller panels, so rejecting it here would lock
|
||||
* users out of a portal that works for them (`hasInfomirMacOui` drives a hint
|
||||
* instead).
|
||||
*/
|
||||
export function validateStalkerMacAddressControl(control: {
|
||||
value: unknown;
|
||||
}): Record<typeof STALKER_MAC_ADDRESS_ERROR, true> | null {
|
||||
const value = control.value;
|
||||
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeStalkerMacAddress(value)
|
||||
? null
|
||||
: { [STALKER_MAC_ADDRESS_ERROR]: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Same validator, but one specific value is grandfathered in.
|
||||
*
|
||||
* The edit dialog needs this: before there was any validation a user could
|
||||
* store an arbitrary string as the MAC, and on a panel that ignores the MAC
|
||||
* entirely such a playlist works today. Attaching the plain validator there
|
||||
* would mark the form invalid on open and disable Save — locking the user out
|
||||
* of editing the title, the URL or the EPG sources of a working source,
|
||||
* forever, over a field the portal may not even read.
|
||||
*
|
||||
* So the value that was already stored stays acceptable, while anything newly
|
||||
* typed is held to the format. `grandfatheredValue` is compared verbatim: the
|
||||
* exemption covers the string that is already on the wire, not a shape.
|
||||
*/
|
||||
export function createStalkerMacAddressValidator(
|
||||
grandfatheredValue: string | null | undefined
|
||||
): (control: { value: unknown }) => Record<
|
||||
typeof STALKER_MAC_ADDRESS_ERROR,
|
||||
true
|
||||
> | null {
|
||||
return (control) =>
|
||||
grandfatheredValue !== undefined &&
|
||||
grandfatheredValue !== null &&
|
||||
control.value === grandfatheredValue
|
||||
? null
|
||||
: validateStalkerMacAddressControl(control);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The set-top box IPTVnator presents itself as. Every value here is a constant
|
||||
* describing the emulated device, never anything derived from the user's
|
||||
* account — the identity the portal binds to a MAC lives in `device_id` /
|
||||
* `device_id2` and is handled separately.
|
||||
*
|
||||
* The stock Stalker middleware reads these on `get_profile` and stores them
|
||||
* for the admin panel; only an operator's optional `access_filter.php` ever
|
||||
* inspects them, and a box that reports nothing at all is the shape some of
|
||||
* those filters reject. They are therefore free to send and worth sending —
|
||||
* but only as one coherent MAG250: the model, firmware, hardware revision and
|
||||
* image version have to describe the same box as `metrics.model` and the
|
||||
* `STALKER_MAG_USER_AGENT` request header, or the profile reads as a forgery.
|
||||
*
|
||||
* These constants must not vary per playlist. They are deliberately excluded
|
||||
* from `stalkerIdentityFingerprint` / `stalkerSessionFingerprint`: changing
|
||||
* them would invalidate every persisted session for no gain, since the portal
|
||||
* does not bind sessions to them.
|
||||
*/
|
||||
export const STALKER_STB_PROFILE_PARAMS: Readonly<Record<string, string>> =
|
||||
Object.freeze({
|
||||
/** Firmware banner a MAG250 reports verbatim. */
|
||||
ver: 'ImageDescription: 0.2.18-r14-pub-250; ImageDate: Fri Jan 15 15:20:44 EET 2016; PORTAL version: 5.6.0; API Version: JS API version: 328; STB API version: 134; Player Engine version: 0x566',
|
||||
/** Was sent empty before — some panels treat that as "not a box". */
|
||||
stb_type: 'MAG250',
|
||||
hw_version: '1.7-BD-00',
|
||||
/** Numeric form of the `0.2.18` firmware in `ver`. */
|
||||
image_version: '218',
|
||||
client_type: 'STB',
|
||||
num_banks: '2',
|
||||
video_out: 'hdmi',
|
||||
hd: '1',
|
||||
});
|
||||
+36
-1
@@ -5,7 +5,10 @@ import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
StalkerPortalError,
|
||||
StalkerStore,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { WORKSPACE_CATEGORY_SORT_STORAGE_KEY } from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
@@ -558,4 +561,36 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Stalker category error description', () => {
|
||||
afterEach(() => {
|
||||
stalkerStore.isCategoryResourceFailed.set(false as never);
|
||||
});
|
||||
|
||||
it('leads with the remedy for a device conflict', () => {
|
||||
// The portal blames the hardware ("Your STB is damaged"), which
|
||||
// is the opposite of actionable — the explanation has to come
|
||||
// first, with the portal's own words kept after it.
|
||||
stalkerStore.isCategoryResourceFailed.set(
|
||||
new StalkerPortalError(
|
||||
'device-conflict',
|
||||
'device conflict - device_id mismatch — Your STB is damaged.'
|
||||
) as never
|
||||
);
|
||||
|
||||
expect(fixture.componentInstance.stalkerCategoryErrorDescription()).toBe(
|
||||
'PORTALS.ERROR_VIEW.STALKER_DEVICE_CONFLICT device conflict - device_id mismatch — Your STB is damaged.'
|
||||
);
|
||||
});
|
||||
|
||||
it('still relays any other refusal verbatim', () => {
|
||||
stalkerStore.isCategoryResourceFailed.set(
|
||||
new StalkerPortalError('blocked', 'Account disabled') as never
|
||||
);
|
||||
|
||||
expect(fixture.componentInstance.stalkerCategoryErrorDescription()).toBe(
|
||||
'Account disabled'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+11
@@ -165,6 +165,17 @@ export class WorkspaceContextPanelComponent {
|
||||
const portalError = asStalkerPortalError(
|
||||
this.isStalkerCategoryFailed()
|
||||
);
|
||||
// Device conflicts are the exception to "the portal explains itself":
|
||||
// its own wording blames the hardware, so the actionable sentence
|
||||
// leads and the portal's text follows it.
|
||||
if (portalError?.kind === 'device-conflict') {
|
||||
const hint = this.translate.instant(
|
||||
'PORTALS.ERROR_VIEW.STALKER_DEVICE_CONFLICT'
|
||||
);
|
||||
return portalError.portalText
|
||||
? `${hint} ${portalError.portalText}`
|
||||
: hint;
|
||||
}
|
||||
if (portalError?.portalText) {
|
||||
return portalError.portalText;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user