Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions shared/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,13 @@ export const translations = {
generalSettingsAutoDownloadHint: "Download updates silently without asking. If off, a dialog will prompt before downloading.",
proxyExport: "Export",
proxyImport: "Import",
proxyImportTitle: "Import Proxies (YAML)",
proxyImportPlaceholder: "Paste YAML here...\n\n- name: my-proxy\n url: socks5://1.2.3.4:1080",
proxyImportTextTab: "Plain Text",
proxyImportYamlTab: "YAML",
proxyImportTextPlaceholder: "One proxy per line:\nhost:username:password\nhost:port:username:password",
proxyImportYamlPlaceholder: "- name: my-proxy\n url: socks5://user:pass@1.2.3.4:1080",
proxyImportTextTemplate: "# host:user:pass or host:port:user:pass\n127.0.0.1:username:password\n10.0.0.1:1080:username:password",
proxyImportYamlTemplate: "- name: proxy-1\n url: http://user:pass@1.2.3.4:8080\n- name: proxy-2\n url: socks5://user:pass@5.6.7.8:1080",
proxyImportFillTemplate: "Template",
proxyImportSuccess: "Imported {count} proxies",
proxyImportError: "Import failed",
showMore: "Show More",
Expand Down Expand Up @@ -570,8 +575,13 @@ export const translations = {
generalSettingsAutoDownloadHint: "发现新版本后静默下载。关闭则会弹窗询问。",
proxyExport: "导出",
proxyImport: "导入",
proxyImportTitle: "导入代理 (YAML)",
proxyImportPlaceholder: "粘贴 YAML...\n\n- name: my-proxy\n url: socks5://1.2.3.4:1080",
proxyImportTextTab: "纯文本",
proxyImportYamlTab: "YAML",
proxyImportTextPlaceholder: "每行一个代理:\n主机:用户名:密码\n主机:端口:用户名:密码",
proxyImportYamlPlaceholder: "- name: my-proxy\n url: socks5://user:pass@1.2.3.4:1080",
proxyImportTextTemplate: "# 主机:用户:密码 或 主机:端口:用户:密码\n127.0.0.1:username:password\n10.0.0.1:1080:username:password",
proxyImportYamlTemplate: "- name: proxy-1\n url: http://user:pass@1.2.3.4:8080\n- name: proxy-2\n url: socks5://user:pass@5.6.7.8:1080",
proxyImportFillTemplate: "模板",
proxyImportSuccess: "已导入 {count} 个代理",
proxyImportError: "导入失败",
showMore: "显示更多",
Expand Down
78 changes: 76 additions & 2 deletions src/routes/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* PUT /api/proxies/settings — update settings { healthCheckIntervalMinutes }
*/

import { Hono } from "hono";
import { Hono, type Context } from "hono";
import yaml from "js-yaml";
import type { ProxyPool } from "../proxy/proxy-pool.js";
import type { AccountPool } from "../auth/account-pool.js";
Expand Down Expand Up @@ -368,9 +368,17 @@ export function createProxyRoutes(proxyPool: ProxyPool, accountPool: AccountPool
});
});

