From 36f340c907fe586ec7beb699f93a933de92d8067 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 07:44:57 +0000 Subject: [PATCH] fix(playlist): stop URL spans at inline delimiters, IPv6-safe port check, linear span scan - '|' ends a URL span: it is the inline field delimiter of one-line handouts ('Server: http://host|User: alice'), and letting the span run through it masked the next label and dropped the account entirely. - The explicit-port check reads parsed.port instead of matching colons in the authority, so an IPv6 endpoint no longer mistakes its address for a port and now receives the separately labeled one. - The out-of-span label search walks the ordered spans with a forward cursor instead of rescanning them per match; detection runs on every keystroke and the rescan was quadratic on pastes full of host-shaped query keys. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4 --- .../provider-import-detection.util.spec.ts | 32 +++++++++++++++++++ .../src/lib/provider-import-detection.util.ts | 4 ++- .../src/lib/provider-import-scan.util.ts | 29 +++++++++++------ 3 files changed, 54 insertions(+), 11 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 e02278eeb..c30de2cd2 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 @@ -315,6 +315,38 @@ describe('detectProviderImportCandidates', () => { expect(xtream[0].serverUrl).toBe('http://panel.example.io:8080'); }); + it('reads an inline one-line handout with pipe delimiters', () => { + // No space after the pipe: the URL must not swallow the next + // label, or the account silently produces no candidate at all. + const candidates = detectProviderImportCandidates( + 'Server: http://panel.example.org|User: alice|Pass: s3cret' + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0]).toMatchObject({ + serverUrl: 'http://panel.example.org', + username: 'alice', + password: 's3cret', + }); + }); + + it('applies a labeled port to an IPv6 endpoint', () => { + // The address is full of colons; none of them is a port. + const candidates = detectProviderImportCandidates( + [ + 'Server: http://[2001:db8::1]/player_api.php', + 'Port: 8080', + 'User: alice', + 'Pass: s3cret', + ].join('\n') + ); + + const xtream = only(candidates, 'xtream'); + expect(xtream).toHaveLength(1); + expect(xtream[0].serverUrl).toBe('http://[2001:db8::1]:8080'); + }); + it('ignores host-shaped query keys inside unrelated links', () => { // `?url=guide` is a query key, not a label — the real "Server:" // line below it must still be the one the credentials attach to. 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 0a31369d3..d5086b7c7 100644 --- a/libs/shared/interfaces/src/lib/provider-import-detection.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-detection.util.ts @@ -321,7 +321,9 @@ function completeWithLabeledPort( url: DetectedUrl, port: string | undefined ): string { - if (!port || /^https?:\/\/[^/]*:\d+/i.test(url.raw)) { + // `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 !== '') { return url.raw; } const { protocol, hostname, pathname, search } = url.parsed; 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 2f7938cff..cb2ab67c1 100644 --- a/libs/shared/interfaces/src/lib/provider-import-scan.util.ts +++ b/libs/shared/interfaces/src/lib/provider-import-scan.util.ts @@ -31,7 +31,11 @@ export interface LabeledFields { signature2?: string; } -const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/gi; +// `|` ends a URL: it is not a legal URL character, but it IS the inline field +// delimiter of one-line handouts ("Server: http://host|User: alice"). Letting +// the span run through it would mask the very next label and lose that +// account entirely. +const URL_PATTERN = /https?:\/\/[^\s<>"'`|]+/gi; const TRAILING_PUNCTUATION = /[),.;:!?\]»›]+$/; const M3U_PATH_PATTERN = /\.m3u8?$/i; const XTREAM_API_PATH_PATTERN = /\/(?:get|player_api|panel_api)\.php$/i; @@ -478,14 +482,14 @@ function maskSpans(text: string, spans: Array<[number, number]>): string { return chars.join(''); } -function isInsideSpan( - spans: Array<[number, number]>, - index: number -): boolean { - return spans.some(([start, end]) => index > start && index < end); -} - -/** First match of `pattern` whose label does not start inside a URL span. */ +/** + * First match of `pattern` whose label does not start inside a URL span. + * + * Both sequences are produced left to right and the spans never overlap, so + * a single forward cursor is enough — rescanning the span list per match + * would be quadratic on a paste full of host-shaped query keys, and this + * runs synchronously on every keystroke. + */ function firstMatchOutsideSpans( pattern: RegExp, text: string, @@ -496,9 +500,14 @@ function firstMatchOutsideSpans( ? pattern : new RegExp(pattern.source, `${pattern.flags}g`) ); + let cursor = 0; let match: RegExpExecArray | null; while ((match = matcher.exec(text)) !== null) { - if (!isInsideSpan(spans, match.index)) { + while (cursor < spans.length && spans[cursor][1] <= match.index) { + cursor += 1; + } + const span = spans[cursor]; + if (!span || !(match.index > span[0] && match.index < span[1])) { return match; } if (match[0] === '') {