mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(proxy): shorthand proxy formats + protocol header mode for bulk import (#6867)
* 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<string, true> 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 <diegosouza.pw@gmail.com>
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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<string, true> = { http: true, https: true, socks5: true };
|
||||
export const VALID_PROXY_STATUSES: Record<string, true> = { 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 };
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user