mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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:
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user