mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(backup): harden portal credential restore
This commit is contained in:
1 parent
9988e506de
commit
171095e091
9 files changed
+332
-24
No files matched your search
@@ -96,7 +96,7 @@ describe('SettingsBackupCredentialsDialogComponent', () => {
|
||||
expect(text).toContain('Enter a password.');
|
||||
});
|
||||
|
||||
it('returns a trimmed username while preserving the password exactly', () => {
|
||||
it('returns the username and password exactly as entered', () => {
|
||||
component.credentialsForm.setValue({
|
||||
username: ' viewer ',
|
||||
password: ' secret with spaces ',
|
||||
@@ -105,7 +105,7 @@ describe('SettingsBackupCredentialsDialogComponent', () => {
|
||||
component.submit();
|
||||
|
||||
expect(dialogRef.close).toHaveBeenCalledWith({
|
||||
username: 'viewer',
|
||||
username: ' viewer ',
|
||||
password: ' secret with spaces ',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ export class SettingsBackupCredentialsDialogComponent {
|
||||
|
||||
const { username, password } = this.credentialsForm.getRawValue();
|
||||
this.dialogRef.close({
|
||||
username: username.trim(),
|
||||
username,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,6 +175,107 @@ describe('PlaylistBackupService Stalker restore safety', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('matches a structured backup identity to the equivalent legacy local identity', async () => {
|
||||
const local = existing({
|
||||
_id: 'local-id',
|
||||
stalkerIdentityOverrides: undefined,
|
||||
stalkerDeviceId1: 'LOCAL-DEVICE',
|
||||
stalkerSerialNumber: 'LOCAL-SERIAL',
|
||||
});
|
||||
const { playlistsService, service } = serviceWith([local]);
|
||||
const backup = entry({
|
||||
portalUrl: local.portalUrl as string,
|
||||
sourceUrl: local.stalkerSourceUrl,
|
||||
macAddress: local.macAddress as string,
|
||||
profilePreset: local.stalkerProfilePreset,
|
||||
identityOverrides: {
|
||||
deviceId1: 'LOCAL-DEVICE',
|
||||
serialNumber: 'LOCAL-SERIAL',
|
||||
},
|
||||
username: local.username,
|
||||
password: 'replacement-password',
|
||||
});
|
||||
|
||||
const summary = await service.importBackup(
|
||||
JSON.stringify(manifest(backup, true))
|
||||
);
|
||||
|
||||
expect(summary).toMatchObject({ imported: 0, merged: 1 });
|
||||
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
_id: 'local-id',
|
||||
password: 'replacement-password',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not merge when a legacy explicit device identity differs', async () => {
|
||||
const local = existing({
|
||||
_id: 'local-id',
|
||||
stalkerIdentityOverrides: undefined,
|
||||
stalkerDeviceId1: 'LOCAL-DEVICE',
|
||||
});
|
||||
const { service } = serviceWith([local]);
|
||||
const backup = entry({
|
||||
portalUrl: local.portalUrl as string,
|
||||
sourceUrl: local.stalkerSourceUrl,
|
||||
macAddress: local.macAddress as string,
|
||||
profilePreset: local.stalkerProfilePreset,
|
||||
stalkerDeviceId1: 'OTHER-DEVICE',
|
||||
username: local.username,
|
||||
password: 'replacement-password',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.importBackup(JSON.stringify(manifest(backup, true)))
|
||||
).resolves.toMatchObject({ imported: 1, merged: 0 });
|
||||
});
|
||||
|
||||
it('matches equivalent structured and legacy transport, but separates a transport mismatch', async () => {
|
||||
const local = existing({
|
||||
_id: 'local-id',
|
||||
origin: 'https://portal.test',
|
||||
referrer: 'https://portal.test/c/',
|
||||
stalkerTransportConfiguration: undefined,
|
||||
userAgent: 'MAG-UA',
|
||||
});
|
||||
const matching = entry({
|
||||
portalUrl: local.portalUrl as string,
|
||||
sourceUrl: local.stalkerSourceUrl,
|
||||
macAddress: local.macAddress as string,
|
||||
profilePreset: local.stalkerProfilePreset,
|
||||
identityOverrides: local.stalkerIdentityOverrides,
|
||||
transportConfiguration: {
|
||||
origin: 'https://portal.test',
|
||||
referer: 'https://portal.test/c/',
|
||||
userAgent: 'MAG-UA',
|
||||
},
|
||||
username: local.username,
|
||||
password: 'replacement-password',
|
||||
});
|
||||
const matchingHarness = serviceWith([local]);
|
||||
|
||||
await expect(
|
||||
matchingHarness.service.importBackup(
|
||||
JSON.stringify(manifest(matching, true))
|
||||
)
|
||||
).resolves.toMatchObject({ imported: 0, merged: 1 });
|
||||
|
||||
const mismatching = entry({
|
||||
...matching.connection,
|
||||
transportConfiguration: {
|
||||
...matching.connection.transportConfiguration,
|
||||
userAgent: 'DIFFERENT-UA',
|
||||
},
|
||||
});
|
||||
const mismatchHarness = serviceWith([local]);
|
||||
await expect(
|
||||
mismatchHarness.service.importBackup(
|
||||
JSON.stringify(manifest(mismatching, true))
|
||||
)
|
||||
).resolves.toMatchObject({ imported: 1, merged: 0 });
|
||||
});
|
||||
|
||||
it('keeps whitespace-distinct Stalker principals as separate rows', async () => {
|
||||
const local = existing({
|
||||
_id: 'local-id',
|
||||
|
||||
@@ -61,6 +61,9 @@ export function createPlaylistBackupService(
|
||||
clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined),
|
||||
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
portalStatusService: {
|
||||
checkPortalStatus: jest.fn().mockResolvedValue('active'),
|
||||
},
|
||||
pendingRestoreService: {
|
||||
set: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PlaylistsService } from './playlists.service';
|
||||
import { SettingsStore } from './settings-store.service';
|
||||
import { DatabaseService } from './database-electron.service';
|
||||
import { PlaybackPositionService } from './playback-position.service';
|
||||
import { PortalStatusService } from './portal-status.service';
|
||||
import { XtreamPendingRestoreService } from './xtream-pending-restore.service';
|
||||
import {
|
||||
isM3uRecentlyViewedItem,
|
||||
@@ -17,6 +18,9 @@ import {
|
||||
PlaylistBackupSettings,
|
||||
PLAYLIST_BACKUP_KIND,
|
||||
PLAYLIST_BACKUP_VERSION,
|
||||
LEGACY_DEFAULT_STALKER_SERIAL,
|
||||
StalkerSessionIdentityOverrides,
|
||||
StalkerSessionTransportConfiguration,
|
||||
StalkerPlaylistBackupEntry,
|
||||
StalkerPortalItem,
|
||||
XtreamBackupCategoryType,
|
||||
@@ -80,6 +84,7 @@ export class PlaylistBackupService {
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly playbackPositionService = inject(PlaybackPositionService);
|
||||
private readonly portalStatusService = inject(PortalStatusService);
|
||||
private readonly pendingRestoreService = inject(
|
||||
XtreamPendingRestoreService
|
||||
);
|
||||
@@ -656,14 +661,25 @@ export class PlaylistBackupService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const username = credentials.username.trim();
|
||||
const password = credentials.password.trim();
|
||||
if (!username || !password) {
|
||||
const { username, password } = credentials;
|
||||
if (!username.trim() || !password.trim()) {
|
||||
throw new PlaylistBackupError(
|
||||
'Xtream credentials must include a username and password.'
|
||||
);
|
||||
}
|
||||
|
||||
const status = await this.portalStatusService.checkPortalStatus(
|
||||
entry.connection.serverUrl,
|
||||
username,
|
||||
password,
|
||||
{ skipCache: true }
|
||||
);
|
||||
if (status !== 'active') {
|
||||
throw new PlaylistBackupError(
|
||||
'Xtream credentials could not be validated as an active account.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
connection: {
|
||||
@@ -755,9 +771,17 @@ export class PlaylistBackupService {
|
||||
const identityMatches = candidates.filter(
|
||||
(playlist) =>
|
||||
this.hasSameStalkerSourceMacAndProfile(entry, playlist) &&
|
||||
this.stableObjectIdentity(playlist.stalkerIdentityOverrides) ===
|
||||
this.stableObjectIdentity(
|
||||
this.effectiveStalkerIdentityFromPlaylist(playlist)
|
||||
) ===
|
||||
this.stableObjectIdentity(
|
||||
entry.connection.identityOverrides
|
||||
this.effectiveStalkerIdentityFromBackup(entry)
|
||||
) &&
|
||||
this.stableObjectIdentity(
|
||||
this.effectiveStalkerTransportFromPlaylist(playlist)
|
||||
) ===
|
||||
this.stableObjectIdentity(
|
||||
this.effectiveStalkerTransportFromBackup(entry)
|
||||
)
|
||||
);
|
||||
const exportedIdMatch = identityMatches.find(
|
||||
@@ -826,13 +850,113 @@ export class PlaylistBackupService {
|
||||
private isLegacyStalkerBackupEntry(
|
||||
entry: StalkerPlaylistBackupEntry
|
||||
): boolean {
|
||||
const connection = entry.connection;
|
||||
return (
|
||||
!hasOwn(entry.connection, 'sourceUrl') &&
|
||||
!hasOwn(entry.connection, 'profilePreset') &&
|
||||
!hasOwn(entry.connection, 'identityOverrides')
|
||||
!hasOwn(connection, 'sourceUrl') &&
|
||||
!hasOwn(connection, 'profilePreset') &&
|
||||
!hasOwn(connection, 'identityOverrides') &&
|
||||
!hasOwn(connection, 'transportConfiguration') &&
|
||||
!hasOwn(connection, 'username') &&
|
||||
!hasOwn(connection, 'userAgent') &&
|
||||
!hasOwn(connection, 'referrer') &&
|
||||
!hasOwn(connection, 'origin') &&
|
||||
!hasOwn(connection, 'stalkerSerialNumber') &&
|
||||
!hasOwn(connection, 'stalkerDeviceId1') &&
|
||||
!hasOwn(connection, 'stalkerDeviceId2') &&
|
||||
!hasOwn(connection, 'stalkerSignature1') &&
|
||||
!hasOwn(connection, 'stalkerSignature2')
|
||||
);
|
||||
}
|
||||
|
||||
private effectiveStalkerIdentityFromBackup(
|
||||
entry: StalkerPlaylistBackupEntry
|
||||
): StalkerSessionIdentityOverrides | undefined {
|
||||
if (entry.connection.identityOverrides !== undefined) {
|
||||
return this.presentStringFields(
|
||||
entry.connection.identityOverrides
|
||||
) as StalkerSessionIdentityOverrides | undefined;
|
||||
}
|
||||
return this.presentStringFields({
|
||||
deviceId1: entry.connection.stalkerDeviceId1,
|
||||
deviceId2: entry.connection.stalkerDeviceId2,
|
||||
serialNumber: this.nonSyntheticLegacySerial(
|
||||
entry.connection.stalkerSerialNumber
|
||||
),
|
||||
signature1: entry.connection.stalkerSignature1,
|
||||
signature2: entry.connection.stalkerSignature2,
|
||||
}) as StalkerSessionIdentityOverrides | undefined;
|
||||
}
|
||||
|
||||
private effectiveStalkerIdentityFromPlaylist(
|
||||
playlist: Playlist
|
||||
): StalkerSessionIdentityOverrides | undefined {
|
||||
if (playlist.stalkerIdentityOverrides !== undefined) {
|
||||
return this.presentStringFields(
|
||||
playlist.stalkerIdentityOverrides
|
||||
) as StalkerSessionIdentityOverrides | undefined;
|
||||
}
|
||||
return this.presentStringFields({
|
||||
deviceId1: playlist.stalkerDeviceId1,
|
||||
deviceId2: playlist.stalkerDeviceId2,
|
||||
serialNumber: this.nonSyntheticLegacySerial(
|
||||
playlist.stalkerSerialNumber
|
||||
),
|
||||
signature1: playlist.stalkerSignature1,
|
||||
signature2: playlist.stalkerSignature2,
|
||||
}) as StalkerSessionIdentityOverrides | undefined;
|
||||
}
|
||||
|
||||
private effectiveStalkerTransportFromBackup(
|
||||
entry: StalkerPlaylistBackupEntry
|
||||
): StalkerSessionTransportConfiguration | undefined {
|
||||
if (entry.connection.transportConfiguration !== undefined) {
|
||||
return this.presentStringFields(
|
||||
entry.connection.transportConfiguration
|
||||
) as StalkerSessionTransportConfiguration | undefined;
|
||||
}
|
||||
return this.presentStringFields({
|
||||
origin: entry.connection.origin,
|
||||
referer: entry.connection.referrer,
|
||||
userAgent: entry.connection.userAgent,
|
||||
}) as StalkerSessionTransportConfiguration | undefined;
|
||||
}
|
||||
|
||||
private effectiveStalkerTransportFromPlaylist(
|
||||
playlist: Playlist
|
||||
): StalkerSessionTransportConfiguration | undefined {
|
||||
if (playlist.stalkerTransportConfiguration !== undefined) {
|
||||
return this.presentStringFields(
|
||||
playlist.stalkerTransportConfiguration
|
||||
) as StalkerSessionTransportConfiguration | undefined;
|
||||
}
|
||||
return this.presentStringFields({
|
||||
origin: playlist.origin,
|
||||
referer: playlist.referrer,
|
||||
userAgent: playlist.userAgent,
|
||||
}) as StalkerSessionTransportConfiguration | undefined;
|
||||
}
|
||||
|
||||
private nonSyntheticLegacySerial(
|
||||
serialNumber: string | undefined
|
||||
): string | undefined {
|
||||
return serialNumber?.trim().toUpperCase() ===
|
||||
LEGACY_DEFAULT_STALKER_SERIAL
|
||||
? undefined
|
||||
: serialNumber;
|
||||
}
|
||||
|
||||
private presentStringFields(
|
||||
value: Readonly<object>
|
||||
): Readonly<Record<string, string>> | undefined {
|
||||
const present: Record<string, string> = {};
|
||||
for (const [key, field] of Object.entries(value)) {
|
||||
if (typeof field === 'string' && field.trim().length > 0) {
|
||||
present[key] = field;
|
||||
}
|
||||
}
|
||||
return Object.keys(present).length > 0 ? present : undefined;
|
||||
}
|
||||
|
||||
private chooseUnambiguousMatch(
|
||||
candidates: readonly Playlist[],
|
||||
exportedId: string
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
Playlist,
|
||||
PlaylistBackupManifestV1,
|
||||
PLAYLIST_BACKUP_KIND,
|
||||
PLAYLIST_BACKUP_VERSION,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { createPlaylistBackupService } from './playlist-backup.service.test-helpers';
|
||||
|
||||
describe('PlaylistBackupService redacted Xtream credential validation', () => {
|
||||
it('does not persist the entry when the normal connection check rejects the supplied credentials', async () => {
|
||||
const manifest: PlaylistBackupManifestV1 = {
|
||||
kind: PLAYLIST_BACKUP_KIND,
|
||||
version: PLAYLIST_BACKUP_VERSION,
|
||||
exportedAt: '2026-07-27T00:00:00.000Z',
|
||||
includeSecrets: false,
|
||||
playlists: [
|
||||
{
|
||||
portalType: 'xtream',
|
||||
exportedId: 'xtream-redacted',
|
||||
title: 'Redacted Xtream',
|
||||
autoRefresh: false,
|
||||
connection: {
|
||||
credentialsOmitted: true,
|
||||
serverUrl: 'https://portal.test/base/',
|
||||
},
|
||||
userState: {
|
||||
hiddenCategories: [],
|
||||
favorites: [],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const playlistsService = {
|
||||
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
|
||||
getAllData: jest.fn(() => of([])),
|
||||
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
|
||||
handlePlaylistParsing: jest.fn(),
|
||||
};
|
||||
const portalStatusService = {
|
||||
checkPortalStatus: jest.fn().mockResolvedValue('inactive'),
|
||||
};
|
||||
const service = createPlaylistBackupService({
|
||||
playlistsService,
|
||||
portalStatusService,
|
||||
});
|
||||
|
||||
const summary = await service.importBackup(JSON.stringify(manifest), {
|
||||
resolveXtreamCredentials: async () => ({
|
||||
username: 'restored-user',
|
||||
password: 'wrong-password',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({ failed: 1, imported: 0 })
|
||||
);
|
||||
expect(summary.errors[0]).toContain(
|
||||
'Xtream credentials could not be validated'
|
||||
);
|
||||
expect(playlistsService.addPlaylist).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -268,7 +268,12 @@ describe('PlaylistBackupService redacted Xtream restore', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function serviceWith(playlists: Playlist[]) {
|
||||
function serviceWith(
|
||||
playlists: Playlist[],
|
||||
portalStatusService = {
|
||||
checkPortalStatus: jest.fn().mockResolvedValue('active'),
|
||||
}
|
||||
) {
|
||||
const playlistsService = {
|
||||
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
|
||||
getAllData: jest.fn(() => of(playlists)),
|
||||
@@ -276,8 +281,12 @@ describe('PlaylistBackupService redacted Xtream restore', () => {
|
||||
handlePlaylistParsing: jest.fn(),
|
||||
};
|
||||
return {
|
||||
portalStatusService,
|
||||
playlistsService,
|
||||
service: createPlaylistBackupService({ playlistsService }),
|
||||
service: createPlaylistBackupService({
|
||||
playlistsService,
|
||||
portalStatusService,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,7 +343,9 @@ describe('PlaylistBackupService redacted Xtream restore', () => {
|
||||
});
|
||||
|
||||
it('creates the row only after the resolver supplies valid credentials', async () => {
|
||||
const { playlistsService, service } = serviceWith([]);
|
||||
const { playlistsService, portalStatusService, service } = serviceWith(
|
||||
[]
|
||||
);
|
||||
|
||||
const summary = await service.importBackup(
|
||||
JSON.stringify(redactedManifest()),
|
||||
@@ -347,11 +358,17 @@ describe('PlaylistBackupService redacted Xtream restore', () => {
|
||||
);
|
||||
|
||||
expect(summary.imported).toBe(1);
|
||||
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith(
|
||||
'https://portal.test/base/',
|
||||
' restored-user ',
|
||||
' restored-password ',
|
||||
{ skipCache: true }
|
||||
);
|
||||
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serverUrl: 'https://portal.test/base/',
|
||||
username: 'restored-user',
|
||||
password: 'restored-password',
|
||||
username: ' restored-user ',
|
||||
password: ' restored-password ',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('PortalStatusService', () => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('normalizes full playlist URLs and trims copied credentials before checking status', async () => {
|
||||
it('normalizes full playlist URLs while preserving the exact credentials', async () => {
|
||||
dataService.sendIpcEvent.mockResolvedValue({
|
||||
payload: {
|
||||
user_info: {
|
||||
@@ -103,8 +103,8 @@ describe('PortalStatusService', () => {
|
||||
url: 'https://example.com',
|
||||
params: {
|
||||
action: 'get_account_info',
|
||||
password: 'pass',
|
||||
username: 'user',
|
||||
password: ' pass ',
|
||||
username: ' user ',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -185,16 +185,14 @@ export class PortalStatusService {
|
||||
username: string;
|
||||
} | null {
|
||||
try {
|
||||
const normalizedUsername = username.trim();
|
||||
const normalizedPassword = password.trim();
|
||||
if (!normalizedUsername || !normalizedPassword) {
|
||||
if (!username.trim() || !password.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl: normalizeXtreamServerUrl(serverUrl),
|
||||
username: normalizedUsername,
|
||||
password: normalizedPassword,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
Reference in new issue
Block a user