diff --git a/README-fa.md b/README-fa.md
index fe8ff72..1de512c 100644
--- a/README-fa.md
+++ b/README-fa.md
@@ -14,7 +14,7 @@
### 🛡️ ECH
- - جایگذاری و ارتقا: لینک تکی یا چندگانه (تکبهتک در هر خط) از
vless:// یا trojan:// را جایگذاری کنید و لینکهای ارتقایافته با پارامترهای fp و ech دریافت کنید.
+ - جایگذاری و ارتقا: لینک تکی یا چندگانه (تکبهتک در هر خط) از
vless:// یا trojan:// را جایگذاری کنید و لینکهای ارتقایافته با پارامترهای fp و ech دریافت کنید. اگر یک اشتراک رمزگذاریشده (یک بلوک base64، بلوک چندخطی یا base64 خطبهخط) را اینجا جایگذاری کنید، به همه کانفیگهای VLESS/Trojan داخل آن باز میشود و بهجای یک لینک ادغامشده، همه را ارتقا میدهد.
- ورودی اشتراک: لینک اشتراک را برای دانلود در مرورگر جایگذاری کنید — یا محتوای آن را (متن ساده یا base64) جایگذاری کنید — تا همه کانفیگهای VLESS و Trojan داخل آن با همان گزینههای ECH ارتقا یابند. تغییر سرور در اینجا نادیده گرفته میشود (هر کانفیگ آدرس خودش را نگه میدارد)؛ با تغییر گزینههای ECH، کانفیگهای گرفتهشده بدون دانلود مجدد ارتقا مییابند.
- تغییر سرور: فیلد سرور (IP/دامنه) فقط برای کانفیگ تکی بهصورت خودکار از URL استخراج میشود و قابل ویرایش است. در حالت چند کانفیگ، این فیلد خالی میماند و فقط اگر مقدار دلخواه وارد کنید روی همه کانفیگها اعمال میشود (پشتیبانی از IPv4، IPv6 و دامنه).
- اثر انگشت (Fingerprint): پیشفرض
chrome با گزینههای unsafe، firefox، safari، random و none.
@@ -28,13 +28,13 @@
### 🧬 Fragment + Fingerprint
- - جایگذاری و ارتقا: لینک تکی یا چندگانه (تکبهتک در هر خط) از
vless:// یا trojan:// را جایگذاری کنید و لینکهای ارتقایافته با پارامترهای cs، fm و fp دریافت کنید.
+ - جایگذاری و ارتقا: لینک تکی یا چندگانه (تکبهتک در هر خط) از
vless:// یا trojan:// را جایگذاری کنید و لینکهای ارتقایافته با پارامترهای cs، fm و fp دریافت کنید. اگر یک اشتراک رمزگذاریشده (یک بلوک base64، بلوک چندخطی یا base64 خطبهخط) را اینجا جایگذاری کنید، به همه کانفیگهای VLESS/Trojan داخل آن باز میشود و بهجای یک لینک ادغامشده، همه را ارتقا میدهد.
- پشتیبانی از چندین کانفیگ همزمان: امکان جایگذاری لیست انبوه کانفیگها جهت پردازش، اعتبارسنجی و ارتقای دستهجمعی بدون تداخل در remark کانفیگها.
- تغییر سرور: فیلد سرور (IP/دامنه) فقط برای کانفیگ تکی بهصورت خودکار از URL استخراج میشود و قابل ویرایش است. در حالت چند کانفیگ، این فیلد خالی میماند و فقط اگر مقدار دلخواه وارد کنید روی همه کانفیگها اعمال میشود (پشتیبانی از IPv4، IPv6 و دامنه).
- اثر انگشت (Fingerprint): پیشفرض
unsafe با گزینههای chrome، firefox، safari، random و none.
- نسخههای فرگمنت (Fragment): فیلد
fm دارای دو preset است — v1 کلاسیک (5,94,1 / 109,1 / split برابر 355) و v2 جدید (پیشفرض: 0,104,1 / 114,1 / split برابر 11). با سلکتور Fragment Version جابهجا شوید؛ فیلد همچنان دستی قابل ویرایش است.
- آگاه از TLS: پارامترهای
cs و fm فقط زمانی اضافه میشوند که کانفیگ از امنیت tls استفاده کند. با خالی کردن هر فیلد، آن پارامتر حذف میشود.
- - سابتب اشتراک: لینک اشتراک را مستقیم در مرورگر دریافت کنید، رمزگشایی (متن ساده یا base64) و ارتقای دستهجمعی کانفیگهای VLESS و Trojan داخل آن — یا محتوایش را جایگذاری کنید / فایل
.txt ذخیرهشده را روی کارت بیندازید تا دانلود لازم نباشد. از همان گزینههای fp / cs / fm بالا استفاده میشود؛ تغییر سرور نادیده گرفته میشود (هر کانفیگ آدرس خودش را نگه میدارد).
+ - سابتب اشتراک: لینک اشتراک را مستقیم در مرورگر دریافت کنید، رمزگشایی (متن ساده، یک بلوک base64، یا base64 خطبهخط) و ارتقای دستهجمعی کانفیگهای VLESS و Trojan داخل آن — یا محتوایش را جایگذاری کنید / فایل
.txt ذخیرهشده را روی کارت بیندازید تا دانلود لازم نباشد. از همان گزینههای fp / cs / fm بالا استفاده میشود؛ تغییر سرور نادیده گرفته میشود (هر کانفیگ آدرس خودش را نگه میدارد).
- providerهای بسته: اگر provider هدر CORS نفرستد، خطا ۳ قدم راهنمایی بههمراه دکمه Open link برای باز کردن لینک در تب جدید و کپی نشان میدهد.
- خروجی: کپی در کلیپبورد، دانلود
.txt (هر خط یک کانفیگ) یا base64 .txt (فرمت استاندارد بدنه اشتراک).
- بدون ECH: پارامتر
ech فقط در تب ECH اعمال میشود — به کانفیگهای اشتراک اضافه نمیگردد.
diff --git a/README.md b/README.md
index 0d45482..63cfb7f 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ All processing happens in your browser. No data is sent to any server — fetchi
## 🚀 Features
### 🛡️ ECH
-- **Paste & Enhance**: Paste single or multiple `vless://` or `trojan://` URLs (one per line) and get enhanced links with `fp` and `ech` parameters added.
+- **Paste & Enhance**: Paste single or multiple `vless://` or `trojan://` URLs (one per line) and get enhanced links with `fp` and `ech` parameters added. An encoded subscription pasted here (one whole base64 blob, a line-wrapped blob, or per-line base64) is expanded into every VLESS/Trojan config it holds instead of collapsing into a single link.
- **Subscription Input**: Paste a subscription link to fetch it in your browser — or paste its contents (plain or base64) — to batch-enhance every VLESS/Trojan config inside with the same ECH options. The server override is ignored here (each config keeps its own address); changing the ECH options re-enhances the fetched configs without re-fetching.
- **Server Override**: The server (IP/domain) field is auto-filled from the URL for a single config and is user-editable. With multiple configs the field stays empty and only applies to all configs if you type a custom value (supports IPv4, IPv6 and domains).
- **Fingerprint**: Default `chrome`, with `unsafe`, `firefox`, `safari`, `random` and `none` options.
@@ -25,13 +25,13 @@ All processing happens in your browser. No data is sent to any server — fetchi
- **Protocol Support**: **VLESS** and **Trojan**.
### 🧬 Fragment + Fingerprint
-- **Paste & Enhance**: Paste single or multiple `vless://` or `trojan://` URLs (one per line) and get enhanced links with `cs`, `fm` and `fp` parameters added.
+- **Paste & Enhance**: Paste single or multiple `vless://` or `trojan://` URLs (one per line) and get enhanced links with `cs`, `fm` and `fp` parameters added. An encoded subscription pasted here (one whole base64 blob, a line-wrapped blob, or per-line base64) is expanded into every VLESS/Trojan config it holds instead of collapsing into a single link.
- **Multi-Config Support**: Paste bulk proxy URL lists to parse, validate, and batch enhance all URLs cleanly without remark collision.
- **Server Override**: The server (IP/domain) field is auto-filled from the URL for a single config and is user-editable. With multiple configs the field stays empty and only applies to all configs if you type a custom value (supports IPv4, IPv6 and domains).
- **Fingerprint**: Default `unsafe`, with `chrome`, `firefox`, `safari`, `random` and `none` options.
- **Fragment presets**: `fm` has `v1` (classic: `5,94,1` / `109,1` / split `355`) and `v2` (new, default: `0,104,1` / `114,1` / split `11`). Switch via the `Fragment Version` selector; the field stays editable for manual tweaks.
- **TLS-aware**: `cs` and `fm` are only added when the config uses `tls` security. Clear a field to skip that parameter.
-- **Subscription sub-tab**: Paste a subscription link to fetch it directly in your browser, decode it (plain or base64) and batch-enhance every VLESS/Trojan config inside — or paste its contents / drop a saved `.txt` file to skip fetching. Uses the same `fp` / `cs` / `fm` options above; the server override is ignored (each config keeps its own address).
+- **Subscription sub-tab**: Paste a subscription link to fetch it directly in your browser, decode it (plain text, one base64 blob, or per-line base64) and batch-enhance every VLESS/Trojan config inside — or paste its contents / drop a saved `.txt` file to skip fetching. Uses the same `fp` / `cs` / `fm` options above; the server override is ignored (each config keeps its own address).
- **Blocked Providers**: If the provider sends no CORS headers, the error shows 3 quick steps and an **Open link** button opens the URL in a new tab for copying.
- **Export**: Copy to clipboard, download as `.txt` (one per line) or base64 `.txt` (standard subscription body).
- **No ECH here**: ECH is applied only in the **ECH** tab — subscription entries never get an `ech` parameter.
diff --git a/pure.js b/pure.js
index 7c871f2..c568f44 100644
--- a/pure.js
+++ b/pure.js
@@ -252,13 +252,22 @@ function subIsLink(v) {
return !/\s/.test(v) && /^https?:\/\/\S+$/i.test(v);
}
-// Mirrors extractSubConfigs() instead of guessing from the first line or a
-// length threshold: any line with a known scheme counts, and a short base64
-// blob is accepted if it actually decodes to a config.
+// Mirrors extractSubConfigs(): plain scheme lines count, the whole-body
+// blob counts when its decode holds a config, and per-line base64 blobs
+// count when EVERY line decodes to at least one config. The per-line rule
+// matters for padded chunks — their concatenation fails the whole-body
+// decode (mid-stream '=' padding), so without this branch the subscription
+// tab would reject bodies the bulk box accepts through expandBulkLines().
function subLooksLikeContent(v) {
if (extractLines(v).some(l => SUB_SCHEME.test(l))) return true;
const whole = safeAtob(v.replace(/\s+/g, ''));
- return !!whole && extractLines(whole).some(l => SUB_SCHEME.test(l));
+ if (whole && extractLines(whole).some(l => SUB_SCHEME.test(l))) return true;
+ const lines = extractLines(v);
+ if (lines.length === 0) return false;
+ return lines.every(line => {
+ const decoded = safeAtob(line.replace(/^\/\/.*$/, '').trim());
+ return !!decoded && extractLines(decoded).some(l => SUB_SCHEME.test(l));
+ });
}
// A single http(s) URL with an explicit port but no path, query or hash is
@@ -308,8 +317,8 @@ const SUB_TOTAL_MS = 5 * 60 * 1000;
const SUB_TOO_LARGE_MSG = 'That file is too large — drop a plain-text subscription file (.txt), usually a few hundred KB at most.';
// Returns every proxy line found in a subscription body, whether it arrives
-// as plain text, as one base64 blob, or as a mix of plain lines and
-// per-line base64 blobs.
+// as plain text, as one base64 blob (optionally line-wrapped), or as per-line
+// base64 blobs.
function extractSubConfigs(body) {
let text = (body || '').trim();
if (!text) return [];
@@ -337,24 +346,72 @@ function extractSubConfigs(body) {
return out;
}
- // Base64 body: try the whole payload first (standard subscription format),
- // then line by line for providers that send a base64 blob per config.
+ // Base64 body: try the whole payload first (standard subscription
+ // format, possibly wrapped across lines). This is also what keeps
+ // line-wrapped blobs correct: each wrapped chunk is a slice of the ONE
+ // underlying decoded text, and a slice that begins or ends mid-config
+ // still matches the scheme regex (a cut-off head like "vless://u@" or a
+ // tail like "ss://u@..."), so a per-line-everywhere test cannot
+ // distinguish the two shapes by itself. A genuinely per-line body whose
+ // chunks lack padding is exactly the shape whose whole-body merge
+ // DEGENERATES: the concatenation decodes to a single swallowed line, so
+ // the whole-body list holds at most one scheme line. Per-line therefore
+ // wins only when the whole-body decode fails or is degenerate (≤1
+ // scheme line), and the multi-config whole-body result always takes
+ // precedence.
const whole = safeAtob(text.replace(/\s+/g, ''));
- if (whole) {
- const decodedLines = extractLines(whole);
- if (decodedLines.some(l => SUB_SCHEME.test(l))) {
- return decodedLines;
+ const wholeLines = whole ? extractLines(whole) : [];
+ const wholeConfigs = wholeLines.filter(l => SUB_SCHEME.test(l));
+ if (wholeConfigs.length > 1) {
+ return wholeLines;
+ }
+
+ const perLine = [];
+ let everyLineIsConfig = lines.length > 0;
+ for (const line of lines) {
+ const decoded = safeAtob(line.replace(/^\/\/.*$/, '').trim());
+ if (!decoded) {
+ everyLineIsConfig = false;
+ continue;
}
+ const configs = extractLines(decoded).filter(l => SUB_SCHEME.test(l));
+ if (configs.length === 0) everyLineIsConfig = false;
+ perLine.push(...configs);
+ }
+ if (everyLineIsConfig && perLine.length > 0) return perLine;
+
+ if (whole && wholeLines.some(l => SUB_SCHEME.test(l))) {
+ return wholeLines;
}
- // Last resort: some providers base64 each config on its own line. Only
+ // Last resort: keep whatever per-line configs were decoded above. Only
// decoded lines that actually hold a proxy scheme count — otherwise any
// base64-looking word (an HTML file, a comment) becomes a "config".
- return extractLines(text)
- .map(l => safeAtob(l.replace(/^\/\/.*$/, '').trim()))
- .filter(Boolean)
- .flatMap(extractLines)
- .filter(l => SUB_SCHEME.test(l));
+ return perLine;
+}
+
+// The bulk URL box ("Paste URL(s)") advertises a one-per-line list, but users
+// routinely paste an encoded subscription instead. Expand that here: a whole
+// blob, a line-wrapped blob or per-line blobs all decode into their individual
+// config lines via extractSubConfigs(). Without it the single blob line reaches
+// parseProxyURLSingle(), whose base64 fallback recurses on the decoded body —
+// and new URL() silently strips the line breaks, collapsing every config into
+// the first one's remark ("bulk paste shows one link"). extractSubConfigs
+// keeps raw base64 lines that decode to configs AND appends the decoded
+// copies; the subscription tab's renderer skips such raw lines before
+// parsing, but the bulk box would parse both copies — so drop them here,
+// where every bulk consumer funnels through. Junk that decodes
+// to nothing counts as no config and stays, so genuinely invalid input
+// still surfaces the usual per-line parse error instead of looking empty.
+function expandBulkLines(raw) {
+ const value = (raw || '').trim();
+ if (!value) return [];
+ const expanded = extractSubConfigs(value).filter(line => {
+ if (SUB_SCHEME.test(line)) return true;
+ const decoded = safeAtob(line.replace(/^\/\/.*$/, '').trim());
+ return !(decoded && extractLines(decoded).some(l => SUB_SCHEME.test(l)));
+ });
+ return expanded.length ? expanded : extractLines(value);
}
// Transport failures surface as TypeError in every browser, but the message
@@ -421,6 +478,14 @@ function parseProxyURLSingle(raw) {
try {
const decoded = safeAtob(url);
if (decoded) {
+ // A decoded value carrying more than one config line is a whole
+ // subscription body, not a single link. Recursing would let the
+ // WHATWG URL parser strip the line breaks and merge every config
+ // into the first one's remark — the "bulk paste shows one link"
+ // bug. Bulk callers expand it first (expandBulkLines).
+ if (extractLines(decoded).filter(l => SUB_SCHEME.test(l)).length > 1) {
+ return { error: 'That base64 value holds several configs — paste the list or use the Subscription input' };
+ }
if (decoded.includes('"add"')) {
return parseVmess('vmess://' + url);
}
@@ -1428,6 +1493,6 @@ if (typeof module !== 'undefined' && module.exports) {
maskString, truncateStr,
subIsLink, subLooksLikeContent, subLooksLikeProxy, subValidate,
SUB_IDLE_MS, SUB_TOTAL_MS, SUB_TOO_LARGE_MSG,
- extractSubConfigs, subIsBlockedError, subErrorText
+ extractSubConfigs, expandBulkLines, subIsBlockedError, subErrorText
};
}
diff --git a/script.js b/script.js
index 2cbda9d..6dfaa87 100644
--- a/script.js
+++ b/script.js
@@ -1193,8 +1193,7 @@
outputSection.style.display = 'none';
outputUrl.textContent = '';
outputRemark.textContent = '';
- const val = urlInput.value.trim();
- const lines = extractLines(val);
+ const lines = expandBulkLines(urlInput.value);
let parsedList = [];
let unsupportedCount = 0;
let invalidCount = 0;
@@ -1269,8 +1268,7 @@
}
function onTabUrlEnhance() {
- const val = urlInput.value.trim();
- const lines = extractLines(val);
+ const lines = expandBulkLines(urlInput.value);
if (lines.length === 0) {
outputSection.style.display = 'none';
return;
diff --git a/tests/pure.test.js b/tests/pure.test.js
index 43a11f7..4340b18 100644
--- a/tests/pure.test.js
+++ b/tests/pure.test.js
@@ -11,7 +11,7 @@ const {
enhanceUrlCore, enhanceEchUrlCore,
subIsLink, subLooksLikeContent, subLooksLikeProxy, subValidate,
SUB_IDLE_MS, SUB_TOTAL_MS, SUB_TOO_LARGE_MSG,
- extractSubConfigs, subIsBlockedError, subErrorText,
+ extractSubConfigs, expandBulkLines, subIsBlockedError, subErrorText,
parseProxyURLSingle, parseProxyURL, parseVless, parseVmess,
parseTrojan, parseShadowsocks, parseSocks, parseHttp,
buildStreamSettings, buildProxyOutbound, buildChainOutbound, buildSingboxOutbound,
@@ -241,6 +241,26 @@ describe('parseProxyURLSingle', () => {
it('rejects unknown schemes', () => {
assert.match(parseProxyURLSingle('gopher://x').error, /Unknown protocol/);
});
+
+ it('refuses a base64 body with several configs instead of merging them', () => {
+ // Regression: the base64 fallback recursed on the decoded body, and
+ // new URL() strips line breaks — so a pasted subscription collapsed
+ // into one VLESS link whose remark swallowed the rest.
+ const blob = Buffer.from(VLESS_GOOD + '\n' + TROJAN_GOOD, 'utf8').toString('base64');
+ const r = parseProxyURLSingle(blob);
+ assert.ok(r && r.error, 'expected an error, not a merged config');
+ assert.match(r.error, /several configs/);
+ });
+
+ it('still base64-decodes a single link whose remark spans lines', () => {
+ // A %0A in the remark is not a second config: only lines that start
+ // with a proxy scheme count, so this must keep parsing as one link.
+ const one = 'vless://uuid-1@a.example.com:443?security=tls#Hello%0AWorld';
+ const blob = Buffer.from(one, 'utf8').toString('base64');
+ const r = parseProxyURLSingle(blob);
+ assert.equal(r.protocol, 'vless');
+ assert.equal(r.server, 'a.example.com');
+ });
});
describe('parseProxyURL', () => {
@@ -609,6 +629,63 @@ describe('extractSubConfigs', () => {
assert.deepEqual(extractSubConfigs(b64(lineA) + '\n' + b64(lineB)), [lineA, lineB]);
});
+ it('keeps every config of a per-line base64 body whose chunks lack padding', () => {
+ // Regression: concatenating two unpadded chunks, b64(a) + b64(b), is
+ // itself valid base64 and decodes to the single merged string a + b.
+ // The old whole-body-first order therefore collapsed the whole list
+ // into one config. 3-byte-aligned remarks guarantee no '=' padding.
+ let a = 'vless://a@b:1#x';
+ while (Buffer.byteLength(a, 'utf8') % 3 !== 0) a += 'x';
+ let b = 'trojan://c@d:2#y';
+ while (Buffer.byteLength(b, 'utf8') % 3 !== 0) b += 'y';
+ assert.ok(!b64(a).endsWith('=') && !b64(b).endsWith('='));
+ assert.equal(safeAtob(b64(a) + b64(b)), a + b); // the trap this guards
+ assert.deepEqual(extractSubConfigs(b64(a) + '\n' + b64(b)), [a, b]);
+ });
+
+ it('decodes per-line base64 vmess containers', () => {
+ const vm = vmessLink('per-line');
+ assert.deepEqual(extractSubConfigs(b64(vm)), [vm]);
+ });
+
+ it('still decodes a line-wrapped whole-body base64 blob', () => {
+ const whole = b64(lineA + '\n' + lineB);
+ const wrapped = (whole.match(/.{1,20}/g) || []).join('\n');
+ assert.deepEqual(extractSubConfigs(wrapped), [lineA, lineB]);
+ });
+
+ it('keeps every config of a wrapped whole-body blob under the standard 76-char wrap', () => {
+ // Regression for the per-line-first heuristic: with 25-byte config
+ // lines and a 76-char wrap, every wrapped chunk contains a full or
+ // cut-off config-looking fragment, so the per-line branch stole the
+ // decision — output had a truncated "vless://u@" and lost two configs.
+ const lines = [];
+ for (let i = 0; i < 6; i++) lines.push('vless://u@1.2.3.4:443#c0' + i);
+ const text = lines.join('\n');
+ const wrapped = (b64(text).match(/.{1,76}/g) || []).join('\n');
+ assert.deepEqual(extractSubConfigs(wrapped), lines);
+ });
+
+ it('keeps every config of a wrapped whole-body blob under a 64-char wrap', () => {
+ const lines = [];
+ for (let i = 0; i < 6; i++) lines.push('vless://u@1.2.3.4:443#c0' + i);
+ const text = lines.join('\n');
+ const wrapped = (b64(text).match(/.{1,64}/g) || []).join('\n');
+ assert.deepEqual(extractSubConfigs(wrapped), lines);
+ });
+
+ it('keeps long-line wrapped blobs on the whole-body path', () => {
+ const lines = [];
+ for (let i = 0; i < 4; i++) {
+ let l = 'vless://12345678-1234-1234-1234-1234567890ab@example-' + i +
+ '.com:443?security=reality&sni=example.com&fp=chrome#' + i;
+ while (l.length < 120) l += 'x';
+ lines.push(l);
+ }
+ const wrapped = (b64(lines.join('\n')).match(/.{1,76}/g) || []).join('\n');
+ assert.deepEqual(extractSubConfigs(wrapped), lines);
+ });
+
it('merges mixed plain+base64 lines with dedupe', () => {
const mixed = lineA + '\n' + b64(lineA) + '\n' + b64(lineB);
// Original lines stay verbatim; decoded configs are appended once —
@@ -623,6 +700,100 @@ describe('extractSubConfigs', () => {
});
});
+describe('expandBulkLines', () => {
+ const lineA = 'vless://a@b:1';
+ const lineB = 'trojan://c@d:2';
+ const b64 = (s) => Buffer.from(s, 'utf8').toString('base64');
+
+ it('returns a plain one-per-line list unchanged', () => {
+ assert.deepEqual(expandBulkLines(lineA + '\n' + lineB), [lineA, lineB]);
+ });
+
+ it('expands a whole-body base64 blob into every config', () => {
+ // The URL box receives the blob as a single line, which used to reach
+ // parseProxyURLSingle() and merge into one link.
+ assert.deepEqual(expandBulkLines(b64(lineA + '\n' + lineB)), [lineA, lineB]);
+ });
+
+ it('expands per-line base64 blobs', () => {
+ assert.deepEqual(expandBulkLines(b64(lineA) + '\n' + b64(lineB)), [lineA, lineB]);
+ });
+
+ it('expands a line-wrapped whole-body blob', () => {
+ const wrapped = (b64(lineA + '\n' + lineB).match(/.{1,20}/g) || []).join('\n');
+ assert.deepEqual(expandBulkLines(wrapped), [lineA, lineB]);
+ });
+
+ it('falls back to the raw lines so junk keeps its parse error', () => {
+ assert.deepEqual(expandBulkLines('not-a-url\nmore junk'), ['not-a-url', 'more junk']);
+ });
+
+ it('returns [] for blank input', () => {
+ assert.deepEqual(expandBulkLines(' '), []);
+ assert.deepEqual(expandBulkLines(''), []);
+ });
+
+ it('drops a raw base64 line its decode already adds (no duplicate output)', () => {
+ // A mixed body keeps raw b64 lines AND their decoded copies; the
+ // bulk box parses every line, so the config appeared twice in the
+ // enhance/download output. Subscription rendering already skips
+ // such raw lines (script.js renderTabResults) — mirror that here.
+ const mixed = lineA + '\n' + b64(lineB);
+ assert.deepEqual(expandBulkLines(mixed), [lineA, lineB]);
+ });
+
+ it('keeps a raw base64 line whose decode holds no config (parse error preserved)', () => {
+ const junkB64 = b64('not a config');
+ assert.deepEqual(expandBulkLines(lineA + '\n' + junkB64), [lineA, junkB64]);
+ });
+
+ it('keeps non-base64 junk lines verbatim (no scheme in decode either)', () => {
+ assert.deepEqual(expandBulkLines(lineA + '\nnot a url'), [lineA, 'not a url']);
+ });
+
+ it('still expands a pure blob body unchanged', () => {
+ assert.deepEqual(expandBulkLines(b64(lineA + '\n' + lineB)), [lineA, lineB]);
+ });
+});
+
+describe('subLooksLikeContent mirror parity', () => {
+ const lineA = 'vless://a@b:1';
+ const lineB = 'trojan://c@d:2';
+ const b64 = (s) => Buffer.from(s, 'utf8').toString('base64');
+
+ it('accepts a per-line padded base64 body (whole-concat decode fails)', () => {
+ // Regression: the whole-body concatenation carries mid-stream '='
+ // padding and safeAtob() returns null, so the mirror said "not
+ // content" while extractSubConfigs() parsed 2 valid configs — the
+ // subscription tab rejected input the bulk box accepts.
+ const body = b64(lineA) + '\n' + b64(lineB);
+ assert.equal(subLooksLikeContent(body), true);
+ assert.equal(subValidate(body), 'content');
+ });
+
+ it('accepts a per-line unpadded base64 body', () => {
+ const a = lineA + '#x', b = lineB + '#y';
+ let aa = a; while (Buffer.byteLength(aa, 'utf8') % 3 !== 0) aa += 'x';
+ let bb = b; while (Buffer.byteLength(bb, 'utf8') % 3 !== 0) bb += 'y';
+ const body = b64(aa) + '\n' + b64(bb);
+ assert.equal(subLooksLikeContent(body), true);
+ });
+
+ it('accepts a line-wrapped whole-body blob (unchanged path)', () => {
+ const wrapped = (b64(lineA + '\n' + lineB).match(/.{1,20}/g) || []).join('\n');
+ assert.equal(subLooksLikeContent(wrapped), true);
+ });
+
+ it('rejects a body where any line decodes to no config', () => {
+ const junk = b64('not a config');
+ assert.equal(subLooksLikeContent(b64(lineA) + '\n' + junk), false);
+ });
+
+ it('rejects plain junk without schemes (unchanged path)', () => {
+ assert.equal(subLooksLikeContent('hello-world-no-scheme'), false);
+ });
+});
+
describe('subIsBlockedError', () => {
it('matches TypeError by name and fetch text as fallback', () => {
const t = new Error('whatever');