From 2cea665689051963ffb3a339890e134b5bcce8cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 08:36:30 +0000 Subject: [PATCH] fix(playlist): keep IPv6 brackets, honor written default ports, collision-safe dedupe keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4 --- .../provider-import-detection.util.spec.ts | 53 +++++++++++++++++++ .../src/lib/provider-import-detection.util.ts | 31 ++++++++--- .../src/lib/provider-import-scan.util.ts | 40 ++++++++++++-- 3 files changed, 114 insertions(+), 10 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 c30de2cd2..34a40727c 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 @@ -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 активирован' 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 d5086b7c7..be6466c85 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts @@ -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(); 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; } 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 cb2ab67c1..e09943b2f 100644 --- a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts @@ -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; }