mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
fix(xtream): guard the account-info answer by playlist identity and reject rolled-over dates
Review follow-ups (Greptile): - A source switch while `get_account_info` is in flight no longer hands playlist A's status or clock to playlist B: the store is patched only while the asking playlist is still selected, the timezone is persisted under the asking playlist's id regardless, and a late failure cannot mark the newly selected playlist unavailable. - `parseNaiveUtcMs` reads the constructed date back, so out-of-range panel strings (`2026-13-01 25:00:00`) are rejected instead of silently rolling over into a real instant. - Document that a clock-derived fixed offset is a DST-less snapshot, refreshed by every account-info check and only ever used for non-standard servers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
94623590c4
commit
89beb2c4a5
5 files changed
+133
-16
No files matched your search
@@ -269,7 +269,11 @@ timezone is learned by `withPortal.checkPortalStatus()` and normalized by
|
||||
- otherwise (`UTC+3`, a typo, an unknown alias) the offset is derived from
|
||||
the clock pair the same response carries — `time_now` read as a naive UTC
|
||||
wall clock minus `timestamp_now`, snapped to 15 minutes — and stored as
|
||||
`UTC±HH:MM`;
|
||||
`UTC±HH:MM`. This is a snapshot without DST rules: for such a panel,
|
||||
programmes on the far side of a DST switch are off by an hour until the
|
||||
next account-info check refreshes the offset. Xtream Codes reports PHP
|
||||
timezone identifiers (IANA names), so the snapshot only serves
|
||||
non-standard servers, where the alternative was the viewer's clock;
|
||||
- with neither, nothing is stored and the URL falls back to the viewer's
|
||||
clock, the only remaining guess.
|
||||
|
||||
|
||||
@@ -199,6 +199,58 @@ describe('withPortal', () => {
|
||||
expect(transformPlaylistMeta).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists a late answer under the playlist that asked, never onto the one selected meanwhile', async () => {
|
||||
const other: XtreamPlaylistData = {
|
||||
...PLAYLIST,
|
||||
id: 'playlist-2',
|
||||
name: 'Other portal',
|
||||
serverUrl: 'https://other.example.com',
|
||||
};
|
||||
let answer!: (value: unknown) => void;
|
||||
apiService.getAccountInfo.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const pending = store.checkPortalStatus();
|
||||
store.setCurrentPlaylist(other);
|
||||
answer({
|
||||
user_info: { auth: 1, exp_date: '0', status: 'Active' },
|
||||
server_info: { timezone: 'Europe/London' },
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toBe('active');
|
||||
expect(store.currentPlaylist()).toEqual(other);
|
||||
expect(store.portalStatus()).toBe('unavailable');
|
||||
expect(transformPlaylistMeta).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(storedPlaylist.serverTimezone).toBe('Europe/London');
|
||||
});
|
||||
|
||||
it('does not mark a playlist selected meanwhile unavailable for the earlier playlist’s failure', async () => {
|
||||
const other: XtreamPlaylistData = { ...PLAYLIST, id: 'playlist-2' };
|
||||
let fail!: (reason: unknown) => void;
|
||||
apiService.getAccountInfo.mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
fail = reject;
|
||||
})
|
||||
);
|
||||
|
||||
const pending = store.checkPortalStatus();
|
||||
store.setCurrentPlaylist(other);
|
||||
respondWith({ timezone: 'UTC' });
|
||||
await store.checkPortalStatus();
|
||||
expect(store.portalStatus()).toBe('active');
|
||||
|
||||
fail(new Error('panel down'));
|
||||
|
||||
await expect(pending).resolves.toBe('unavailable');
|
||||
expect(store.portalStatus()).toBe('active');
|
||||
});
|
||||
|
||||
it('still reports the portal status when persisting the timezone fails', async () => {
|
||||
transformPlaylistMeta.mockImplementation(() => {
|
||||
throw new Error('storage unavailable');
|
||||
|
||||
@@ -166,10 +166,17 @@ export function withPortal() {
|
||||
.map((format) => format.trim())
|
||||
.filter(Boolean)
|
||||
: undefined;
|
||||
patchState(store, { portalStatus });
|
||||
// The answer belongs to the playlist whose
|
||||
// credentials were sent: its timezone is persisted
|
||||
// under THAT id regardless, while the store is
|
||||
// patched only if that playlist is still the
|
||||
// selected one — a source switch during the request
|
||||
// must not hand playlist A's status or clock to
|
||||
// playlist B.
|
||||
const current = store.currentPlaylist();
|
||||
if (current) {
|
||||
if (current?.id === playlist.id) {
|
||||
patchState(store, {
|
||||
portalStatus,
|
||||
currentPlaylist: {
|
||||
...current,
|
||||
allowedOutputFormats,
|
||||
@@ -178,20 +185,22 @@ export function withPortal() {
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (
|
||||
serverTimezone &&
|
||||
serverTimezone !== current.serverTimezone
|
||||
) {
|
||||
await rememberServerTimezone(
|
||||
current.id,
|
||||
serverTimezone
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
serverTimezone &&
|
||||
serverTimezone !== playlist.serverTimezone
|
||||
) {
|
||||
await rememberServerTimezone(
|
||||
playlist.id,
|
||||
serverTimezone
|
||||
);
|
||||
}
|
||||
return portalStatus;
|
||||
} catch (error) {
|
||||
logger.error('Error checking portal status', error);
|
||||
patchState(store, { portalStatus: 'unavailable' });
|
||||
if (store.currentPlaylist()?.id === playlist.id) {
|
||||
patchState(store, { portalStatus: 'unavailable' });
|
||||
}
|
||||
return 'unavailable';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -206,6 +206,25 @@ describe('xtream-server-timezone.util', () => {
|
||||
).toBe(Date.UTC(2026, 2, 29, 1, 30, 0) / 1000);
|
||||
});
|
||||
|
||||
it('rejects out-of-range fields instead of letting Date.UTC roll them over', () => {
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-13-01 25:00:00', 'UTC')
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-02-30 10:00:00', 'UTC')
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-09-06 23:60:00', 'UTC')
|
||||
).toBeNull();
|
||||
expect(
|
||||
deriveXtreamServerUtcOffsetMinutes('2026-09-06 24:00:00', EPOCH)
|
||||
).toBeNull();
|
||||
// A leap day is a real date.
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2028-02-29 00:00:00', 'UTC')
|
||||
).toBe(Date.UTC(2028, 1, 29) / 1000);
|
||||
});
|
||||
|
||||
it('returns null for malformed strings or an unusable timezone', () => {
|
||||
expect(
|
||||
parseXtreamServerLocalDateTime('2026-09-06', 'UTC')
|
||||
|
||||
@@ -110,6 +110,14 @@ export function deriveXtreamServerUtcOffsetMinutes(
|
||||
* The single persistable timezone string for a panel: its IANA name when
|
||||
* the runtime resolves it, else a fixed offset derived from the clock
|
||||
* fields, else `undefined` (nothing trustworthy was reported).
|
||||
*
|
||||
* The fixed offset is a snapshot of the panel's clock and carries no DST
|
||||
* rules: for such a panel, programmes on the far side of a DST switch are
|
||||
* off by an hour until the next account-info check refreshes the offset.
|
||||
* That is the most a panel with an unusable name gives away — Xtream Codes
|
||||
* itself reports PHP timezone identifiers, which are IANA names, so the
|
||||
* snapshot only ever serves non-standard servers, where the alternative is
|
||||
* the viewer's clock, wrong in every season.
|
||||
*/
|
||||
export function resolveXtreamServerTimezone(
|
||||
serverInfo: XtreamServerClockInfo | null | undefined
|
||||
@@ -269,15 +277,40 @@ function parseNaiveUtcMs(value: string | null | undefined): number | null {
|
||||
return null;
|
||||
}
|
||||
const [, year, month, day, hour, minute, second = '0'] = match;
|
||||
const ms = Date.UTC(
|
||||
const fields = [
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second)
|
||||
Number(second),
|
||||
];
|
||||
const ms = Date.UTC(
|
||||
fields[0],
|
||||
fields[1],
|
||||
fields[2],
|
||||
fields[3],
|
||||
fields[4],
|
||||
fields[5]
|
||||
);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
if (!Number.isFinite(ms)) {
|
||||
return null;
|
||||
}
|
||||
// `Date.UTC` rolls out-of-range fields over (`2026-13-01 25:00` becomes
|
||||
// a real instant in 2027); only a string that reads back unchanged is a
|
||||
// date the panel actually wrote.
|
||||
const date = new Date(ms);
|
||||
const readBack = [
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCDate(),
|
||||
date.getUTCHours(),
|
||||
date.getUTCMinutes(),
|
||||
date.getUTCSeconds(),
|
||||
];
|
||||
return fields.every((field, index) => field === readBack[index])
|
||||
? ms
|
||||
: null;
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
|
||||
Reference in new issue
Block a user