mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
feat(backup): exclude portal secrets by default
This commit is contained in:
1 parent
c4974e9b93
commit
581ad6fe8b
4 files changed
+458
-55
No files matched your search
@@ -165,7 +165,9 @@ describe('PlaylistBackupService export → import round-trip', () => {
|
||||
const collaborators = createStatefulBackupCollaborators(state);
|
||||
const exportService = createPlaylistBackupService(collaborators);
|
||||
|
||||
const firstExport = await exportService.exportBackup();
|
||||
const firstExport = await exportService.exportBackup({
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
// Simulate a fresh install that has already cached the same portal
|
||||
// content (offline cache reports completed) but carries no user
|
||||
@@ -220,15 +222,19 @@ describe('PlaylistBackupService export → import round-trip', () => {
|
||||
}),
|
||||
]);
|
||||
expect(state.epgUrls).toEqual(['https://epg.example.com/guide.xml']);
|
||||
expect(
|
||||
state.playlists.map((playlist) => playlist._id)
|
||||
).toEqual(['m3u-1', 'xtream-1', 'stalker-1']);
|
||||
expect(state.playlists.map((playlist) => playlist._id)).toEqual([
|
||||
'm3u-1',
|
||||
'xtream-1',
|
||||
'stalker-1',
|
||||
]);
|
||||
|
||||
// Exporting the restored state must reproduce the original
|
||||
// manifest byte for byte (modulo the export timestamp): any field
|
||||
// silently dropped by export, import, or the restore mapping shows
|
||||
// up as a diff here.
|
||||
const secondExport = await importService.exportBackup();
|
||||
const secondExport = await importService.exportBackup({
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
expect(normalizeManifest(secondExport.manifest)).toEqual(
|
||||
normalizeManifest(firstExport.manifest)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
Playlist,
|
||||
PlaylistBackupManifestV1,
|
||||
PLAYLIST_BACKUP_KIND,
|
||||
PLAYLIST_BACKUP_VERSION,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PlaylistBackupError,
|
||||
type PlaylistBackupExportOptions,
|
||||
} from './playlist-backup.service';
|
||||
import { createPlaylistBackupService } from './playlist-backup.service.test-helpers';
|
||||
|
||||
function portalPlaylists(): Playlist[] {
|
||||
return [
|
||||
{
|
||||
_id: 'xtream-secret',
|
||||
title: 'Xtream',
|
||||
count: 0,
|
||||
importDate: '2026-07-27T00:00:00.000Z',
|
||||
lastUsage: '2026-07-27T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
serverUrl: 'https://xtream.example',
|
||||
username: 'xtream-user-secret',
|
||||
password: 'xtream-password-secret',
|
||||
},
|
||||
{
|
||||
_id: 'stalker-secret',
|
||||
title: 'Stalker',
|
||||
count: 0,
|
||||
importDate: '2026-07-27T00:00:00.000Z',
|
||||
lastUsage: '2026-07-27T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
stalkerSourceUrl: 'https://stalker.example/customer/c/',
|
||||
portalUrl: 'https://stalker.example/customer/portal.php',
|
||||
stalkerLandingUrl: 'https://stalker.example/customer/c/',
|
||||
stalkerRequestRecipe: 'full-session',
|
||||
stalkerRecipeClassifierVersion: 1,
|
||||
stalkerLastVerifiedAt: '2026-07-27T10:00:00.000Z',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
isFullStalkerPortal: true,
|
||||
username: 'stalker-user-secret',
|
||||
password: 'stalker-password-secret',
|
||||
stalkerProfilePreset: {
|
||||
id: 'mag250-public-5_1-minimal-v1',
|
||||
version: 1,
|
||||
},
|
||||
stalkerIdentityOverrides: {
|
||||
serialNumber: 'SERIAL-SECRET',
|
||||
deviceId1: 'DEVICE-SECRET',
|
||||
deviceId2: 'DEVICE-2-SECRET',
|
||||
signature1: 'SIGNATURE-SECRET',
|
||||
signature2: 'SIGNATURE-2-SECRET',
|
||||
prehash: 'PREHASH-SECRET',
|
||||
apiSignature: 'API-SIGNATURE-SECRET',
|
||||
firmwareVersion: 'FIRMWARE-SECRET',
|
||||
imageVersion: 'IMAGE-SECRET',
|
||||
hardwareVersion: 'HARDWARE-SECRET',
|
||||
hardwareVersion2: 'HARDWARE-2-SECRET',
|
||||
numberOfBanks: 'BANKS-SECRET',
|
||||
videoOutput: 'VIDEO-SECRET',
|
||||
},
|
||||
stalkerTransportConfiguration: {
|
||||
locale: 'de-DE',
|
||||
language: 'de',
|
||||
timezone: 'Europe/Berlin',
|
||||
userAgent: 'Explicit Public UA',
|
||||
xUserAgent: 'Explicit Public X-UA',
|
||||
referer: 'https://stalker.example/customer/c/',
|
||||
origin: 'https://stalker.example',
|
||||
},
|
||||
stalkerSerialNumber: 'LEGACY-SERIAL-SECRET',
|
||||
stalkerDeviceId1: 'LEGACY-DEVICE-SECRET',
|
||||
stalkerDeviceId2: 'LEGACY-DEVICE-2-SECRET',
|
||||
stalkerSignature1: 'LEGACY-SIGNATURE-SECRET',
|
||||
stalkerSignature2: 'LEGACY-SIGNATURE-2-SECRET',
|
||||
stalkerToken: 'RUNTIME-TOKEN-SECRET',
|
||||
},
|
||||
] as Playlist[];
|
||||
}
|
||||
|
||||
function serviceWithPortals(playlists = portalPlaylists()) {
|
||||
return createPlaylistBackupService({
|
||||
playlistsService: {
|
||||
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
|
||||
getAllData: jest.fn(() => of(playlists)),
|
||||
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
|
||||
handlePlaylistParsing: jest.fn(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('PlaylistBackupService secret policy', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('excludes structured portal credentials and device identity by default', async () => {
|
||||
const backup = await serviceWithPortals().exportBackup();
|
||||
const serialized = JSON.stringify(backup.manifest);
|
||||
|
||||
expect(backup.manifest.includeSecrets).toBe(false);
|
||||
for (const secret of [
|
||||
'xtream-user-secret',
|
||||
'xtream-password-secret',
|
||||
'stalker-user-secret',
|
||||
'stalker-password-secret',
|
||||
'SERIAL-SECRET',
|
||||
'DEVICE-SECRET',
|
||||
'SIGNATURE-SECRET',
|
||||
'PREHASH-SECRET',
|
||||
'API-SIGNATURE-SECRET',
|
||||
'FIRMWARE-SECRET',
|
||||
'LEGACY-SERIAL-SECRET',
|
||||
'RUNTIME-TOKEN-SECRET',
|
||||
]) {
|
||||
expect(serialized).not.toContain(secret);
|
||||
}
|
||||
|
||||
const xtream = backup.manifest.playlists.find(
|
||||
(entry) => entry.portalType === 'xtream'
|
||||
);
|
||||
expect(xtream).toEqual(
|
||||
expect.objectContaining({
|
||||
connection: {
|
||||
credentialsOmitted: true,
|
||||
serverUrl: 'https://xtream.example',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Stalker source, MAC, preset, non-secret overrides, and portable state', async () => {
|
||||
const backup = await serviceWithPortals().exportBackup();
|
||||
const stalker = backup.manifest.playlists.find(
|
||||
(entry) => entry.portalType === 'stalker'
|
||||
);
|
||||
|
||||
expect(stalker).toEqual(
|
||||
expect.objectContaining({
|
||||
connection: expect.objectContaining({
|
||||
sourceUrl: 'https://stalker.example/customer/c/',
|
||||
portalUrl: 'https://stalker.example/customer/portal.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
profilePreset: {
|
||||
id: 'mag250-public-5_1-minimal-v1',
|
||||
version: 1,
|
||||
},
|
||||
transportConfiguration: expect.objectContaining({
|
||||
locale: 'de-DE',
|
||||
timezone: 'Europe/Berlin',
|
||||
userAgent: 'Explicit Public UA',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(stalker).not.toHaveProperty('connection.identityOverrides');
|
||||
expect(stalker).not.toHaveProperty('connection.username');
|
||||
expect(stalker).not.toHaveProperty('connection.password');
|
||||
|
||||
const serialized = JSON.stringify(stalker);
|
||||
expect(serialized).not.toContain('stalkerLandingUrl');
|
||||
expect(serialized).not.toContain('stalkerRequestRecipe');
|
||||
expect(serialized).not.toContain('stalkerRecipeClassifierVersion');
|
||||
expect(serialized).not.toContain('stalkerLastVerifiedAt');
|
||||
});
|
||||
|
||||
it('includes credentials and explicit device identity only after opt-in', async () => {
|
||||
const options: PlaylistBackupExportOptions = {
|
||||
includeSecrets: true,
|
||||
};
|
||||
const backup = await serviceWithPortals().exportBackup(options);
|
||||
const serialized = JSON.stringify(backup.manifest);
|
||||
|
||||
expect(backup.manifest.includeSecrets).toBe(true);
|
||||
for (const secret of [
|
||||
'xtream-user-secret',
|
||||
'xtream-password-secret',
|
||||
'stalker-user-secret',
|
||||
'stalker-password-secret',
|
||||
'SERIAL-SECRET',
|
||||
'DEVICE-SECRET',
|
||||
'SIGNATURE-SECRET',
|
||||
'PREHASH-SECRET',
|
||||
'API-SIGNATURE-SECRET',
|
||||
'FIRMWARE-SECRET',
|
||||
]) {
|
||||
expect(serialized).toContain(secret);
|
||||
}
|
||||
expect(serialized).not.toContain('RUNTIME-TOKEN-SECRET');
|
||||
});
|
||||
|
||||
it('preserves present empty Stalker secret fields for patch-style opt-in restore', async () => {
|
||||
const [stalker] = portalPlaylists().slice(1);
|
||||
stalker.username = '';
|
||||
stalker.password = '';
|
||||
stalker.stalkerIdentityOverrides = {
|
||||
serialNumber: '',
|
||||
deviceId1: '',
|
||||
};
|
||||
const backup = await serviceWithPortals([stalker]).exportBackup({
|
||||
includeSecrets: true,
|
||||
});
|
||||
const entry = backup.manifest.playlists[0];
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
portalType: 'stalker',
|
||||
connection: {
|
||||
username: '',
|
||||
password: '',
|
||||
identityOverrides: {
|
||||
serialNumber: '',
|
||||
deviceId1: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a manifest that declares secret exclusion but contains gated fields', async () => {
|
||||
const service = serviceWithPortals([]);
|
||||
const manifest: PlaylistBackupManifestV1 = {
|
||||
kind: PLAYLIST_BACKUP_KIND,
|
||||
version: PLAYLIST_BACKUP_VERSION,
|
||||
exportedAt: '2026-07-27T00:00:00.000Z',
|
||||
includeSecrets: false,
|
||||
playlists: [
|
||||
{
|
||||
portalType: 'stalker',
|
||||
exportedId: 'stalker-secret',
|
||||
title: 'Stalker',
|
||||
autoRefresh: false,
|
||||
connection: {
|
||||
portalUrl: 'https://stalker.example/portal.php',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
username: 'forbidden-secret',
|
||||
},
|
||||
userState: {
|
||||
favorites: [],
|
||||
recentlyViewed: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.importBackup(JSON.stringify(manifest))
|
||||
).rejects.toBeInstanceOf(PlaylistBackupError);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,10 @@ export interface PlaylistBackupExportPayload {
|
||||
manifest: PlaylistBackupManifestV1;
|
||||
}
|
||||
|
||||
export interface PlaylistBackupExportOptions {
|
||||
includeSecrets?: boolean;
|
||||
}
|
||||
|
||||
export interface PlaylistBackupImportSummary {
|
||||
imported: number;
|
||||
merged: number;
|
||||
@@ -63,20 +67,25 @@ export class PlaylistBackupService {
|
||||
XtreamPendingRestoreService
|
||||
);
|
||||
|
||||
async exportBackup(): Promise<PlaylistBackupExportPayload> {
|
||||
async exportBackup(
|
||||
options: PlaylistBackupExportOptions = {}
|
||||
): Promise<PlaylistBackupExportPayload> {
|
||||
const includeSecrets = options.includeSecrets === true;
|
||||
const playlists = await firstValueFrom(
|
||||
this.playlistsService.getAllData()
|
||||
);
|
||||
const settings = this.buildSettingsPayload();
|
||||
const playlistEntries = await Promise.all(
|
||||
playlists.map((playlist) => this.buildPlaylistEntry(playlist))
|
||||
playlists.map((playlist) =>
|
||||
this.buildPlaylistEntry(playlist, includeSecrets)
|
||||
)
|
||||
);
|
||||
const exportedAt = new Date().toISOString();
|
||||
const manifest: PlaylistBackupManifestV1 = {
|
||||
kind: PLAYLIST_BACKUP_KIND,
|
||||
version: PLAYLIST_BACKUP_VERSION,
|
||||
exportedAt,
|
||||
includeSecrets: true,
|
||||
includeSecrets,
|
||||
...(settings ? { settings } : {}),
|
||||
playlists: playlistEntries,
|
||||
};
|
||||
@@ -168,15 +177,16 @@ export class PlaylistBackupService {
|
||||
}
|
||||
|
||||
private async buildPlaylistEntry(
|
||||
playlist: Playlist
|
||||
playlist: Playlist,
|
||||
includeSecrets: boolean
|
||||
): Promise<PlaylistBackupEntry> {
|
||||
const portalType = this.getPlaylistPortalType(playlist);
|
||||
|
||||
switch (portalType) {
|
||||
case 'xtream':
|
||||
return this.buildXtreamEntry(playlist);
|
||||
return this.buildXtreamEntry(playlist, includeSecrets);
|
||||
case 'stalker':
|
||||
return this.buildStalkerEntry(playlist);
|
||||
return this.buildStalkerEntry(playlist, includeSecrets);
|
||||
case 'm3u':
|
||||
default:
|
||||
return this.buildM3uEntry(playlist);
|
||||
@@ -231,7 +241,8 @@ export class PlaylistBackupService {
|
||||
}
|
||||
|
||||
private async buildXtreamEntry(
|
||||
playlist: Playlist
|
||||
playlist: Playlist,
|
||||
includeSecrets: boolean
|
||||
): Promise<XtreamPlaylistBackupEntry> {
|
||||
if (!this.hasElectronApi()) {
|
||||
return {
|
||||
@@ -240,13 +251,10 @@ export class PlaylistBackupService {
|
||||
title: playlist.title,
|
||||
autoRefresh: Boolean(playlist.autoRefresh),
|
||||
position: playlist.position,
|
||||
connection: {
|
||||
serverUrl: playlist.serverUrl ?? '',
|
||||
username: playlist.username ?? '',
|
||||
...(playlist.password
|
||||
? { password: playlist.password }
|
||||
: {}),
|
||||
},
|
||||
connection: this.buildXtreamConnection(
|
||||
playlist,
|
||||
includeSecrets
|
||||
),
|
||||
userState: {
|
||||
hiddenCategories: [],
|
||||
favorites: [],
|
||||
@@ -278,11 +286,7 @@ export class PlaylistBackupService {
|
||||
title: playlist.title,
|
||||
autoRefresh: Boolean(playlist.autoRefresh),
|
||||
position: playlist.position,
|
||||
connection: {
|
||||
serverUrl: playlist.serverUrl ?? '',
|
||||
username: playlist.username ?? '',
|
||||
...(playlist.password ? { password: playlist.password } : {}),
|
||||
},
|
||||
connection: this.buildXtreamConnection(playlist, includeSecrets),
|
||||
userState: {
|
||||
hiddenCategories: [
|
||||
...this.mapHiddenCategories(liveCategories, 'live'),
|
||||
@@ -309,7 +313,29 @@ export class PlaylistBackupService {
|
||||
};
|
||||
}
|
||||
|
||||
private buildStalkerEntry(playlist: Playlist): StalkerPlaylistBackupEntry {
|
||||
private buildXtreamConnection(
|
||||
playlist: Playlist,
|
||||
includeSecrets: boolean
|
||||
): XtreamPlaylistBackupEntry['connection'] {
|
||||
if (!includeSecrets) {
|
||||
return {
|
||||
credentialsOmitted: true,
|
||||
serverUrl: playlist.serverUrl ?? '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
serverUrl: playlist.serverUrl ?? '',
|
||||
username: playlist.username ?? '',
|
||||
...(playlist.password !== undefined
|
||||
? { password: playlist.password }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildStalkerEntry(
|
||||
playlist: Playlist,
|
||||
includeSecrets: boolean
|
||||
): StalkerPlaylistBackupEntry {
|
||||
return {
|
||||
portalType: 'stalker',
|
||||
exportedId: playlist._id,
|
||||
@@ -318,33 +344,36 @@ export class PlaylistBackupService {
|
||||
position: playlist.position,
|
||||
connection: {
|
||||
portalUrl: playlist.portalUrl ?? playlist.url ?? '',
|
||||
...(playlist.stalkerSourceUrl !== undefined
|
||||
? { sourceUrl: playlist.stalkerSourceUrl }
|
||||
: {}),
|
||||
macAddress: playlist.macAddress ?? '',
|
||||
...(playlist.isFullStalkerPortal !== undefined
|
||||
? {
|
||||
isFullStalkerPortal: playlist.isFullStalkerPortal,
|
||||
}
|
||||
: {}),
|
||||
...(playlist.username ? { username: playlist.username } : {}),
|
||||
...(playlist.password ? { password: playlist.password } : {}),
|
||||
...(playlist.stalkerProfilePreset !== undefined
|
||||
? {
|
||||
profilePreset: {
|
||||
...playlist.stalkerProfilePreset,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(playlist.stalkerTransportConfiguration !== undefined
|
||||
? {
|
||||
transportConfiguration: {
|
||||
...playlist.stalkerTransportConfiguration,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(playlist.userAgent
|
||||
? { userAgent: playlist.userAgent }
|
||||
: {}),
|
||||
...(playlist.referrer ? { referrer: playlist.referrer } : {}),
|
||||
...(playlist.origin ? { origin: playlist.origin } : {}),
|
||||
...(playlist.stalkerSerialNumber
|
||||
? { stalkerSerialNumber: playlist.stalkerSerialNumber }
|
||||
: {}),
|
||||
...(playlist.stalkerDeviceId1
|
||||
? { stalkerDeviceId1: playlist.stalkerDeviceId1 }
|
||||
: {}),
|
||||
...(playlist.stalkerDeviceId2
|
||||
? { stalkerDeviceId2: playlist.stalkerDeviceId2 }
|
||||
: {}),
|
||||
...(playlist.stalkerSignature1
|
||||
? { stalkerSignature1: playlist.stalkerSignature1 }
|
||||
: {}),
|
||||
...(playlist.stalkerSignature2
|
||||
? { stalkerSignature2: playlist.stalkerSignature2 }
|
||||
...(includeSecrets
|
||||
? this.buildStalkerSecretConnectionFields(playlist)
|
||||
: {}),
|
||||
},
|
||||
userState: {
|
||||
@@ -356,6 +385,53 @@ export class PlaylistBackupService {
|
||||
};
|
||||
}
|
||||
|
||||
private buildStalkerSecretConnectionFields(
|
||||
playlist: Playlist
|
||||
): Pick<
|
||||
StalkerPlaylistBackupEntry['connection'],
|
||||
| 'identityOverrides'
|
||||
| 'password'
|
||||
| 'stalkerDeviceId1'
|
||||
| 'stalkerDeviceId2'
|
||||
| 'stalkerSerialNumber'
|
||||
| 'stalkerSignature1'
|
||||
| 'stalkerSignature2'
|
||||
| 'username'
|
||||
> {
|
||||
return {
|
||||
...(playlist.username !== undefined
|
||||
? { username: playlist.username }
|
||||
: {}),
|
||||
...(playlist.password !== undefined
|
||||
? { password: playlist.password }
|
||||
: {}),
|
||||
...(playlist.stalkerIdentityOverrides !== undefined
|
||||
? {
|
||||
identityOverrides: {
|
||||
...playlist.stalkerIdentityOverrides,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(playlist.stalkerSerialNumber !== undefined
|
||||
? {
|
||||
stalkerSerialNumber: playlist.stalkerSerialNumber,
|
||||
}
|
||||
: {}),
|
||||
...(playlist.stalkerDeviceId1 !== undefined
|
||||
? { stalkerDeviceId1: playlist.stalkerDeviceId1 }
|
||||
: {}),
|
||||
...(playlist.stalkerDeviceId2 !== undefined
|
||||
? { stalkerDeviceId2: playlist.stalkerDeviceId2 }
|
||||
: {}),
|
||||
...(playlist.stalkerSignature1 !== undefined
|
||||
? { stalkerSignature1: playlist.stalkerSignature1 }
|
||||
: {}),
|
||||
...(playlist.stalkerSignature2 !== undefined
|
||||
? { stalkerSignature2: playlist.stalkerSignature2 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSettingsPayload(): PlaylistBackupSettings | undefined {
|
||||
const epgUrls = this.normalizeSettingsUrls(
|
||||
this.settingsStore.getSettings().epgUrl ?? []
|
||||
@@ -407,14 +483,36 @@ export class PlaylistBackupService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
manifest.includeSecrets !== undefined &&
|
||||
typeof manifest.includeSecrets !== 'boolean'
|
||||
) {
|
||||
throw new PlaylistBackupError(
|
||||
'Backup contains an invalid secret policy.'
|
||||
);
|
||||
}
|
||||
const includeSecrets = manifest.includeSecrets !== false;
|
||||
const exportedIds = new Set<string>();
|
||||
for (const entry of manifest.playlists) {
|
||||
this.validateEntry(entry);
|
||||
this.validateEntry(entry, includeSecrets);
|
||||
if (exportedIds.has(entry.exportedId)) {
|
||||
throw new PlaylistBackupError(
|
||||
'Backup contains duplicate exported playlist IDs.'
|
||||
);
|
||||
}
|
||||
exportedIds.add(entry.exportedId);
|
||||
}
|
||||
|
||||
return manifest as PlaylistBackupManifestV1;
|
||||
return {
|
||||
...(manifest as PlaylistBackupManifestV1),
|
||||
includeSecrets,
|
||||
};
|
||||
}
|
||||
|
||||
private validateEntry(entry: PlaylistBackupEntry): void {
|
||||
private validateEntry(
|
||||
entry: PlaylistBackupEntry,
|
||||
includeSecrets: boolean
|
||||
): void {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new PlaylistBackupError(
|
||||
'Backup contains an invalid playlist entry.'
|
||||
@@ -436,14 +534,23 @@ export class PlaylistBackupService {
|
||||
}
|
||||
break;
|
||||
case 'xtream':
|
||||
if (
|
||||
!entry.connection?.serverUrl ||
|
||||
!entry.connection?.username
|
||||
) {
|
||||
if (!entry.connection?.serverUrl) {
|
||||
throw new PlaylistBackupError(
|
||||
`Xtream backup "${entry.title}" is missing connection metadata.`
|
||||
);
|
||||
}
|
||||
if (
|
||||
includeSecrets
|
||||
? !entry.connection.username ||
|
||||
entry.connection.credentialsOmitted === true
|
||||
: entry.connection.credentialsOmitted !== true ||
|
||||
hasOwn(entry.connection, 'username') ||
|
||||
hasOwn(entry.connection, 'password')
|
||||
) {
|
||||
throw new PlaylistBackupError(
|
||||
`Xtream backup "${entry.title}" violates its secret policy.`
|
||||
);
|
||||
}
|
||||
|
||||
// Restore treats the backup's user state as authoritative and
|
||||
// replaces the existing state with it. Every v1 export writes
|
||||
@@ -470,6 +577,14 @@ export class PlaylistBackupService {
|
||||
`Stalker backup "${entry.title}" is missing connection metadata.`
|
||||
);
|
||||
}
|
||||
if (
|
||||
!includeSecrets &&
|
||||
containsStalkerGatedFields(entry.connection)
|
||||
) {
|
||||
throw new PlaylistBackupError(
|
||||
`Stalker backup "${entry.title}" violates its secret policy.`
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new PlaylistBackupError(
|
||||
@@ -539,7 +654,9 @@ export class PlaylistBackupService {
|
||||
return [
|
||||
'xtream',
|
||||
this.normalizeUrlIdentity(entry.connection.serverUrl),
|
||||
this.normalizeIdentityValue(entry.connection.username),
|
||||
this.normalizeIdentityValue(
|
||||
entry.connection.username ?? ''
|
||||
),
|
||||
].join('|');
|
||||
case 'stalker':
|
||||
return [
|
||||
@@ -627,11 +744,12 @@ export class PlaylistBackupService {
|
||||
playlistId: string,
|
||||
existing: Playlist | null
|
||||
): Promise<Playlist> {
|
||||
const parsedPlaylist = await this.playlistsService.handlePlaylistParsing(
|
||||
'TEXT',
|
||||
entry.source.rawM3u,
|
||||
entry.title
|
||||
);
|
||||
const parsedPlaylist =
|
||||
await this.playlistsService.handlePlaylistParsing(
|
||||
'TEXT',
|
||||
entry.source.rawM3u,
|
||||
entry.title
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
@@ -1035,3 +1153,22 @@ export class PlaylistBackupService {
|
||||
return !!(window as Window & { electron?: unknown }).electron;
|
||||
}
|
||||
}
|
||||
|
||||
function hasOwn(value: object, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
function containsStalkerGatedFields(
|
||||
connection: StalkerPlaylistBackupEntry['connection']
|
||||
): boolean {
|
||||
return [
|
||||
'identityOverrides',
|
||||
'password',
|
||||
'stalkerDeviceId1',
|
||||
'stalkerDeviceId2',
|
||||
'stalkerSerialNumber',
|
||||
'stalkerSignature1',
|
||||
'stalkerSignature2',
|
||||
'username',
|
||||
].some((key) => hasOwn(connection, key));
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { PlaybackPositionData } from './playback-position.interface';
|
||||
import { M3uRecentlyViewedItem } from './playlist-recently-viewed.interface';
|
||||
import { StalkerPortalItem } from './stalker-portal-item.interface';
|
||||
import type {
|
||||
StalkerSessionIdentityOverrides,
|
||||
StalkerSessionProfilePreset,
|
||||
StalkerSessionTransportConfiguration,
|
||||
} from './stalker-session.interface';
|
||||
|
||||
export const PLAYLIST_BACKUP_KIND = 'iptvnator-playlist-backup';
|
||||
export const PLAYLIST_BACKUP_VERSION = 1;
|
||||
@@ -69,8 +74,9 @@ export interface XtreamPlaylistBackupEntry extends PlaylistBackupBaseEntry {
|
||||
portalType: 'xtream';
|
||||
connection: {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
credentialsOmitted?: true;
|
||||
};
|
||||
userState: XtreamBackupUserState;
|
||||
}
|
||||
@@ -79,8 +85,12 @@ export interface StalkerPlaylistBackupEntry extends PlaylistBackupBaseEntry {
|
||||
portalType: 'stalker';
|
||||
connection: {
|
||||
portalUrl: string;
|
||||
sourceUrl?: string;
|
||||
macAddress: string;
|
||||
isFullStalkerPortal?: boolean;
|
||||
profilePreset?: StalkerSessionProfilePreset;
|
||||
transportConfiguration?: StalkerSessionTransportConfiguration;
|
||||
identityOverrides?: StalkerSessionIdentityOverrides;
|
||||
username?: string;
|
||||
password?: string;
|
||||
userAgent?: string;
|
||||
|
||||
Reference in new issue
Block a user