mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
fix(playlist): keep IPv6 brackets, honor written default ports, collision-safe dedupe keys
- Cleanup of prose punctuation and bracket quotes now loses to parseability: ']' closes both a quote and an IPv6 authority, so a strip that breaks the address is rolled back and 'Portal: http://[2001:db8::1]' stays importable. - The explicit-port check reads the raw authority instead of parsed.port, which the WHATWG parser blanks for a protocol default — a written ':80' is the user's choice and a labeled port no longer overwrites it. - Candidate dedupe keys are JSON-serialized, so two accounts whose credentials contain the former separator no longer collapse into one. 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
+114
-10
No files matched your search
@@ -331,6 +331,45 @@ describe('detectProviderImportCandidates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an explicitly written default port over a labeled one', () => {
|
||||
// The WHATWG parser blanks `:80`, but the user wrote it.
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'Server: http://primary.example.org:80/player_api.php',
|
||||
'Port: 8080',
|
||||
'User: alice',
|
||||
'Pass: s3cret',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
// `:80` is http's default, so the shared normalizer collapses it
|
||||
// away — what matters is that the labeled 8080 never replaced it.
|
||||
expect(only(candidates, 'xtream')[0].serverUrl).toBe(
|
||||
'http://primary.example.org'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps distinct accounts whose credentials contain the key separator', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
[
|
||||
'http://panel.example.org/get.php?username=a%7Cb&password=c',
|
||||
'http://panel.example.org/get.php?username=a&password=b%7Cc',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const xtream = only(candidates, 'xtream');
|
||||
expect(xtream).toHaveLength(2);
|
||||
expect(
|
||||
xtream.map((candidate) => [
|
||||
candidate.username,
|
||||
candidate.password,
|
||||
])
|
||||
).toEqual([
|
||||
['a|b', 'c'],
|
||||
['a', 'b|c'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies a labeled port to an IPv6 endpoint', () => {
|
||||
// The address is full of colons; none of them is a port.
|
||||
const candidates = detectProviderImportCandidates(
|
||||
@@ -480,6 +519,20 @@ describe('detectProviderImportCandidates', () => {
|
||||
expect(stalker[0].macAddress).toBe('00:1A:79:AA:BB:CC');
|
||||
});
|
||||
|
||||
it('keeps the closing bracket of a root IPv6 portal URL', () => {
|
||||
// `]` closes the authority here; stripping it as prose would
|
||||
// leave an address that cannot be parsed or imported.
|
||||
const candidates = detectProviderImportCandidates(
|
||||
['Portal: http://[2001:db8::1]', 'MAC: 00:1A:79:12:34:56'].join(
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
|
||||
const stalker = only(candidates, 'stalker');
|
||||
expect(stalker).toHaveLength(1);
|
||||
expect(stalker[0].portalUrl).toBe('http://[2001:db8::1]');
|
||||
});
|
||||
|
||||
it('proposes a low-confidence stalker candidate for a lone MAC', () => {
|
||||
const candidates = detectProviderImportCandidates(
|
||||
'Ваш MAC 00:1A:79:00:11:22 активирован'
|
||||
|
||||
@@ -317,13 +317,27 @@ function portalInstallationKey(
|
||||
* Applied only when the URL itself states no explicit port, so a written
|
||||
* `:80` is never overridden by the label.
|
||||
*/
|
||||
/**
|
||||
* Whether the URL states a port itself. Read off the RAW authority rather
|
||||
* than `parsed.port`, which the WHATWG parser blanks for a protocol's default
|
||||
* (`http://host:80` reports no port) — a written `:80` is still the user's
|
||||
* explicit choice and must not be overwritten by a labeled one. An IPv6
|
||||
* literal's own colons are skipped by cutting the bracketed address first.
|
||||
*/
|
||||
function hasExplicitPort(raw: string): boolean {
|
||||
const afterScheme = raw.slice(raw.indexOf('://') + 3);
|
||||
const authority = afterScheme.split(/[/?#]/, 1)[0];
|
||||
const afterAddress = authority.startsWith('[')
|
||||
? authority.slice(authority.indexOf(']') + 1)
|
||||
: authority;
|
||||
return /:\d+$/.test(afterAddress);
|
||||
}
|
||||
|
||||
function completeWithLabeledPort(
|
||||
url: DetectedUrl,
|
||||
port: string | undefined
|
||||
): string {
|
||||
// `parsed.port` rather than a colon in the authority: an IPv6 literal
|
||||
// (`http://[2001:db8::1]/…`) is full of colons that are address, not port.
|
||||
if (!port || url.parsed.port !== '') {
|
||||
if (!port || hasExplicitPort(url.raw)) {
|
||||
return url.raw;
|
||||
}
|
||||
const { protocol, hostname, pathname, search } = url.parsed;
|
||||
@@ -416,17 +430,20 @@ function dedupe(
|
||||
): ProviderImportCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
return candidates.filter((candidate) => {
|
||||
const key = [
|
||||
// JSON rather than a joined string: a credential may legitimately
|
||||
// contain the separator (URL-encoded in the query), and two different
|
||||
// accounts must never collapse into one key because of it. Same
|
||||
// server + username with a DIFFERENT password is a distinct account
|
||||
// (e.g. a rotation message), so the password is part of the key.
|
||||
const key = JSON.stringify([
|
||||
candidate.kind,
|
||||
candidate.url ?? '',
|
||||
candidate.serverUrl ?? '',
|
||||
candidate.username ?? '',
|
||||
// Same server + username with a DIFFERENT password is a distinct
|
||||
// account (e.g. a rotation message) — never collapse it away.
|
||||
candidate.password ?? '',
|
||||
candidate.portalUrl ?? '',
|
||||
candidate.macAddress ?? '',
|
||||
].join('|');
|
||||
]);
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,31 @@ export interface LabeledFields {
|
||||
// account entirely.
|
||||
const URL_PATTERN = /https?:\/\/[^\s<>"'`|]+/gi;
|
||||
const TRAILING_PUNCTUATION = /[),.;:!?\]»›]+$/;
|
||||
|
||||
/**
|
||||
* Strips sentence punctuation a URL picked up from prose ("see http://x/y.")
|
||||
* — unless doing so breaks the address itself. `]` is both a closing quote in
|
||||
* prose and the structural end of an IPv6 authority (`http://[2001:db8::1]`),
|
||||
* and only parsing can tell the two apart: the stripped form wins whenever it
|
||||
* still parses, otherwise the original stands. Values with no scheme parse
|
||||
* either way, so they keep the plain stripped form.
|
||||
*/
|
||||
function stripTrailingProsePunctuation(value: string): string {
|
||||
const stripped = value.replace(TRAILING_PUNCTUATION, '');
|
||||
if (stripped === value || parsesAsUrl(stripped) || !parsesAsUrl(value)) {
|
||||
return stripped;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsesAsUrl(value: string): boolean {
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const M3U_PATH_PATTERN = /\.m3u8?$/i;
|
||||
const XTREAM_API_PATH_PATTERN = /\/(?:get|player_api|panel_api)\.php$/i;
|
||||
const STALKER_PATH_PATTERN = /\/(?:portal\.php|c\/?)$/i;
|
||||
@@ -289,7 +314,7 @@ export function extractUrls(text: string): DetectedUrl[] {
|
||||
detected.length < MAX_URLS &&
|
||||
(match = matcher.exec(text)) !== null
|
||||
) {
|
||||
const raw = match[0].replace(TRAILING_PUNCTUATION, '');
|
||||
const raw = stripTrailingProsePunctuation(match[0]);
|
||||
if (seen.has(raw)) {
|
||||
continue;
|
||||
}
|
||||
@@ -423,7 +448,9 @@ export function labeledHostUrl(labeled: LabeledFields): string | undefined {
|
||||
// value takes precedence over the scanned one, so leaving `Server:
|
||||
// http://host!` uncleaned here would hand the forms a hostname DNS can
|
||||
// never resolve while the sanitized scanned URL sat right beside it.
|
||||
const host = labeled.host?.replace(TRAILING_PUNCTUATION, '');
|
||||
const host = labeled.host
|
||||
? stripTrailingProsePunctuation(labeled.host)
|
||||
: undefined;
|
||||
if (!host) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -518,5 +545,12 @@ function firstMatchOutsideSpans(
|
||||
}
|
||||
|
||||
function stripWrapping(value: string): string {
|
||||
return value.trim().replace(WRAPPING_CHARS, '').trim();
|
||||
const trimmed = value.trim();
|
||||
const stripped = trimmed.replace(WRAPPING_CHARS, '').trim();
|
||||
// Same hazard as the prose-punctuation cleanup: `]` closes both a bracket
|
||||
// quote and an IPv6 authority, so a strip that breaks the address loses.
|
||||
if (stripped === trimmed || parsesAsUrl(stripped) || !parsesAsUrl(trimmed)) {
|
||||
return stripped;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
Reference in new issue
Block a user