mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
feat(proxy): implement bulk proxy import via pipe-delimited parser with update-or-create logic
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
75
src/app/api/settings/proxies/bulk-import/route.ts
Normal file
75
src/app/api/settings/proxies/bulk-import/route.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -149,6 +149,7 @@ export {
|
||||
getProxyById,
|
||||
createProxy,
|
||||
updateProxy,
|
||||
upsertProxy,
|
||||
deleteProxyById,
|
||||
getProxyAssignments,
|
||||
getProxyWhereUsed,
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
Reference in New Issue
Block a user