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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4
This commit is contained in:
Claude committed 2026-08-15 22:50:07 +00:00
1 parent f3723eb8e3
commit 2ef6e51067
3 files changed
+83 -25

No files matched your search

@@ -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 },
@@ -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}`;
}
/**
@@ -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 <hex>" (or "DEVICE ID 1&2: <hex>") 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;
}