// --- Import proxies from YAML ---
// --- Import proxies from YAML or plain text ---
app.post("/api/proxies/import", async (c) => {
const rawBody = await c.req.text();
const contentType = c.req.header("content-type") ?? "";

// Explicit text/plain from frontend tab selection
if (contentType.includes("text/plain")) {
return importPlainTextProxies(c, rawBody, proxyPool);
}

// YAML path
let parsed: unknown;
try {
parsed = yaml.load(rawBody);
Expand Down Expand Up @@ -437,3 +445,69 @@ function composeProxyUrl(
const portSuffix = port ? `:${port}` : "";
return `${scheme}://${auth}${trimmedHost}${portSuffix}`;
}

const PROTOCOL_PREFIX_RE = /^(https?|socks5h?):\/\//;

/**
* Parse plain-text proxy list.
*
* Supported line formats:
* host:port → http://host:port
* host:username:password → http://username:password@host
* host:port:username:password → http://username:password@host:port
* protocol://host:port:user:pass → protocol://user:pass@host:port
*/
function importPlainTextProxies(c: Context, rawBody: string, proxyPool: ProxyPool): Response {
const added: string[] = [];
const errors: string[] = [];

for (const raw of rawBody.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;

// Strip optional protocol prefix
let scheme = "http";
let rest = line;
const protoMatch = PROTOCOL_PREFIX_RE.exec(line);
if (protoMatch) {
scheme = protoMatch[1];
rest = line.slice(protoMatch[0].length);
}

const parts = rest.split(":");
let url: string;

if (parts.length === 2) {
// host:port
const [host, port] = parts;
if (isNaN(parseInt(port, 10))) {
errors.push(`Invalid port in host:port format: ${line}`);
continue;
}
url = `${scheme}://${host}:${port}`;
} else if (parts.length === 3) {
// host:username:password
const [host, username, password] = parts;
url = composeProxyUrl(scheme, host, undefined, username, password);
} else if (parts.length >= 4) {
// host:port:username:password (password may contain colons)
const host = parts[0];
const port = parts[1];
const username = parts[2];
const password = parts.slice(3).join(":");
url = composeProxyUrl(scheme, host, port, username, password);
} else {
errors.push(`Invalid format (expected host:port or host:user:pass): ${line}`);
continue;
}

const id = proxyPool.add(line, url);
added.push(id);
}

if (added.length > 0) {
proxyPool.startHealthCheckTimer();
}

return c.json({ success: errors.length === 0, added: added.length, errors });
}
55 changes: 39 additions & 16 deletions web/src/components/ProxyPool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export function ProxyPool({ proxies }: ProxyPoolProps) {
const [checking, setChecking] = useState<string | null>(null);
const [checkingAll, setCheckingAll] = useState(false);
const [showImport, setShowImport] = useState(false);
const [importYaml, setImportYaml] = useState("");
const [importMode, setImportMode] = useState<"text" | "yaml">("text");
const [importText, setImportText] = useState("");
const [importStatus, setImportStatus] = useState<string | null>(null);
const [importing, setImporting] = useState(false);

Expand Down Expand Up @@ -116,21 +117,23 @@ export function ProxyPool({ proxies }: ProxyPoolProps) {
}, []);

const handleImport = useCallback(async () => {
if (!importYaml.trim()) return;
if (!importText.trim()) return;
setImporting(true);
setImportStatus(null);
try {
const resp = await fetch("/api/proxies/import", {
method: "POST",
headers: { "Content-Type": "text/yaml" },
body: importYaml,
headers: { "Content-Type": importMode === "yaml" ? "text/yaml" : "text/plain" },
body: importText,
});
const data = await resp.json() as { success?: boolean; added?: number; error?: string };
const data = await resp.json() as { success?: boolean; added?: number; error?: string; errors?: string[] };
if (!resp.ok) {
setImportStatus(data.error ?? t("proxyImportError"));
} else {
setImportStatus(t("proxyImportSuccess").replace("{count}", String(data.added ?? 0)));
setImportYaml("");
const msg = t("proxyImportSuccess").replace("{count}", String(data.added ?? 0));
const errCount = data.errors?.length ?? 0;
setImportStatus(errCount > 0 ? `${msg} (${errCount} errors)` : msg);
setImportText("");
await proxies.refresh();
setTimeout(() => {
setShowImport(false);
Expand All @@ -142,7 +145,7 @@ export function ProxyPool({ proxies }: ProxyPoolProps) {
} finally {
setImporting(false);
}
}, [importYaml, proxies, t]);
}, [importText, importMode, proxies, t]);

return (
<section>
Expand Down Expand Up @@ -190,31 +193,51 @@ export function ProxyPool({ proxies }: ProxyPoolProps) {
{/* Import modal */}
{showImport && (
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 mb-4">
<h3 class="text-sm font-semibold mb-2">{t("proxyImportTitle")}</h3>
<div class="flex items-center gap-1 mb-3">
{(["text", "yaml"] as const).map((mode) => (
<button
key={mode}
onClick={() => { setImportMode(mode); setImportStatus(null); }}
class={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
importMode === mode
? "bg-primary text-white"
: "text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark"
}`}
>
{t(mode === "text" ? "proxyImportTextTab" : "proxyImportYamlTab")}
</button>
))}
</div>
<textarea
class={`${inputCls} w-full h-32 font-mono resize-y`}
value={importYaml}
onInput={(e) => setImportYaml((e.target as HTMLTextAreaElement).value)}
placeholder={t("proxyImportPlaceholder")}
value={importText}
onInput={(e) => setImportText((e.target as HTMLTextAreaElement).value)}
placeholder={t(importMode === "text" ? "proxyImportTextPlaceholder" : "proxyImportYamlPlaceholder")}
/>
<div class="flex items-center justify-between mt-3">
{importStatus && (
<p class={`text-xs ${importStatus.includes("fail") || importStatus.includes("error") ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
<p class={`text-xs ${importStatus.includes("fail") || importStatus.includes("error") || importStatus.includes("失败") ? "text-red-500" : "text-green-600 dark:text-green-400"}`}>
{importStatus}
</p>
)}
<div class="ml-auto flex items-center gap-2">
<button
onClick={() => { setShowImport(false); setImportYaml(""); setImportStatus(null); }}
onClick={() => { setShowImport(false); setImportText(""); setImportStatus(null); }}
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
>
{t("cancelBtn")}
</button>
<button
onClick={() => setImportText(t(importMode === "text" ? "proxyImportTextTemplate" : "proxyImportYamlTemplate"))}
class="px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
>
{t("proxyImportFillTemplate")}
</button>
<button
onClick={handleImport}
disabled={importing || !importYaml.trim()}
disabled={importing || !importText.trim()}
class={`px-4 py-1.5 text-xs font-medium rounded-lg transition-colors whitespace-nowrap ${
!importing && importYaml.trim()
!importing && importText.trim()
? "bg-primary text-white hover:bg-primary/90 cursor-pointer"
: "bg-slate-100 dark:bg-[#21262d] text-slate-400 dark:text-text-dim cursor-not-allowed"
}`}
Expand Down
Loading