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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4
This commit is contained in:
Claude committed 2026-08-16 07:44:57 +00:00
1 parent 34a5aa01d5
commit 36f340c907
3 files changed
+54 -11

No files matched your search

@@ -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.
@@ -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;
@@ -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] === '') {