mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(stalker): require flag evidence before trusting a row as unflagged
Two Codex findings on #1364. P1 — legacy persisted snapshots. Favorites and Recently Viewed rows saved before this change went through `buildStalkerSelectedVodItem`'s whitelist, which dropped both flags, and `buildStalkerFavoritePayload` spreads that whitelisted object. So a legacy row is flagless because WE stripped it, not because the portal said no — and the helper was reading it as "explicitly unflagged". With an absolute HTTP `cmd` from a load-balanced portal that meant playing a non-final URL. There is no migration or provenance marker for those rows. A stock portal returns both flags on every row, so their PRESENCE is itself the provenance signal, and it is the only one available without a refetch. `resolveStalkerStaticPlaybackUrl` now requires at least one flag key to be present; absence reads as "no evidence" and routes back to `create_link`, which is the pre-PR behaviour. This costs the optimization on panels that omit the flags entirely — the honest price for not being able to tell them apart from our own stripped rows. Radio is the one documented exception. It has always played a directly usable command without `create_link`, so a flagless radio row keeps that rather than newly minting — a portal whose radio `create_link` never worked would otherwise lose playback it has today. ITV and VOD have no such history and stay conservative. P2 — loopback range. IPv4 reserves all of `127.0.0.0/8`, so `127.0.0.2` was being handed to the player as a real address. Classified by range now, with a test that `127.0.0.1.cdn.example` is still treated as the ordinary hostname it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
01d9785c3e
commit
c0dab37e0b
5 files changed
+108
-19
No files matched your search
@@ -329,10 +329,11 @@ path, so they cannot regress a portal that works today:
|
||||
| Input | Verdict | Why |
|
||||
| --- | --- | --- |
|
||||
| Either flag truthy (`1`, `'1'`, `true`) | `create_link` | The portal asked for a temporary link. |
|
||||
| No row supplied at all | `create_link` | A caller that cannot show the flags gets no verdict. This is NOT the same as a row that carries none — a portal too old to send them is genuinely announcing "no temporary link". |
|
||||
| No row supplied at all | `create_link` | A caller that cannot show the flags gets no verdict. |
|
||||
| A row carrying neither flag KEY | `create_link` | Absence means "no evidence", not "no". A stock portal returns both flags on every row, so their PRESENCE is the provenance signal — and the only one available, because rows persisted into Favorites/Recently Viewed before this change were stripped of them by `buildStalkerSelectedVodItem`'s whitelist, making a legacy snapshot indistinguishable from a genuinely unflagged row. There is no migration for those. **Radio is the documented exception**: a directly playable radio command has always played as-is, so a flagless radio row keeps that rather than newly minting. |
|
||||
| Relative `/media/file_12.mpg` or query-only `?token=…` | `create_link` | Only the portal turns those into an address; the VOD `has_files` rewrite produces exactly the first shape. |
|
||||
| Non-HTTP scheme (`ffrt4://ch/live/…`) | `create_link` | Portal-internal pseudo-URL. |
|
||||
| Loopback host (`localhost`, `127.0.0.1`, `0.0.0.0`, `::1`) | `create_link` | `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, not an address a set-top box could open. |
|
||||
| Loopback host (`localhost`, all of `127.0.0.0/8`, `0.0.0.0`, `::1`) | `create_link` | `ffrt3 http://localhost/ch/1234_` is an instruction to the portal, not an address a set-top box could open. |
|
||||
| Otherwise | static `cmd` | Solution prefix stripped by `normalizeStalkerPlaybackCommand()`. |
|
||||
|
||||
`fetchStalkerPlaybackLink()` applies the verdict for ITV, VOD and radio, and
|
||||
|
||||
@@ -362,7 +362,13 @@ export class StreamResolverService {
|
||||
// played as-is, so it keeps behaving like a row without flags.
|
||||
const linkFlags =
|
||||
(item.stalkerItem as StalkerLinkFlagSource | undefined) ??
|
||||
(item.radio === 'true' ? {} : undefined);
|
||||
// Explicit zeros rather than `{}`: the helper now requires the
|
||||
// flag keys to be PRESENT before it will trust a row, and a radio
|
||||
// item without a snapshot has to keep the behaviour this route has
|
||||
// always had — a directly usable command plays as-is.
|
||||
(item.radio === 'true'
|
||||
? { use_http_tmp_link: '0', use_load_balancing: '0' }
|
||||
: undefined);
|
||||
const staticUrl = resolveStalkerStaticPlaybackUrl(
|
||||
linkFlags,
|
||||
item.stalkerCmd ?? ''
|
||||
|
||||
+15
-3
@@ -32,6 +32,7 @@ import {
|
||||
fetchStalkerExpireDate,
|
||||
fetchStalkerMovieFileId,
|
||||
fetchStalkerPlaybackLink,
|
||||
hasStalkerLinkFlagEvidence,
|
||||
shouldResolveMovieFileId,
|
||||
type StalkerLinkFlagSource,
|
||||
} from '../utils';
|
||||
@@ -321,10 +322,21 @@ export function withStalkerPlayer() {
|
||||
throw new Error('nothing_to_play');
|
||||
}
|
||||
|
||||
// Radio used to skip `create_link` for any directly
|
||||
// Radio has always skipped `create_link` for a directly
|
||||
// playable command. Handing the row to the shared decision
|
||||
// adds the flags, so a station the portal proxies now gets
|
||||
// its temporary link instead of a dead static URL.
|
||||
// its temporary link instead of a dead static URL — but a
|
||||
// row carrying no flags at all must keep the old rule
|
||||
// rather than start minting, or a portal whose radio
|
||||
// `create_link` never worked would lose playback it has.
|
||||
// ITV and VOD have no such history and stay conservative.
|
||||
const radioLinkFlags = hasStalkerLinkFlagEvidence(item)
|
||||
? item
|
||||
: {
|
||||
...item,
|
||||
use_http_tmp_link: '0',
|
||||
use_load_balancing: '0',
|
||||
};
|
||||
const streamUrl = await fetchStalkerPlaybackLink(
|
||||
requestDeps,
|
||||
{
|
||||
@@ -333,7 +345,7 @@ export function withStalkerPlayer() {
|
||||
storeState.selectedContentType(),
|
||||
cmd: item.cmd,
|
||||
forcedContentType: 'radio',
|
||||
linkFlags: item,
|
||||
linkFlags: radioLinkFlags,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+47
-6
@@ -61,7 +61,7 @@ describe('stalker-link-semantics', () => {
|
||||
it('accepts a bare URL with no solution prefix', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{},
|
||||
{ use_http_tmp_link: '0' },
|
||||
'https://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('https://cdn.example/live/42.m3u8');
|
||||
@@ -88,7 +88,9 @@ describe('stalker-link-semantics', () => {
|
||||
[''],
|
||||
[' '],
|
||||
])('defers to create_link for the unresolvable command %p', (cmd) => {
|
||||
expect(resolveStalkerStaticPlaybackUrl({}, cmd)).toBeNull();
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -97,12 +99,12 @@ describe('stalker-link-semantics', () => {
|
||||
['http://0.0.0.0/ch/1234_'],
|
||||
['http://[::1]/ch/1234_'],
|
||||
])('defers to create_link for the portal-local address %p', (cmd) => {
|
||||
expect(resolveStalkerStaticPlaybackUrl({}, cmd)).toBeNull();
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('gives no verdict when the caller has no row to read flags from', () => {
|
||||
// Distinct from a row that simply carries no flags: the caller
|
||||
// cannot vouch for the row, so the portal decides.
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
undefined,
|
||||
@@ -117,10 +119,49 @@ describe('stalker-link-semantics', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('gives no verdict for a row carrying neither flag key', () => {
|
||||
// A Favorite persisted before these flags were carried was
|
||||
// stripped of them by `buildStalkerSelectedVodItem`'s whitelist,
|
||||
// so it is indistinguishable from a row the portal marked
|
||||
// unflagged. There is no migration for those rows, so absence has
|
||||
// to read as "no evidence" rather than "no temporary link".
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ id: '42', cmd: 'x' } as never,
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: undefined, use_load_balancing: null },
|
||||
'ffrt3 http://cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['http://127.0.0.2/ch/1234_'],
|
||||
['http://127.1.2.3:8080/ch/1234_'],
|
||||
['http://127.255.255.255/ch/1234_'],
|
||||
])('treats the whole 127.0.0.0/8 range as portal-local (%p)', (cmd) => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl({ use_http_tmp_link: '0' }, cmd)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mistake a non-loopback 127-lookalike for loopback', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://127.0.0.1.cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://127.0.0.1.cdn.example/live/42.m3u8');
|
||||
});
|
||||
|
||||
it('keeps a non-loopback host that merely looks local', () => {
|
||||
expect(
|
||||
resolveStalkerStaticPlaybackUrl(
|
||||
{},
|
||||
{ use_http_tmp_link: '0' },
|
||||
'http://localhost.cdn.example/live/42.m3u8'
|
||||
)
|
||||
).toBe('http://localhost.cdn.example/live/42.m3u8');
|
||||
|
||||
+36
-7
@@ -25,12 +25,38 @@ export interface StalkerLinkFlagSource {
|
||||
*/
|
||||
const PORTAL_LOCAL_HOSTNAMES = new Set([
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'0.0.0.0',
|
||||
'::1',
|
||||
'[::1]',
|
||||
]);
|
||||
|
||||
/** IPv4 reserves all of `127.0.0.0/8` for loopback, not just `127.0.0.1`. */
|
||||
const IPV4_LOOPBACK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
||||
|
||||
function isPortalLocalHostname(hostname: string): boolean {
|
||||
return PORTAL_LOCAL_HOSTNAMES.has(hostname) || IPV4_LOOPBACK.test(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the row can speak for itself about temporary links.
|
||||
*
|
||||
* A stock portal returns both flags on every ITV/VOD row, so their PRESENCE
|
||||
* is the provenance signal — and it is the only one available, because rows
|
||||
* persisted into Favorites/Recently Viewed before these flags were carried
|
||||
* were stripped of them by `buildStalkerSelectedVodItem`'s whitelist. Without
|
||||
* this check a legacy snapshot would be indistinguishable from a row the
|
||||
* portal genuinely marked unflagged, and would take the static path on a
|
||||
* command that may still need resolving. There is no migration or provenance
|
||||
* marker for those rows, so absence has to mean "no evidence", not "no".
|
||||
*/
|
||||
export function hasStalkerLinkFlagEvidence(
|
||||
source: StalkerLinkFlagSource | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
source?.use_http_tmp_link != null || source?.use_load_balancing != null
|
||||
);
|
||||
}
|
||||
|
||||
/** Truthiness for portal flags, which arrive as strings, numbers or booleans. */
|
||||
export function isStalkerPortalFlagEnabled(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -74,9 +100,10 @@ export function requiresStalkerTemporaryLink(
|
||||
* these guards only ever push a row back onto today's `create_link` path and
|
||||
* so cannot regress a portal that works now:
|
||||
*
|
||||
* - no row at all — a caller that cannot show the flags gets no verdict, which
|
||||
* is not the same as a row that carries none (a portal too old to send them
|
||||
* is genuinely announcing "no temporary link");
|
||||
* - no row at all, or a row carrying neither flag key — no evidence, so no
|
||||
* verdict (see {@link hasStalkerLinkFlagEvidence}: a row persisted before
|
||||
* these flags were carried looks identical to one the portal marked
|
||||
* unflagged);
|
||||
* - either flag set — the portal asked for a temporary link;
|
||||
* - a relative (`/media/file_12.mpg`) or query-only (`?token=…`) command —
|
||||
* only `create_link` turns those into an address, and the VOD `has_files`
|
||||
@@ -89,7 +116,10 @@ export function resolveStalkerStaticPlaybackUrl(
|
||||
source: StalkerLinkFlagSource | null | undefined,
|
||||
cmd: string
|
||||
): string | null {
|
||||
if (!source || requiresStalkerTemporaryLink(source)) {
|
||||
if (
|
||||
!hasStalkerLinkFlagEvidence(source) ||
|
||||
requiresStalkerTemporaryLink(source)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -99,8 +129,7 @@ export function resolveStalkerStaticPlaybackUrl(
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
if (PORTAL_LOCAL_HOSTNAMES.has(hostname)) {
|
||||
if (isPortalLocalHostname(new URL(url).hostname.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
|
||||
Reference in new issue
Block a user