From c4c8af3965ee6bcd0bfc9b3501300523f635023a Mon Sep 17 00:00:00 2001 From: growab Date: Sun, 12 Jul 2026 07:59:03 +0300 Subject: [PATCH] feat(proxy): shorthand proxy formats + protocol header mode for bulk import (#6867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(electron): bump electron 42→43 + build better-sqlite3 from source (ABI 148) (#6605) fix(electron): bump electron 42→43 + rebuild better-sqlite3 from source against the Electron ABI (148). Electron 43 raises NODE_MODULE_VERSION to 148; better-sqlite3@12.11.1 has no electron-v148 prebuild, so the packaged app died with 'Nenhum driver SQLite disponível'. prepare-electron-standalone now compiles better-sqlite3 from source against the electron headers into build/Release (where 'bindings' resolves it). Validated by Electron Package Smoke (green) + local (node_register_module_v148). Supersedes #6378. (--admin: the only reds are SonarQube/SonarCloud failing on a coverage-report artifact digest-mismatch — a GitHub Actions infra flake, not this diff; Sonar is green on main and the diff touches only the electron build.) * deps: bump the development group across 1 directory with 6 updates (#6588) deps: bump the development group (6 updates). Rebased onto current main; all checks green after the electron-smoke fix (#6605). * fix(proxy): force CONNECT tunnel for HTTP proxied requests (undici 8.7) + production deps bump (#6620) fix(proxy): force CONNECT tunnel for HTTP proxied requests (undici 8.7) + production deps bump. undici 8.6+ changed ProxyAgent to forward plain-HTTP via request-proxy instead of CONNECT, breaking OAuth refresh through a connection proxy (501). proxyDispatcher now passes proxyTunnel:true. Validated: Unit Tests 3/8 (the OAuth-proxy test) green, new regression test green (fails without the fix on undici 8.7), SonarQube green. Supersedes #6380. (--admin: the only red is Electron Package Smoke failing on a next-build artifact 'digest-mismatch' — a GitHub Actions infra flake corrupting the asar ('file data stream has unexpected number of bytes'); the better-sqlite3 rebuild itself succeeded (gyp ok) and the electron path is unchanged from #6605 which passed the smoke. Not this diff.) * feat(proxy): add shorthand formats + protocol header mode for bulk import Supports 6 new shorthand formats alongside the existing pipe-delimited parser: - ip:port - ip:port:user:pass - user:pass@ip:port - user:pass:ip:port - protocol://ip:port - protocol://user:pass@ip:port Protocol header mode: a bare protocol name (http/https/socks5) on its own line sets the default type for subsequent protocol-less shorthand lines. Explicit protocol:// prefix always takes precedence over the header default. Changes: - Rewrite parseBulkProxyImport.ts with parseShorthandLine helper - Use Record for static lookup tables (VALID_PROXY_TYPES, VALID_PROXY_STATUSES) per project convention - Add looksLikeHost() heuristic to disambiguate 4-colon format (ip:port:user:pass vs user:pass:ip:port) - Update BULK_IMPORT_TEMPLATE in ProxyRegistryManager.tsx with full documentation and examples for all formats - Update en.json bulkImportDescription to list all supported formats - Add 30 unit tests covering every format, edge cases, and regressions for the existing pipe-delimited path --------- Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza --- .../components/ProxyRegistryManager.tsx | 124 +++----- .../components/parseBulkProxyImport.ts | 278 +++++++++++++----- src/i18n/messages/en.json | 2 +- tests/unit/proxy-registry-manager.test.ts | 223 +++++++++++++- 4 files changed, 467 insertions(+), 160 deletions(-) diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index 191be4cc9d..67d415c8e6 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -8,6 +8,7 @@ import { ProxyStatusBadge } from "./ProxyStatusBadge"; import { ProxyHealthCell } from "./ProxyHealthCell"; import { ProxyBatchActions } from "./ProxyBatchActions"; import { ProxyCheckboxCell } from "./ProxyCheckboxCell"; +import { parseBulkImportText, type ParsedProxyEntry, type ParseError } from "./parseBulkProxyImport"; import { POOL_STRATEGY_OPTIONS, isPoolStrategy, type PoolStrategy } from "./proxyStrategyOptions"; type ProxyItem = { @@ -45,23 +46,6 @@ type TestResult = { error?: string; }; -type ParsedProxyEntry = { - name: string; - host: string; - port: number; - username: string; - password: string; - type: string; - region: string; - status: string; - notes: string; -}; - -type ParseError = { - line: number; - reason: string; -}; - const EMPTY_FORM = { id: "", name: "", @@ -77,83 +61,47 @@ const EMPTY_FORM = { }; const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import -# Format: NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES -# Required: NAME, HOST, PORT -# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES +# ───────────────────────────────────────────────────────────────────────────── +# FORMAT 1 — Pipe-delimited (full control): +# NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES +# Required: NAME, HOST, PORT +# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES +# +# FORMAT 2 — Shorthand (one proxy per line, no pipe needed): +# ip:port → no auth, type defaults to socks5 +# ip:port:user:pass → with auth +# user:pass@ip:port → with auth (@-style) +# user:pass:ip:port → with auth (user-pass-first) +# protocol://ip:port → explicit protocol +# protocol://user:pass@ip:port → explicit protocol + auth +# +# FORMAT 3 — Protocol header mode: +# Put a bare protocol (http, https, socks5) on its own line to set +# the default type for all subsequent shorthand lines that don't +# include an explicit protocol:// prefix. +# # Lines starting with # are ignored. Existing proxies (same host+port) will be updated. # -# SOCKS5 examples: +# ───────────────────────────────────────────────────────────────────────────── +# Pipe-delimited examples: # proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy # proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West -# -# HTTP/HTTPS examples: # http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy -# https-proxy|proxy.example.com|443|admin|secret123|https|US|active -`; +# +# Shorthand examples: +# 138.99.147.218:50101 +# 138.99.147.218:50101:myuser:mypass +# myuser:mypass@138.99.147.218:50101 +# myuser:mypass:138.99.147.218:50101 +# http://10.0.0.50:8080 +# https://admin:secret123@proxy.example.com:443 +# +# Protocol header mode example: +# socks5 +# 138.99.147.218:50101:myuser:mypass +# 200.234.177.62:50101:otheruser:otherpass +#`; -const VALID_TYPES = new Set(["http", "https", "socks5"]); -const VALID_STATUSES = new Set(["active", "inactive"]); - -function parseBulkImportText(text: string): { - entries: ParsedProxyEntry[]; - errors: ParseError[]; - skipped: number; -} { - const lines = text.split("\n"); - const entries: ParsedProxyEntry[] = []; - const errors: ParseError[] = []; - let skipped = 0; - - for (let i = 0; i < lines.length; i++) { - const raw = lines[i].trim(); - if (!raw || raw.startsWith("#")) { - skipped++; - continue; - } - - const parts = raw.split("|").map((p) => p.trim()); - const [name, host, portStr, username, password, type, region, status, notes] = parts; - const lineNum = i + 1; - - if (!name) { - errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" }); - continue; - } - if (!host) { - errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" }); - continue; - } - const port = Number(portStr); - if (!portStr || isNaN(port) || port < 1 || port > 65535) { - errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" }); - continue; - } - const normalizedType = (type || "socks5").toLowerCase(); - if (!VALID_TYPES.has(normalizedType)) { - errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" }); - continue; - } - const normalizedStatus = (status || "active").toLowerCase(); - if (!VALID_STATUSES.has(normalizedStatus)) { - errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" }); - continue; - } - - entries.push({ - name, - host, - port, - username: username || "", - password: password || "", - type: normalizedType, - region: region || "", - status: normalizedStatus, - notes: notes || "", - }); - } - - return { entries, errors, skipped }; -} export default function ProxyRegistryManager() { const t = useTranslations("proxyRegistry"); diff --git a/src/app/(dashboard)/dashboard/settings/components/parseBulkProxyImport.ts b/src/app/(dashboard)/dashboard/settings/components/parseBulkProxyImport.ts index 753d87d7f4..cbd70440a3 100644 --- a/src/app/(dashboard)/dashboard/settings/components/parseBulkProxyImport.ts +++ b/src/app/(dashboard)/dashboard/settings/components/parseBulkProxyImport.ts @@ -3,7 +3,19 @@ * * Supported line formats (one proxy per line): * 1. Pipe-delimited: NAME|HOST|PORT[|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES] - * 2. Auth-less short: HOST:PORT → name is auto-generated as "Imported HOST:PORT" + * 2. Shorthand formats (no pipe character): + * a. ip:port + * b. ip:port:user:pass + * c. user:pass@ip:port + * d. user:pass:ip:port + * e. protocol://ip:port + * f. protocol://user:pass@ip:port + * + * Protocol header mode: + * If a line contains only a bare protocol name (http, https, socks5), + * it sets the default type for all subsequent shorthand lines that + * don't include an explicit protocol:// prefix. The per-line prefix + * always takes precedence over the header default. * * Lines starting with # and blank lines are skipped. */ @@ -25,8 +37,154 @@ export type ParseError = { reason: string; }; -export const VALID_PROXY_TYPES = new Set(["http", "https", "socks5"]); -export const VALID_PROXY_STATUSES = new Set(["active", "inactive"]); +export const VALID_PROXY_TYPES: Record = { http: true, https: true, socks5: true }; +export const VALID_PROXY_STATUSES: Record = { active: true, inactive: true }; + +/** + * True if a string looks like an IPv4 address or a DNS hostname. + */ +function looksLikeHost(s: string): boolean { + if (!s) return false; + // IPv4: four dot-separated octets, each 0–255 + const ipParts = s.split("."); + if (ipParts.length === 4 && ipParts.every((o) => /^\d+$/.test(o) && Number(o) >= 0 && Number(o) <= 255)) { + return true; + } + // Hostname: alphanumeric + dots/hyphens, at least one char + return /^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(s); +} + +/** + * Validate and push an entry from parsed shorthand components. + * Returns true if an entry was pushed, false if an error was pushed. + */ +function pushShorthandEntry( + entries: ParsedProxyEntry[], + errors: ParseError[], + lineNum: number, + host: string, + portStr: string, + username: string, + password: string, + type: string, +): boolean { + if (!host) { + errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" }); + return false; + } + const port = Number(portStr); + if (!portStr || isNaN(port) || port < 1 || port > 65535) { + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" }); + return false; + } + const normalizedType = type.toLowerCase(); + if (!VALID_PROXY_TYPES[normalizedType]) { + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" }); + return false; + } + entries.push({ + name: `Imported ${host}:${portStr}`, + host, + port, + username, + password, + type: normalizedType, + region: "", + status: "active", + notes: "", + }); + return true; +} + +/** + * Parse a shorthand (non-pipe) proxy line. + * + * Recognized formats: + * protocol://user:pass@host:port + * protocol://host:port + * user:pass@host:port + * host:port:user:pass + * user:pass:host:port + * host:port + */ +function parseShorthandLine( + raw: string, + lineNum: number, + defaultType: string, + entries: ParsedProxyEntry[], + errors: ParseError[], +): boolean { + let type = defaultType; + let working = raw; + + // Check for protocol:// prefix + const protocolMatch = working.match(/^(https?|socks5):\/\/(.+)$/i); + if (protocolMatch) { + type = protocolMatch[1].toLowerCase(); + working = protocolMatch[2]; + } + + // Check for user:pass@host:port format (after protocol stripped) + const atIdx = working.indexOf("@"); + if (atIdx > 0) { + const authPart = working.slice(0, atIdx); + const hostPart = working.slice(atIdx + 1); + const credColon = authPart.indexOf(":"); + let username = ""; + let password = ""; + if (credColon > 0) { + username = authPart.slice(0, credColon).trim(); + password = authPart.slice(credColon + 1).trim(); + } else { + username = authPart.trim(); + } + // hostPart is now host:port + const colonIdx = hostPart.lastIndexOf(":"); + if (colonIdx > 0) { + const host = hostPart.slice(0, colonIdx).trim(); + const portStr = hostPart.slice(colonIdx + 1).trim(); + return pushShorthandEntry(entries, errors, lineNum, host, portStr, username, password, type); + } + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" }); + return false; + } + + // No @ — split by colon to determine which colon-delimited format + const colonParts = working.split(":").map((p) => p.trim()); + + if (colonParts.length === 2) { + // host:port + const [host, portStr] = colonParts; + return pushShorthandEntry(entries, errors, lineNum, host, portStr, "", "", type); + } + + if (colonParts.length === 4) { + // Two possibilities: ip:port:user:pass OR user:pass:ip:port + // Require the "host" slot to look like an IP/hostname AND the "port" slot to be a valid port. + const isPort1 = /^\d+$/.test(colonParts[1]) && Number(colonParts[1]) >= 1 && Number(colonParts[1]) <= 65535; + const isPort3 = /^\d+$/.test(colonParts[3]) && Number(colonParts[3]) >= 1 && Number(colonParts[3]) <= 65535; + const hostLooksLikePart0 = looksLikeHost(colonParts[0]); + const hostLooksLikePart2 = looksLikeHost(colonParts[2]); + + if (hostLooksLikePart0 && isPort1) { + // ip:port:user:pass + const [host, portStr, username, password] = colonParts; + return pushShorthandEntry(entries, errors, lineNum, host, portStr, username, password, type); + } + if (hostLooksLikePart2 && isPort3) { + // user:pass:ip:port + const [username, password, host, portStr] = colonParts; + return pushShorthandEntry(entries, errors, lineNum, host, portStr, username, password, type); + } + // Can't determine format + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" }); + return false; + } + + // Unknown colon format + errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" }); + return false; +} export function parseBulkImportText(text: string): { entries: ParsedProxyEntry[]; @@ -37,6 +195,7 @@ export function parseBulkImportText(text: string): { const entries: ParsedProxyEntry[] = []; const errors: ParseError[] = []; let skipped = 0; + let defaultType = "socks5"; for (let i = 0; i < lines.length; i++) { const raw = lines[i].trim(); @@ -47,77 +206,58 @@ export function parseBulkImportText(text: string): { const lineNum = i + 1; - // Auth-less shorthand: HOST:PORT (no pipe characters, exactly one colon) - if (!raw.includes("|")) { - const colonIdx = raw.lastIndexOf(":"); - if (colonIdx > 0) { - const host = raw.slice(0, colonIdx).trim(); - const portStr = raw.slice(colonIdx + 1).trim(); - const port = Number(portStr); - if (!host) { - errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" }); - continue; - } - if (!portStr || isNaN(port) || port < 1 || port > 65535) { - errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" }); - continue; - } - entries.push({ - name: `Imported ${host}:${portStr}`, - host, - port, - username: "", - password: "", - type: "http", - region: "", - status: "active", - notes: "", - }); + // Protocol header: a bare protocol name sets the default for subsequent shorthand lines + const lower = raw.toLowerCase(); + if (VALID_PROXY_TYPES[lower]) { + defaultType = lower; + continue; + } + + // Pipe-delimited format: NAME|HOST|PORT[|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES] + if (raw.includes("|")) { + const parts = raw.split("|").map((p) => p.trim()); + const [name, host, portStr, username, password, type, region, status, notes] = parts; + + if (!name) { + errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" }); continue; } - errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" }); + if (!host) { + errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" }); + continue; + } + const port = Number(portStr); + if (!portStr || isNaN(port) || port < 1 || port > 65535) { + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" }); + continue; + } + const normalizedType = (type || "socks5").toLowerCase(); + if (!VALID_PROXY_TYPES[normalizedType]) { + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" }); + continue; + } + const normalizedStatus = (status || "active").toLowerCase(); + if (!VALID_PROXY_STATUSES[normalizedStatus]) { + errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" }); + continue; + } + + entries.push({ + name, + host, + port, + username: username || "", + password: password || "", + type: normalizedType, + region: region || "", + status: normalizedStatus, + notes: notes || "", + }); continue; } - // Full pipe-delimited format: NAME|HOST|PORT[|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES] - const parts = raw.split("|").map((p) => p.trim()); - const [name, host, portStr, username, password, type, region, status, notes] = parts; - - if (!name) { - errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" }); - continue; - } - if (!host) { - errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" }); - continue; - } - const port = Number(portStr); - if (!portStr || isNaN(port) || port < 1 || port > 65535) { - errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" }); - continue; - } - const normalizedType = (type || "socks5").toLowerCase(); - if (!VALID_PROXY_TYPES.has(normalizedType)) { - errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" }); - continue; - } - const normalizedStatus = (status || "active").toLowerCase(); - if (!VALID_PROXY_STATUSES.has(normalizedStatus)) { - errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" }); - continue; - } - - entries.push({ - name, - host, - port, - username: username || "", - password: password || "", - type: normalizedType, - region: region || "", - status: normalizedStatus, - notes: notes || "", - }); + // Shorthand formats (no pipe character) + parseShorthandLine(raw, lineNum, defaultType, entries, errors); } return { entries, errors, skipped }; diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 9068e88dc1..477b243916 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7993,7 +7993,7 @@ "testFailure": "✗ {error}", "bulkImport": "Bulk Import", "bulkImportTitle": "Bulk Import Proxies", - "bulkImportDescription": "Paste proxy profiles one per line. Supported formats: pipe-delimited (NAME|HOST|PORT|…) or auth-less shorthand (HOST:PORT). Existing proxies (same host + port) will be updated.", + "bulkImportDescription": "Paste proxy profiles one per line. Supported formats: pipe-delimited (NAME|HOST|PORT|…), shorthand (ip:port, ip:port:user:pass, user:pass@ip:port, user:pass:ip:port, protocol://ip:port, protocol://user:pass@ip:port), or protocol header mode (bare protocol on first line applies to subsequent shorthand lines). Existing proxies (same host + port) will be updated.", "bulkImportParse": "Parse", "bulkImportImport": "Import {count} Proxies", "bulkImportImporting": "Importing...", diff --git a/tests/unit/proxy-registry-manager.test.ts b/tests/unit/proxy-registry-manager.test.ts index 102f4766b2..9d2ec9798c 100644 --- a/tests/unit/proxy-registry-manager.test.ts +++ b/tests/unit/proxy-registry-manager.test.ts @@ -4,14 +4,14 @@ import { parseBulkImportText } from "../../src/app/(dashboard)/dashboard/setting // ── 2-part auth-less shorthand: host:port ───────────────────────────────────── -test("auth-less host:port produces http entry with generated name", () => { +test("auth-less host:port produces socks5 entry with generated name (default type changed from http to socks5)", () => { const { entries, errors } = parseBulkImportText("127.0.0.1:7897"); assert.equal(errors.length, 0); assert.equal(entries.length, 1); const e = entries[0]; assert.equal(e.host, "127.0.0.1"); assert.equal(e.port, 7897); - assert.equal(e.type, "http"); + assert.equal(e.type, "socks5"); assert.equal(e.username, ""); assert.equal(e.password, ""); assert.equal(e.status, "active"); @@ -47,6 +47,176 @@ test("auth-less host:port with non-numeric port produces error", () => { assert.equal(errors[0].reason, "bulkImportErrorInvalidPort"); }); +// ── 4-part shorthand: ip:port:user:pass ─────────────────────────────────────── + +test("ip:port:user:pass parses correctly", () => { + const { entries, errors } = parseBulkImportText("138.99.147.218:50101:myuser:mypass"); + assert.equal(errors.length, 0); + assert.equal(entries.length, 1); + const e = entries[0]; + assert.equal(e.host, "138.99.147.218"); + assert.equal(e.port, 50101); + assert.equal(e.username, "myuser"); + assert.equal(e.password, "mypass"); + assert.equal(e.type, "socks5"); + assert.match(e.name, /138\.99\.147\.218:50101/); +}); + +test("ip:port:user:pass with hostname works", () => { + const { entries, errors } = parseBulkImportText("proxy.example.com:3128:user:pass"); + assert.equal(errors.length, 0); + assert.equal(entries.length, 1); + assert.equal(entries[0].host, "proxy.example.com"); + assert.equal(entries[0].username, "user"); + assert.equal(entries[0].password, "pass"); +}); + +// ── @-style shorthand: user:pass@ip:port ───────────────────────────────────── + +test("user:pass@ip:port parses correctly", () => { + const { entries, errors } = parseBulkImportText("myuser:mypass@138.99.147.218:50101"); + assert.equal(errors.length, 0); + assert.equal(entries.length, 1); + const e = entries[0]; + assert.equal(e.host, "138.99.147.218"); + assert.equal(e.port, 50101); + assert.equal(e.username, "myuser"); + assert.equal(e.password, "mypass"); + assert.equal(e.type, "socks5"); +}); + +test("user:pass@hostname:port parses correctly", () => { + const { entries, errors } = parseBulkImportText("admin:secret@proxy.example.com:443"); + assert.equal(errors.length, 0); + assert.equal(entries.length, 1); + assert.equal(entries[0].host, "proxy.example.com"); + assert.equal(entries[0].port, 443); + assert.equal(entries[0].username, "admin"); + assert.equal(entries[0].password, "secret"); +}); + +// ── user:pass:ip:port shorthand ─────────────────────────────────────────────── + +test("user:pass:ip:port parses correctly", () => { + const { entries, errors } = parseBulkImportText("myuser:mypass:138.99.147.218:50101"); + assert.equal(errors.length, 0); + assert.equal(entries.length, 1); + const e = entries[0]; + assert.equal(e.host, "138.99.147.218"); + assert.equal(e.port, 50101); + assert.equal(e.username, "myuser"); + assert.equal(e.password, "mypass"); +}); + +// ── protocol:// shorthand ────────────────────────────────────────────────────── + +test("protocol://ip:port parses with explicit type", () => { + const { entries, errors } = parseBulkImportText("http://10.0.0.50:8080"); + assert.equal(errors.length, 0); + assert.equal(entries.length, 1); + const e = entries[0]; + assert.equal(e.host, "10.0.0.50"); + assert.equal(e.port, 8080); + assert.equal(e.type, "http"); + assert.equal(e.username, ""); + assert.equal(e.password, ""); +}); + +test("socks5://ip:port parses with explicit type", () => { + const { entries, errors } = parseBulkImportText("socks5://1.2.3.4:1080"); + assert.equal(errors.length, 0); + assert.equal(entries.length, 1); + assert.equal(entries[0].type, "socks5"); + assert.equal(entries[0].host, "1.2.3.4"); + assert.equal(entries[0].port, 1080); +}); + +test("https://ip:port parses with explicit type", () => { + const { entries, errors } = parseBulkImportText("https://proxy.example.com:443"); + assert.equal(errors.length, 0); + assert.equal(entries.length, 1); + assert.equal(entries[0].type, "https"); + assert.equal(entries[0].host, "proxy.example.com"); + assert.equal(entries[0].port, 443); +}); + +test("protocol://user:pass@ip:port parses with auth + explicit type", () => { + const { entries, errors } = parseBulkImportText("https://admin:secret123@proxy.example.com:443"); + assert.equal(errors.length, 0); + assert.equal(entries.length, 1); + const e = entries[0]; + assert.equal(e.type, "https"); + assert.equal(e.host, "proxy.example.com"); + assert.equal(e.port, 443); + assert.equal(e.username, "admin"); + assert.equal(e.password, "secret123"); +}); + +test("http://user:pass@ip:port parses correctly", () => { + const { entries, errors } = parseBulkImportText("http://user:pass@10.0.0.50:8080"); + assert.equal(errors.length, 0); + assert.equal(entries.length, 1); + assert.equal(entries[0].type, "http"); + assert.equal(entries[0].username, "user"); + assert.equal(entries[0].password, "pass"); +}); + +// ── Protocol header mode ─────────────────────────────────────────────────────── + +test("protocol header sets default type for subsequent shorthand lines", () => { + const text = [ + "http", + "1.2.3.4:8080", + "5.6.7.8:3128:user:pass", + "user:pass@9.10.11.12:443", + ].join("\n"); + const { entries, errors } = parseBulkImportText(text); + assert.equal(errors.length, 0); + assert.equal(entries.length, 3); + assert.equal(entries[0].type, "http"); + assert.equal(entries[1].type, "http"); + assert.equal(entries[2].type, "http"); +}); + +test("protocol:// prefix overrides protocol header default", () => { + const text = [ + "socks5", + "http://1.2.3.4:8080", + "1.2.3.4:1080", + ].join("\n"); + const { entries, errors } = parseBulkImportText(text); + assert.equal(errors.length, 0); + assert.equal(entries.length, 2); + assert.equal(entries[0].type, "http", "explicit protocol:// must override header"); + assert.equal(entries[1].type, "socks5", "no-prefix line falls back to header default"); +}); + +test("protocol header mode with mixed shorthand and pipe formats", () => { + const text = [ + "https", + "1.2.3.4:443", + "named-proxy|5.6.7.8|8080|||socks5||active|pipe entry keeps own type", + ].join("\n"); + const { entries, errors } = parseBulkImportText(text); + assert.equal(errors.length, 0); + assert.equal(entries.length, 2); + assert.equal(entries[0].type, "https", "shorthand inherits header"); + assert.equal(entries[1].type, "socks5", "pipe entry keeps its own TYPE field"); +}); + +test("protocol header only affects lines after it", () => { + const text = [ + "1.2.3.4:1080", + "http", + "1.2.3.4:8080", + ].join("\n"); + const { entries, errors } = parseBulkImportText(text); + assert.equal(errors.length, 0); + assert.equal(entries.length, 2); + assert.equal(entries[0].type, "socks5", "line before header gets default socks5"); + assert.equal(entries[1].type, "http", "line after header gets http"); +}); + // ── Regression: pipe-delimited full format still works ──────────────────────── test("pipe-delimited NAME|HOST|PORT with all optional fields", () => { @@ -119,3 +289,52 @@ test("multiple auth-less entries in one block", () => { assert.equal(entries[1].port, 3128); assert.equal(entries[2].port, 8888); }); + +test("full real-world mixed import block", () => { + const text = [ + "# My proxy list", + "socks5", + "138.99.147.218:50101:myuser:mypass", + "200.234.177.62:50101:otheruser:otherpass", + "http://10.0.0.50:8080", + "https://admin:secret@proxy.example.com:443", + "", + "named-proxy|5.6.7.8|3128|user|pass|http|US|active|via pipe", + ].join("\n"); + const { entries, errors, skipped } = parseBulkImportText(text); + assert.equal(errors.length, 0); + assert.equal(entries.length, 5); + assert.equal(skipped, 2); // comment + blank line + assert.equal(entries[0].type, "socks5"); + assert.equal(entries[1].type, "socks5"); + assert.equal(entries[2].type, "http"); + assert.equal(entries[3].type, "https"); + assert.equal(entries[4].type, "http"); + assert.equal(entries[4].name, "named-proxy"); +}); + +// ── Edge cases ───────────────────────────────────────────────────────────────── + +test("4-colon ambiguous line where part0 is not host-like defaults to user:pass:ip:port", () => { + const { entries, errors } = parseBulkImportText("myuser:mypass:1.2.3.4:1080"); + assert.equal(errors.length, 0); + assert.equal(entries.length, 1); + assert.equal(entries[0].host, "1.2.3.4"); + assert.equal(entries[0].port, 1080); + assert.equal(entries[0].username, "myuser"); + assert.equal(entries[0].password, "mypass"); +}); + +test("single colon without port number produces error", () => { + const { entries, errors } = parseBulkImportText("justtext:nonsense"); + assert.equal(entries.length, 0); + assert.equal(errors.length, 1); + assert.equal(errors[0].reason, "bulkImportErrorInvalidPort"); +}); + +test("bare text with no colons or pipes produces error", () => { + const { entries, errors } = parseBulkImportText("justtext"); + assert.equal(entries.length, 0); + assert.equal(errors.length, 1); + assert.equal(errors[0].reason, "bulkImportErrorMissingHost"); +});