From 2ef6e51067960c09f5b9415030e82dcc3c06cc0b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 22:50:07 +0000 Subject: [PATCH] fix(playlist): port-aware portal ambiguity keys, mask URLs beyond the extraction cap - The portal installation key is derived after labeled-port completion, so 'http://panel:8080/c/' next to 'http://panel/c/' plus a 'Port: 8080' line reads as one panel instead of stripping the portal from its MAC list. - Label matchers mask EVERY URL-shaped span, not just the capped extraction result: a URL past the candidate cap still carries a label-shaped query, and its credentials must not attach to an earlier credential-less panel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4 --- .../provider-import-detection.util.spec.ts | 45 +++++++++++++++++++ .../src/lib/provider-import-detection.util.ts | 29 +++++++++--- .../src/lib/provider-import-scan.util.ts | 34 +++++++------- 3 files changed, 83 insertions(+), 25 deletions(-) diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts index bd18485ad..4d3650f14 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.spec.ts @@ -774,6 +774,28 @@ describe('detectProviderImportCandidates', () => { } }); + it('applies the labeled port before judging portal ambiguity', () => { + // Both URLs are the same panel once "Port: 8080" completes the + // port-less one — the MAC list must keep the shared portal. + const candidates = detectProviderImportCandidates( + [ + 'http://panel.example.com:8080/c/', + 'http://panel.example.com/c/', + 'Port: 8080', + 'MAC: 00:1A:79:AA:AA:AA', + 'MAC: 00:1A:79:BB:BB:BB', + ].join('\n') + ); + + const stalker = only(candidates, 'stalker'); + expect(stalker).toHaveLength(2); + for (const candidate of stalker) { + expect(candidate.portalUrl).toBe( + 'http://panel.example.com:8080/c/' + ); + } + }); + it('keeps alternate endpoints of one install unambiguous for a MAC list', () => { // /c/ and portal.php are two doors into the same installation — // a MAC list next to both must still get the shared portal. @@ -932,6 +954,29 @@ describe('detectProviderImportCandidates', () => { expect(only(candidates, 'm3u-url')).toHaveLength(1); }); + it('does not read credentials out of URLs beyond the extraction cap', () => { + // URL #17 is past the 16-URL cap and never becomes a candidate; + // its query credentials must not leak into the label matchers and + // attach to the early credential-less panel. + const filler = Array.from( + { length: 15 }, + (_, index) => `https://lists.example.com/list-${index}.m3u` + ); + const candidates = detectProviderImportCandidates( + [ + 'http://early.example.org/player_api.php', + ...filler, + 'http://late.example.org/x?username=wrong&password=wrongpass', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0].serverUrl).toBe('http://early.example.org'); + expect(xtream[0].username).toBeUndefined(); + expect(xtream[0].password).toBeUndefined(); + }); + it('surfaces every link of a long list and caps only pathological pastes', () => { const twelve = Array.from( { length: 12 }, diff --git a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts index 12c650e7c..0a31369d3 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts @@ -104,7 +104,7 @@ export function detectProviderImportCandidates( // every emitted value, pass through unchanged. const normalized = foldDecorativeAlphabets(raw.normalize('NFKC')); const urls = extractUrls(normalized); - const labeled = extractLabeledFields(normalized, urls); + const labeled = extractLabeledFields(normalized); const macs = extractMacAddresses(normalized, labeled.macAddress); const candidates: ProviderImportCandidate[] = []; @@ -137,7 +137,9 @@ export function detectProviderImportCandidates( : urls.filter((url) => url.role === 'generic'); const portalAmbiguous = macs.length > 1 && - new Set(portalPool.map(portalInstallationKey)).size > 1; + new Set( + portalPool.map((url) => portalInstallationKey(url, labeled.port)) + ).size > 1; const portal = portalAmbiguous ? null : pickStalkerPortalUrl(urls, labeled); @@ -282,16 +284,31 @@ export function detectProviderImportCandidates( * of scanner dumps compare equal. The base path separates tenant installs * sharing one origin (`/a/stalker_portal/c/` vs `/b/…`), while the known * endpoint suffixes of ONE install (`/c/`, `portal.php`, `server/load.php`) - * are stripped so its alternate endpoints reduce to the same key. + * are stripped so its alternate endpoints reduce to the same key. The key is + * derived AFTER labeled-port completion, so `http://panel:8080/c/` next to + * `http://panel/c/` plus a `Port: 8080` line reads as one panel, exactly as + * the picker would complete it. */ -function portalInstallationKey(url: DetectedUrl): string { - const base = url.parsed.pathname +function portalInstallationKey( + url: DetectedUrl, + labeledPort: string | undefined +): string { + let parsed = url.parsed; + const completed = completeWithLabeledPort(url, labeledPort); + if (completed !== url.raw) { + try { + parsed = new URL(completed); + } catch { + parsed = url.parsed; + } + } + const base = parsed.pathname .replace(/\/(?:stalker_portal\/)?c\/?$/i, '') .replace(/\/stalker_portal\/?$/i, '') .replace(/\/portal\.php$/i, '') .replace(/\/(?:stalker_portal\/)?server\/load\.php$/i, '') .replace(/\/+$/, ''); - return `${url.parsed.origin}${base}`; + return `${parsed.origin}${base}`; } /** diff --git a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts index d225fc088..9e117b79a 100644 --- a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts @@ -338,10 +338,7 @@ const DUAL_DEVICE_ID_PATTERN = new RegExp( 'iu' ); -export function extractLabeledFields( - text: string, - urls: DetectedUrl[] -): LabeledFields { +export function extractLabeledFields(text: string): LabeledFields { // Query strings ARE label-shaped (`?username=u&password=p`), so labeled // extraction must never look inside a URL: credentials that ride in a // query are mined by `extractXtreamCredentialsFromUrl` with proper URL @@ -351,7 +348,7 @@ export function extractLabeledFields( // ("Portal: http://…"), so it reads the raw text, accepting only matches // whose label starts outside every URL span (`&url=…` inside a query // stays rejected). - const spans = urlSpans(text, urls); + const spans = urlSpans(text); const masked = maskSpans(text, spans); const fields: LabeledFields = {}; // "DEVICE ID=> 1&2 " (or "DEVICE ID 1&2: ") hands one value to @@ -441,21 +438,20 @@ export function labeledHostUrl(labeled: LabeledFields): string | undefined { return `http://${host}${portSuffix}`; } -function urlSpans( - text: string, - urls: DetectedUrl[] -): Array<[number, number]> { +/** + * Spans of EVERY URL-shaped run in the text — deliberately not derived from + * the capped `extractUrls` result. A URL past that cap still carries a query + * that is label-shaped (`?username=…&password=…`), and an unmasked one would + * feed the label matchers credentials belonging to a source that never became + * a candidate. Spans are cheap index pairs, so the cap that bounds candidate + * assembly does not apply here. + */ +function urlSpans(text: string): Array<[number, number]> { const spans: Array<[number, number]> = []; - for (const url of urls) { - let from = 0; - for (;;) { - const index = text.indexOf(url.raw, from); - if (index === -1) { - break; - } - spans.push([index, index + url.raw.length]); - from = index + url.raw.length; - } + const matcher = freshMatcher(URL_PATTERN); + let match: RegExpExecArray | null; + while ((match = matcher.exec(text)) !== null) { + spans.push([match.index, match.index + match[0].length]); } return spans; }