feat(proxy): implement bulk proxy import via pipe-delimited parser with update-or-create logic

This commit is contained in:
diegosouzapw
2026-04-28 13:37:44 -03:00
parent 13f6011255
commit 97ecda03ec
6 changed files with 473 additions and 1 deletions

View File

@@ -38,6 +38,23 @@ 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: "",
@@ -51,6 +68,85 @@ const EMPTY_FORM = {
status: "active",
};
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
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
#
# SOCKS5 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
`;
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: "Missing NAME" });
continue;
}
if (!host) {
errors.push({ line: lineNum, reason: "Missing HOST" });
continue;
}
const port = Number(portStr);
if (!portStr || isNaN(port) || port < 1 || port > 65535) {
errors.push({ line: lineNum, reason: "Invalid PORT (must be 1-65535)" });
continue;
}
const normalizedType = (type || "socks5").toLowerCase();
if (!VALID_TYPES.has(normalizedType)) {
errors.push({ line: lineNum, reason: `Invalid TYPE '${type}' (use http, https, or socks5)` });
continue;
}
const normalizedStatus = (status || "active").toLowerCase();
if (!VALID_STATUSES.has(normalizedStatus)) {
errors.push({ line: lineNum, reason: `Invalid STATUS '${status}' (use active or inactive)` });
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");
const [items, setItems] = useState<ProxyItem[]>([]);
@@ -72,6 +168,20 @@ export default function ProxyRegistryManager() {
const [bulkScopeIds, setBulkScopeIds] = useState("");
const [bulkProxyId, setBulkProxyId] = useState("");
// Bulk Import state
const [bulkImportOpen, setBulkImportOpen] = useState(false);
const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE);
const [bulkImportParsed, setBulkImportParsed] = useState<ParsedProxyEntry[]>([]);
const [bulkImportErrors, setBulkImportErrors] = useState<ParseError[]>([]);
const [bulkImportSkipped, setBulkImportSkipped] = useState(0);
const [bulkImportParsedOnce, setBulkImportParsedOnce] = useState(false);
const [bulkImporting, setBulkImporting] = useState(false);
const [bulkImportResult, setBulkImportResult] = useState<{
created: number;
updated: number;
failed: number;
} | null>(null);
const editingId = useMemo(() => form.id || "", [form.id]);
const loadHealth = useCallback(async () => {
@@ -382,6 +492,77 @@ export default function ProxyRegistryManager() {
}
};
const handleBulkImportParse = () => {
const { entries, errors, skipped } = parseBulkImportText(bulkImportText);
setBulkImportParsed(entries);
setBulkImportErrors(errors);
setBulkImportSkipped(skipped);
setBulkImportParsedOnce(true);
setBulkImportResult(null);
};
const handleBulkImportExecute = async () => {
if (bulkImportParsed.length === 0) return;
if (bulkImportParsed.length > 100) {
setError(t("bulkImportMaxExceeded"));
return;
}
setBulkImporting(true);
setError(null);
setBulkImportResult(null);
try {
const payload = {
items: bulkImportParsed.map((entry) => ({
name: entry.name,
type: entry.type,
host: entry.host,
port: entry.port,
username: entry.username || undefined,
password: entry.password || undefined,
region: entry.region || null,
notes: entry.notes || null,
status: entry.status as "active" | "inactive",
})),
};
const res = await fetch("/api/settings/proxies/bulk-import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error?.message || "Failed to import proxies");
return;
}
setBulkImportResult({
created: data.created || 0,
updated: data.updated || 0,
failed: data.failed || 0,
});
await load();
} catch (e: any) {
setError(e?.message || "Failed to import proxies");
} finally {
setBulkImporting(false);
}
};
const openBulkImport = () => {
setBulkImportText(BULK_IMPORT_TEMPLATE);
setBulkImportParsed([]);
setBulkImportErrors([]);
setBulkImportSkipped(0);
setBulkImportParsedOnce(false);
setBulkImportResult(null);
setBulkImportOpen(true);
};
return (
<>
<Card className="p-6">
@@ -401,6 +582,15 @@ export default function ProxyRegistryManager() {
>
{t("importLegacy")}
</Button>
<Button
size="sm"
variant="secondary"
icon="upload_file"
onClick={openBulkImport}
data-testid="proxy-registry-open-bulk-import"
>
{t("bulkImport")}
</Button>
<Button
size="sm"
variant="secondary"
@@ -726,6 +916,158 @@ export default function ProxyRegistryManager() {
</div>
</div>
</Modal>
{/* Bulk Import Modal */}
<Modal
isOpen={bulkImportOpen}
onClose={() => {
if (!bulkImporting) setBulkImportOpen(false);
}}
title={t("bulkImportTitle")}
maxWidth="xl"
>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">{t("bulkImportDescription")}</p>
<div>
<textarea
data-testid="proxy-registry-bulk-import-textarea"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed"
rows={14}
value={bulkImportText}
onChange={(e) => {
setBulkImportText(e.target.value);
setBulkImportParsedOnce(false);
setBulkImportResult(null);
}}
spellCheck={false}
/>
</div>
{/* Parse button */}
<div className="flex items-center gap-3">
<Button
size="sm"
variant="secondary"
icon="search"
onClick={handleBulkImportParse}
data-testid="proxy-registry-bulk-import-parse"
>
{t("bulkImportParse")}
</Button>
{bulkImportParsedOnce && (
<div className="flex items-center gap-3 text-xs">
<span className="text-emerald-400">
{t("bulkImportParsed", { count: bulkImportParsed.length })}
</span>
<span className="text-text-muted">
{t("bulkImportSkipped", { count: bulkImportSkipped })}
</span>
{bulkImportErrors.length > 0 && (
<span className="text-red-400">
{t("bulkImportParseErrors", { count: bulkImportErrors.length })}
</span>
)}
</div>
)}
</div>
{/* Parse errors */}
{bulkImportErrors.length > 0 && (
<div className="max-h-28 overflow-y-auto rounded border border-red-500/30 bg-red-500/10 p-2">
{bulkImportErrors.map((err, idx) => (
<div key={idx} className="text-xs text-red-400">
{t("bulkImportErrorLine", { line: err.line, reason: err.reason })}
</div>
))}
</div>
)}
{/* Preview table */}
{bulkImportParsedOnce && bulkImportParsed.length > 0 && (
<div className="overflow-x-auto max-h-48 overflow-y-auto rounded border border-border">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-text-muted border-b border-border bg-bg-subtle sticky top-0">
<th className="py-1.5 px-2">Name</th>
<th className="py-1.5 px-2">Type</th>
<th className="py-1.5 px-2">Host</th>
<th className="py-1.5 px-2">Port</th>
<th className="py-1.5 px-2">User</th>
<th className="py-1.5 px-2">Region</th>
<th className="py-1.5 px-2">Status</th>
</tr>
</thead>
<tbody>
{bulkImportParsed.map((entry, idx) => (
<tr key={idx} className="border-b border-border/40">
<td className="py-1 px-2 font-medium text-text-main">{entry.name}</td>
<td className="py-1 px-2">
<span className="px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-[10px]">
{entry.type}
</span>
</td>
<td className="py-1 px-2 font-mono text-text-muted">{entry.host}</td>
<td className="py-1 px-2 font-mono text-text-muted">{entry.port}</td>
<td className="py-1 px-2 text-text-muted">{entry.username || "—"}</td>
<td className="py-1 px-2 text-text-muted">{entry.region || "—"}</td>
<td className="py-1 px-2">
<span
className={
entry.status === "active" ? "text-emerald-400" : "text-text-muted"
}
>
{entry.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* No valid entries warning */}
{bulkImportParsedOnce &&
bulkImportParsed.length === 0 &&
bulkImportErrors.length === 0 && (
<div className="text-sm text-amber-400">{t("bulkImportNoValidEntries")}</div>
)}
{/* Import result */}
{bulkImportResult && (
<div className="px-3 py-2 rounded border border-emerald-500/30 bg-emerald-500/10 text-sm text-emerald-400">
{t("bulkImportSuccess", {
created: bulkImportResult.created,
updated: bulkImportResult.updated,
failed: bulkImportResult.failed,
})}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button size="sm" variant="secondary" onClick={() => setBulkImportOpen(false)}>
{t("cancel")}
</Button>
<Button
size="sm"
icon="upload"
onClick={handleBulkImportExecute}
loading={bulkImporting}
disabled={!bulkImportParsedOnce || bulkImportParsed.length === 0}
data-testid="proxy-registry-bulk-import-execute"
>
{bulkImporting
? t("bulkImportImporting")
: bulkImportParsed.length > 0
? t("bulkImportImport", { count: bulkImportParsed.length })
: t("bulkImport")}
</Button>
</div>
</div>
</Modal>
</>
);
}

View File

@@ -0,0 +1,75 @@
import { upsertProxy } from "@/lib/localDb";
import { bulkImportProxiesSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({
status: 400,
message: "Invalid JSON body",
type: "invalid_request",
});
}
try {
const validation = validateBody(bulkImportProxiesSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({
status: 400,
message: validation.error.message,
details: validation.error.details,
type: "invalid_request",
});
}
const { items } = validation.data;
let created = 0;
let updated = 0;
let failed = 0;
const results: Array<{
name: string;
success: boolean;
action?: "created" | "updated";
id?: string;
error?: string;
}> = [];
for (const item of items) {
try {
const result = await upsertProxy(item);
if (result.proxy) {
if (result.action === "created") created++;
else updated++;
results.push({
name: item.name,
success: true,
action: result.action,
id: result.proxy.id,
});
} else {
failed++;
results.push({ name: item.name, success: false, error: "Unknown error" });
}
} catch (error) {
failed++;
results.push({
name: item.name,
success: false,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
return Response.json({ created, updated, failed, results });
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to bulk import proxies");
}
}

View File

@@ -4625,7 +4625,26 @@
"noData": "—",
"testSuccess": "✓ {ip}",
"testLatency": "{latency}ms",
"testFailure": "✗ {error}"
"testFailure": "✗ {error}",
"bulkImport": "Bulk Import",
"bulkImportTitle": "Bulk Import Proxies",
"bulkImportDescription": "Paste proxy profiles using pipe-delimited format. One proxy per line. Existing proxies (same host + port) will be updated.",
"bulkImportParse": "Parse",
"bulkImportImport": "Import {count} Proxies",
"bulkImportImporting": "Importing...",
"bulkImportParsed": "{count} proxies parsed",
"bulkImportSkipped": "{count} lines skipped",
"bulkImportParseErrors": "{count} errors",
"bulkImportNoValidEntries": "No valid entries found. Check the format and try again.",
"bulkImportSuccess": "Import complete: {created} created, {updated} updated, {failed} failed",
"bulkImportErrorLine": "Line {line}: {reason}",
"bulkImportMaxExceeded": "Maximum 100 proxies per import",
"bulkImportPreview": "Preview",
"bulkImportErrorMissingName": "Missing NAME",
"bulkImportErrorMissingHost": "Missing HOST",
"bulkImportErrorInvalidPort": "Invalid PORT (must be 1-65535)",
"bulkImportErrorInvalidType": "Invalid TYPE (use http, https, or socks5)",
"bulkImportErrorInvalidStatus": "Invalid STATUS (use active or inactive)"
},
"playground": {
"title": "Model Playground",

View File

@@ -192,6 +192,32 @@ export async function createProxy(payload: ProxyPayload) {
return getProxyById(id, { includeSecrets: false });
}
/**
* Upsert a proxy by host+port.
* If a proxy with the same host and port already exists, update it.
* Otherwise, create a new one. Used by the bulk import feature.
*/
export async function upsertProxy(payload: ProxyPayload): Promise<{
proxy: ProxyRegistryRecord | null;
action: "created" | "updated";
}> {
const db = getDbInstance();
const host = (payload.host || "").trim();
const port = Number(payload.port);
const existing = db
.prepare("SELECT id FROM proxy_registry WHERE host = ? AND port = ? LIMIT 1")
.get(host, port) as { id?: string } | undefined;
if (existing?.id) {
const updated = await updateProxy(existing.id, payload);
return { proxy: updated, action: "updated" };
}
const created = await createProxy(payload);
return { proxy: created, action: "created" };
}
export async function updateProxy(id: string, payload: Partial<ProxyPayload>) {
const db = getDbInstance();
const existing = await getProxyById(id, { includeSecrets: true });

View File

@@ -149,6 +149,7 @@ export {
getProxyById,
createProxy,
updateProxy,
upsertProxy,
deleteProxyById,
getProxyAssignments,
getProxyWhereUsed,

View File

@@ -1157,6 +1157,15 @@ export const updateProxyRegistrySchema = createProxyRegistrySchema.partial().ext
id: z.string().trim().min(1, "id is required"),
});
export const bulkImportProxiesSchema = z
.object({
items: z
.array(createProxyRegistrySchema)
.min(1, "At least one proxy is required")
.max(100, "Maximum 100 proxies per import"),
})
.strict();
export const proxyAssignmentSchema = z
.object({
scope: z.enum(["global", "provider", "account", "combo", "key"]),