mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat(cloud): harden sync/auth flow, SSE fallback, and update changelog
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
decolua
co-authored by
Cursor
parent
2e854bd4c9
commit
3d439839d9
@@ -6,6 +6,7 @@ import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
const CLOUD_ACTION_TIMEOUT_MS = 15000;
|
||||
|
||||
export default function APIPageClient({ machineId }) {
|
||||
const [keys, setKeys] = useState([]);
|
||||
@@ -29,6 +30,28 @@ export default function APIPageClient({ machineId }) {
|
||||
loadCloudSettings();
|
||||
}, []);
|
||||
|
||||
const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch("/api/sync/cloud", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
return { ok: false, status: 408, data: { error: "Cloud request timeout" } };
|
||||
}
|
||||
return { ok: false, status: 500, data: { error: error.message || "Cloud request failed" } };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCloudSettings = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
@@ -67,14 +90,8 @@ export default function APIPageClient({ machineId }) {
|
||||
setCloudSyncing(true);
|
||||
setSyncStep("syncing");
|
||||
try {
|
||||
const res = await fetch("/api/sync/cloud", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "enable" })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const { ok, data } = await postCloudAction("enable");
|
||||
if (ok) {
|
||||
setSyncStep("verifying");
|
||||
|
||||
if (data.verified) {
|
||||
@@ -111,25 +128,19 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
try {
|
||||
// Step 1: Sync latest data from cloud
|
||||
await fetch("/api/sync/cloud", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "sync" })
|
||||
});
|
||||
await postCloudAction("sync");
|
||||
|
||||
setSyncStep("disabling");
|
||||
|
||||
// Step 2: Disable cloud
|
||||
const disableRes = await fetch("/api/sync/cloud", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "disable" })
|
||||
});
|
||||
const { ok, data } = await postCloudAction("disable");
|
||||
|
||||
if (disableRes.ok) {
|
||||
if (ok) {
|
||||
setCloudEnabled(false);
|
||||
setCloudStatus({ type: "success", message: "Cloud disabled" });
|
||||
setShowDisableModal(false);
|
||||
} else {
|
||||
setCloudStatus({ type: "error", message: data.error || "Failed to disable cloud" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error disabling cloud:", error);
|
||||
@@ -145,14 +156,8 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
setCloudSyncing(true);
|
||||
try {
|
||||
const res = await fetch("/api/sync/cloud", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "sync" })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
const { ok, data } = await postCloudAction("sync");
|
||||
if (ok) {
|
||||
setCloudStatus({ type: "success", message: "Synced successfully" });
|
||||
} else {
|
||||
setCloudStatus({ type: "error", message: data.error });
|
||||
@@ -599,4 +604,4 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
APIPageClient.propTypes = {
|
||||
machineId: PropTypes.string.isRequired,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,6 +26,11 @@ export async function POST(request) {
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
const forceSecureCookie = process.env.AUTH_COOKIE_SECURE === "true";
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto");
|
||||
const isHttpsRequest = forwardedProto === "https";
|
||||
const useSecureCookie = forceSecureCookie || isHttpsRequest;
|
||||
|
||||
const token = await new SignJWT({ authenticated: true })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("24h")
|
||||
@@ -34,7 +39,7 @@ export async function POST(request) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("auth_token", token, {
|
||||
httpOnly: true,
|
||||
secure: false, // Allow HTTP for local network access
|
||||
secure: useSecureCookie,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function POST(request) {
|
||||
try {
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
// return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKey = authHeader.slice(7);
|
||||
@@ -14,7 +14,7 @@ export async function POST(request) {
|
||||
// Validate API key
|
||||
const isValid = await validateApiKey(apiKey);
|
||||
if (!isValid) {
|
||||
// return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get active provider connections
|
||||
|
||||
@@ -5,7 +5,18 @@ import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
const CLOUD_URL = process.env.CLOUD_URL || process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
const CLOUD_SYNC_TIMEOUT_MS = Number(process.env.CLOUD_SYNC_TIMEOUT_MS || 12000);
|
||||
|
||||
async function fetchWithTimeout(url, options = {}, timeoutMs = CLOUD_SYNC_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sync/cloud
|
||||
@@ -54,28 +65,38 @@ export async function POST(request) {
|
||||
* @param {string|null} createdKey - Key created during enable
|
||||
*/
|
||||
export async function syncToCloud(machineId, createdKey = null) {
|
||||
if (!CLOUD_URL) {
|
||||
return { error: "NEXT_PUBLIC_CLOUD_URL is not configured" };
|
||||
}
|
||||
|
||||
// Get current data from db
|
||||
const providers = await getProviderConnections();
|
||||
const modelAliases = await getModelAliases();
|
||||
const combos = await getCombos();
|
||||
const apiKeys = await getApiKeys();
|
||||
|
||||
// Send to Cloud
|
||||
const response = await fetch(`${CLOUD_URL}/sync/${machineId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providers,
|
||||
modelAliases,
|
||||
combos,
|
||||
apiKeys
|
||||
})
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
// Send to Cloud
|
||||
response = await fetchWithTimeout(`${CLOUD_URL}/sync/${machineId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providers,
|
||||
modelAliases,
|
||||
combos,
|
||||
apiKeys
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
const isTimeout = error?.name === "AbortError";
|
||||
return { error: isTimeout ? "Cloud sync timeout" : "Cloud sync request failed" };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.log("Cloud sync failed:", errorText);
|
||||
return NextResponse.json({ error: "Cloud sync failed" }, { status: 502 });
|
||||
return { error: "Cloud sync failed" };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -119,7 +140,7 @@ async function syncAndVerify(machineId, createdKey, existingKeys) {
|
||||
}
|
||||
|
||||
try {
|
||||
const pingResponse = await fetch(`${CLOUD_URL}/${machineId}/v1/verify`, {
|
||||
const pingResponse = await fetchWithTimeout(`${CLOUD_URL}/${machineId}/v1/verify`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
@@ -152,9 +173,22 @@ async function syncAndVerify(machineId, createdKey, existingKeys) {
|
||||
* Disable Cloud - delete cache and update Claude CLI settings
|
||||
*/
|
||||
async function handleDisable(machineId, request) {
|
||||
const response = await fetch(`${CLOUD_URL}/sync/${machineId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
if (!CLOUD_URL) {
|
||||
return NextResponse.json({ error: "NEXT_PUBLIC_CLOUD_URL is not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetchWithTimeout(`${CLOUD_URL}/sync/${machineId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
} catch (error) {
|
||||
const isTimeout = error?.name === "AbortError";
|
||||
return NextResponse.json(
|
||||
{ error: isTimeout ? "Cloud disable timeout" : "Failed to reach cloud service" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { isCloudEnabled } from "@/lib/localDb";
|
||||
|
||||
const INTERNAL_BASE_URL =
|
||||
process.env.BASE_URL ||
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
"http://localhost:20128";
|
||||
|
||||
/**
|
||||
* Cloud sync scheduler
|
||||
*/
|
||||
@@ -83,7 +88,7 @@ export class CloudSyncScheduler {
|
||||
await this.initializeMachineId();
|
||||
|
||||
// Call internal API route which handles both sync and token update
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"}/api/sync/cloud`, {
|
||||
const response = await fetch(`${INTERNAL_BASE_URL}/api/sync/cloud`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machineId: this.machineId, action: "sync" })
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { getProviderCredentials, markAccountUnavailable, clearAccountError } from "../services/auth.js";
|
||||
import {
|
||||
getProviderCredentials,
|
||||
markAccountUnavailable,
|
||||
clearAccountError,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "../services/auth.js";
|
||||
import { getModelInfo, getComboModels } from "../services/model.js";
|
||||
import { handleChatCore } from "open-sse/handlers/chatCore.js";
|
||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||
@@ -42,14 +48,29 @@ export async function handleChat(request, clientRawRequest = null) {
|
||||
log.request("POST", `${url.pathname} | ${modelStr} | ${msgCount} msgs${toolCount ? ` | ${toolCount} tools` : ""}${effort ? ` | effort=${effort}` : ""}`);
|
||||
|
||||
// Log API key (masked)
|
||||
const apiKey = request.headers.get("Authorization");
|
||||
if (apiKey) {
|
||||
const masked = log.maskKey(apiKey.replace("Bearer ", ""));
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const apiKey = extractApiKey(request);
|
||||
if (authHeader && apiKey) {
|
||||
const masked = log.maskKey(apiKey);
|
||||
log.debug("AUTH", `API Key: ${masked}`);
|
||||
} else {
|
||||
log.debug("AUTH", "No API key provided (local mode)");
|
||||
}
|
||||
|
||||
// Optional strict API key mode for /v1 endpoints.
|
||||
// Keep disabled by default to preserve local-mode compatibility.
|
||||
if (process.env.REQUIRE_API_KEY === "true") {
|
||||
if (!apiKey) {
|
||||
log.warn("AUTH", "Missing API key while REQUIRE_API_KEY=true");
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
||||
}
|
||||
const valid = await isValidApiKey(apiKey);
|
||||
if (!valid) {
|
||||
log.warn("AUTH", "Invalid API key while REQUIRE_API_KEY=true");
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
}
|
||||
|
||||
if (!modelStr) {
|
||||
log.warn("CHAT", "Missing model");
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
|
||||
|
||||
Reference in New Issue
Block a user