Skip to content
Closed
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
51 changes: 30 additions & 21 deletions open-sse/utils/proxyFetch.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@

const isCloud = typeof caches !== "undefined" && typeof caches === "object";

const originalFetch = globalThis.fetch;
let proxyAgent = null;
let socksAgent = null;
let proxyDispatcher = null;
let proxyDispatcherUrl = null;

/**
* Get proxy URL from environment
Expand Down Expand Up @@ -36,37 +35,47 @@ function getProxyUrl(targetUrl) {
}

/**
* Create proxy agent lazily
* Normalize proxy URL (allow host:port)
*/
async function getAgent(proxyUrl) {
const proxyProtocol = new URL(proxyUrl).protocol;

if (proxyProtocol === "socks:" || proxyProtocol === "socks5:" || proxyProtocol === "socks4:") {
if (!socksAgent) {
const { SocksProxyAgent } = await import("socks-proxy-agent");
socksAgent = new SocksProxyAgent(proxyUrl);
}
return socksAgent;
function normalizeProxyUrl(proxyUrl) {
if (!proxyUrl) return null;
try {
// eslint-disable-next-line no-new
new URL(proxyUrl);
return proxyUrl;
} catch {
// Allow "127.0.0.1:7890" style values
return `http://${proxyUrl}`;
}

if (!proxyAgent) {
const { HttpsProxyAgent } = await import("https-proxy-agent");
proxyAgent = new HttpsProxyAgent(proxyUrl);
}

/**
* Create proxy dispatcher lazily (undici-compatible)
*/
async function getDispatcher(proxyUrl) {
const normalized = normalizeProxyUrl(proxyUrl);
if (!normalized) return null;

if (!proxyDispatcher || proxyDispatcherUrl !== normalized) {
const { ProxyAgent } = await import("undici");
proxyDispatcher = new ProxyAgent({ uri: normalized });
proxyDispatcherUrl = normalized;
}
return proxyAgent;

return proxyDispatcher;
}

/**
* Patched fetch with proxy support and fallback to direct connection
*/
async function patchedFetch(url, options = {}) {
const targetUrl = typeof url === "string" ? url : url.toString();
const proxyUrl = getProxyUrl(targetUrl);
const proxyUrl = normalizeProxyUrl(getProxyUrl(targetUrl));

if (proxyUrl) {
try {
const agent = await getAgent(proxyUrl);
return await originalFetch(url, { ...options, dispatcher: agent });
const dispatcher = await getDispatcher(proxyUrl);
return await originalFetch(url, { ...options, dispatcher });
} catch (proxyError) {
// Fallback to direct connection if proxy fails
console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${proxyError.message}`);
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"express": "^5.2.1",
"fs": "^0.0.1-security",
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^7.0.6",
"jose": "^6.1.3",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
Expand Down
181 changes: 181 additions & 0 deletions src/app/(dashboard)/dashboard/profile/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,25 @@ export default function ProfilePage() {
const [dbLoading, setDbLoading] = useState(false);
const [dbStatus, setDbStatus] = useState({ type: "", message: "" });
const importFileRef = useRef(null);
const [proxyForm, setProxyForm] = useState({
outboundProxyEnabled: false,
outboundProxyUrl: "",
outboundNoProxy: "",
});
const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" });
const [proxyLoading, setProxyLoading] = useState(false);
const [proxyTestLoading, setProxyTestLoading] = useState(false);

useEffect(() => {
fetch("/api/settings")
.then((res) => res.json())
.then((data) => {
setSettings(data);
setProxyForm({
outboundProxyEnabled: data?.outboundProxyEnabled === true,
outboundProxyUrl: data?.outboundProxyUrl || "",
outboundNoProxy: data?.outboundNoProxy || "",
});
setLoading(false);
})
.catch((err) => {
Expand All @@ -30,6 +43,103 @@ export default function ProfilePage() {
});
}, []);

const updateOutboundProxy = async (e) => {
e.preventDefault();
if (settings.outboundProxyEnabled !== true) return;
setProxyLoading(true);
setProxyStatus({ type: "", message: "" });

try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
outboundProxyUrl: proxyForm.outboundProxyUrl,
outboundNoProxy: proxyForm.outboundNoProxy,
}),
});

const data = await res.json();
if (res.ok) {
setSettings((prev) => ({ ...prev, ...data }));
setProxyStatus({ type: "success", message: "Proxy settings applied" });
} else {
setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" });
}
} catch (err) {
setProxyStatus({ type: "error", message: "An error occurred" });
} finally {
setProxyLoading(false);
}
};

const testOutboundProxy = async () => {
if (settings.outboundProxyEnabled !== true) return;

const proxyUrl = (proxyForm.outboundProxyUrl || "").trim();
if (!proxyUrl) {
setProxyStatus({ type: "error", message: "Please enter a Proxy URL to test" });
return;
}

setProxyTestLoading(true);
setProxyStatus({ type: "", message: "" });

try {
const res = await fetch("/api/settings/proxy-test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ proxyUrl }),
});

const data = await res.json();
if (res.ok && data?.ok) {
setProxyStatus({
type: "success",
message: `Proxy test OK (${data.status}) in ${data.elapsedMs}ms`,
});
} else {
setProxyStatus({
type: "error",
message: data?.error || "Proxy test failed",
});
}
} catch (err) {
setProxyStatus({ type: "error", message: "An error occurred" });
} finally {
setProxyTestLoading(false);
}
};

const updateOutboundProxyEnabled = async (outboundProxyEnabled) => {
setProxyLoading(true);
setProxyStatus({ type: "", message: "" });

try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ outboundProxyEnabled }),
});

const data = await res.json();
if (res.ok) {
setSettings((prev) => ({ ...prev, ...data }));
setProxyForm((prev) => ({ ...prev, outboundProxyEnabled: data?.outboundProxyEnabled === true }));
setProxyStatus({
type: "success",
message: outboundProxyEnabled ? "Proxy enabled" : "Proxy disabled",
});
} else {
setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" });
}
} catch (err) {
setProxyStatus({ type: "error", message: "An error occurred" });
} finally {
setProxyLoading(false);
}
};

const handlePasswordChange = async (e) => {
e.preventDefault();
if (passwords.new !== passwords.confirm) {
Expand Down Expand Up @@ -379,6 +489,77 @@ export default function ProfilePage() {
</div>
</Card>

{/* Network */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-purple-500/10 text-purple-500">
<span className="material-symbols-outlined text-[20px]">wifi</span>
</div>
<h3 className="text-lg font-semibold">Network</h3>
</div>

<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Outbound Proxy</p>
<p className="text-sm text-text-muted">Enable proxy for OAuth + provider outbound requests.</p>
</div>
<Toggle
checked={settings.outboundProxyEnabled === true}
onChange={() => updateOutboundProxyEnabled(!(settings.outboundProxyEnabled === true))}
disabled={loading || proxyLoading}
/>
</div>

{settings.outboundProxyEnabled === true && (
<form onSubmit={updateOutboundProxy} className="flex flex-col gap-4 pt-2 border-t border-border/50">
<div className="flex flex-col gap-2">
<label className="font-medium">Proxy URL</label>
<Input
placeholder="http://127.0.0.1:7897"
value={proxyForm.outboundProxyUrl}
onChange={(e) => setProxyForm((prev) => ({ ...prev, outboundProxyUrl: e.target.value }))}
disabled={loading || proxyLoading}
/>
<p className="text-sm text-text-muted">Leave empty to inherit existing env proxy (if any).</p>
</div>

<div className="flex flex-col gap-2 pt-2 border-t border-border/50">
<label className="font-medium">No Proxy</label>
<Input
placeholder="localhost,127.0.0.1"
value={proxyForm.outboundNoProxy}
onChange={(e) => setProxyForm((prev) => ({ ...prev, outboundNoProxy: e.target.value }))}
disabled={loading || proxyLoading}
/>
<p className="text-sm text-text-muted">Comma-separated hostnames/domains to bypass the proxy.</p>
</div>

<div className="pt-2 border-t border-border/50 flex items-center gap-2">
<Button
type="button"
variant="secondary"
loading={proxyTestLoading}
disabled={loading || proxyLoading}
onClick={testOutboundProxy}
>
Test proxy URL
</Button>
<Button type="submit" variant="primary" loading={proxyLoading}>
Apply
</Button>
</div>
</form>
)}

{proxyStatus.message && (
<p className={`text-sm ${proxyStatus.type === "error" ? "text-red-500" : "text-green-500"} pt-2 border-t border-border/50`}>
{proxyStatus.message}
</p>
)}
</div>
</Card>

{/* Theme Preferences */}
<Card>
<div className="flex items-center gap-3 mb-4">
Expand Down
12 changes: 11 additions & 1 deletion src/app/api/settings/database/route.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { exportDb, importDb } from "@/lib/localDb";
import { exportDb, getSettings, importDb } from "@/lib/localDb";
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";

export async function GET() {
try {
Expand All @@ -15,6 +16,15 @@ export async function POST(request) {
try {
const payload = await request.json();
await importDb(payload);

// Ensure proxy settings take effect immediately after a DB import.
try {
const settings = await getSettings();
applyOutboundProxyEnv(settings);
} catch (err) {
console.warn("[Settings][DatabaseImport] Failed to re-apply outbound proxy env:", err);
}

return NextResponse.json({ success: true });
} catch (error) {
console.log("Error importing database:", error);
Expand Down
23 changes: 23 additions & 0 deletions src/app/api/settings/proxy-test/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { testProxyUrl } from "@/lib/network/proxyTest";

export async function POST(request) {
try {
const body = await request.json();
const result = await testProxyUrl({
proxyUrl: body?.proxyUrl,
testUrl: body?.testUrl,
timeoutMs: body?.timeoutMs,
});

if (result?.ok) {
return NextResponse.json(result);
}

const status = typeof result?.status === "number" ? result.status : 500;
return NextResponse.json({ ok: false, error: result?.error || "Proxy test failed" }, { status });
} catch (err) {
const message = err?.name === "AbortError" ? "Proxy test timed out" : (err?.message || String(err));
return NextResponse.json({ ok: false, error: message }, { status: 500 });
}
}
10 changes: 10 additions & 0 deletions src/app/api/settings/route.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
import bcrypt from "bcryptjs";

export async function GET() {
Expand Down Expand Up @@ -53,6 +54,15 @@ export async function PATCH(request) {
}

const settings = await updateSettings(body);

// Apply outbound proxy settings immediately (no restart required)
if (
Object.prototype.hasOwnProperty.call(body, "outboundProxyEnabled") ||
Object.prototype.hasOwnProperty.call(body, "outboundProxyUrl") ||
Object.prototype.hasOwnProperty.call(body, "outboundNoProxy")
) {
applyOutboundProxyEnv(settings);
}
const { password, ...safeSettings } = settings;
return NextResponse.json(safeSettings);
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions src/app/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/shared/components/ThemeProvider";
import "@/lib/initCloudSync"; // Auto-initialize cloud sync
import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env

const inter = Inter({
subsets: ["latin"],
Expand Down
File renamed without changes.
Loading