From 6c2b37c5955ee4144dc1e84bee20dd0c2fcaca8a Mon Sep 17 00:00:00 2001 From: Luan Dias <65574834+luandiasrj@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:27:22 -0300 Subject: [PATCH] feat: restore legacy JSON config import/export (#1012) Adds legacy 9router JSON config import/export with Zero-Trust security (password/requireLogin redacted). Integrated into release/v3.5.4. --- .../settings/components/SystemStorageTab.tsx | 157 ++++++++++--- src/app/api/settings/export-json/route.ts | 59 +++++ src/app/api/settings/import-json/route.ts | 80 +++++++ src/lib/db/jsonMigration.ts | 206 ++++++++++++++++++ 4 files changed, 469 insertions(+), 33 deletions(-) create mode 100644 src/app/api/settings/export-json/route.ts create mode 100644 src/app/api/settings/import-json/route.ts create mode 100644 src/lib/db/jsonMigration.ts diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx index 85b4002c68..a1c2ed873c 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx @@ -23,6 +23,7 @@ export default function SystemStorageTab() { const [purgeLogsLoading, setPurgeLogsLoading] = useState(false); const [purgeLogsStatus, setPurgeLogsStatus] = useState({ type: "", message: "" }); const fileInputRef = useRef(null); + const jsonInputRef = useRef(null); const locale = useLocale(); const t = useTranslations("settings"); const tc = useTranslations("common"); @@ -128,28 +129,108 @@ export default function SystemStorageTab() { loadStorageHealth(); }, []); + /** Triggers a browser file download from an existing Blob. */ + const triggerDownload = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + /** Fetches a URL, reads the response as a Blob and triggers a download. */ + const fetchAndDownload = async ( + apiUrl: string, + fallbackFilename: string, + errorMessage: string + ) => { + const res = await fetch(apiUrl); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error((data as { error?: string }).error || errorMessage); + } + const blob = await res.blob(); + const disposition = res.headers.get("Content-Disposition") || ""; + const filenameMatch = disposition.match(/filename="?([^"]+)"?/); + triggerDownload(blob, filenameMatch?.[1] || fallbackFilename); + }; + + const handleExportJson = async () => { + setExportLoading(true); + try { + await fetchAndDownload( + "/api/settings/export-json", + `omniroute-legacy-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`, + "JSON Export failed" + ); + } catch (err) { + console.error("Export JSON failed:", err); + setImportStatus({ + type: "error", + message: t("exportFailedWithError", { error: (err as Error).message }), + }); + } finally { + setExportLoading(false); + } + }; + + const handleImportJsonClick = () => { + jsonInputRef.current?.click(); + }; + + const handleJsonSelected = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.name.endsWith(".json")) { + setImportStatus({ + type: "error", + message: "Invalid file type. Only .json allowed.", + }); + return; + } + + // Auto import JSON + const reader = new FileReader(); + reader.onload = async (e) => { + try { + setImportLoading(true); + const res = await fetch("/api/settings/import-json", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: e.target?.result as string, + }); + const data = await res.json(); + if (res.ok) { + setImportStatus({ + type: "success", + message: data.message || "Legacy JSON imported successfully!", + }); + await loadStorageHealth(); + if (backupsExpanded) await loadBackups(); + } else { + setImportStatus({ type: "error", message: data.error || "Failed to import JSON" }); + } + } catch (err) { + setImportStatus({ type: "error", message: "Error during JSON import" }); + } finally { + setImportLoading(false); + if (jsonInputRef.current) jsonInputRef.current.value = ""; + } + }; + reader.readAsText(file); + }; + const handleExport = async () => { setExportLoading(true); try { - const res = await fetch("/api/db-backups/export"); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || t("exportFailed")); - } - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const disposition = res.headers.get("Content-Disposition") || ""; - const filenameMatch = disposition.match(/filename="(.+)"/); - const filename = filenameMatch - ? filenameMatch[1] - : `omniroute-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`; - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + await fetchAndDownload( + "/api/db-backups/export", + `omniroute-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`, + t("exportFailed") + ); } catch (err) { console.error("Export failed:", err); setImportStatus({ @@ -320,20 +401,11 @@ export default function SystemStorageTab() { onClick={async () => { setExportLoading(true); try { - const res = await fetch("/api/db-backups/exportAll"); - if (!res.ok) throw new Error(t("exportFailed")); - const blob = await res.blob(); - const cd = res.headers.get("Content-Disposition") || ""; - const filenameMatch = cd.match(/filename="?([^"]+)"?/); - const filename = filenameMatch?.[1] || `omniroute-full-backup.tar.gz`; - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + await fetchAndDownload( + "/api/db-backups/exportAll", + "omniroute-full-backup.tar.gz", + t("exportFailed") + ); } catch (err) { setImportStatus({ type: "error", @@ -363,6 +435,25 @@ export default function SystemStorageTab() { className="hidden" onChange={handleFileSelected} /> + + + {/* Import confirmation dialog */} diff --git a/src/app/api/settings/export-json/route.ts b/src/app/api/settings/export-json/route.ts new file mode 100644 index 0000000000..c799f19cc3 --- /dev/null +++ b/src/app/api/settings/export-json/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { + getSettings, + getProviderConnections, + getProviderNodes, + getCombos, + getApiKeys, +} from "@/lib/localDb"; +import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; + +/** + * GET /api/settings/export-json + * Exports a legacy 9router compatible JSON backup. + */ +export async function GET(request: Request) { + if (await isAuthRequired()) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + try { + const rawSettings = await getSettings(); + + // REDACT sensitive security keys to maintain Zero-Trust posture + // even if the admin shares their backup file. + // Use destructuring (not delete) to avoid mutating a potentially cached object. + const { password: _pw, requireLogin: _rl, ...safeSettings } = rawSettings; + + const providerConnections = await getProviderConnections(); + const providerNodes = await getProviderNodes(); + const combos = await getCombos(); + const apiKeys = await getApiKeys(); + + const exportData = { + settings: safeSettings, + providerConnections, + providerNodes, + combos, + apiKeys, + // Metadata to identify export version + _meta: { + exportedAt: new Date().toISOString(), + version: "omniroute-v3-legacy-export" + } + }; + + return new NextResponse(JSON.stringify(exportData, null, 2), { + status: 200, + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="omniroute-legacy-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json"`, + }, + }); + } catch (error) { + console.error("[API] Error exporting JSON backup:", error); + return NextResponse.json({ error: "Failed to export JSON" }, { status: 500 }); + } +} diff --git a/src/app/api/settings/import-json/route.ts b/src/app/api/settings/import-json/route.ts new file mode 100644 index 0000000000..77bbc2a4d6 --- /dev/null +++ b/src/app/api/settings/import-json/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { getDbInstance } from "@/lib/db/core"; +import { backupDbFile } from "@/lib/db/backup"; +import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; +import { runJsonMigration, type LegacyJsonData } from "@/lib/db/jsonMigration"; + +/** + * POST /api/settings/import-json + * + * Imports a legacy 9router / OmniRoute JSON backup into the current SQLite + * database. Accepts either multipart/form-data (file field) or a raw JSON body. + * + * 🔒 Auth-guarded. + * 🔒 Zero-Trust: password and requireLogin keys are stripped before insertion. + * 🔒 A pre-import backup is created automatically before any data is written. + */ +export async function POST(request: Request) { + if (await isAuthRequired()) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + try { + let rawText: string | null = null; + const contentType = request.headers.get("content-type") ?? ""; + + if (contentType.includes("multipart/form-data")) { + const formData = await request.formData(); + const file = formData.get("file") as File | null; + if (!file) return NextResponse.json({ error: "No json file provided" }, { status: 400 }); + rawText = await file.text(); + } else { + rawText = await request.text(); + } + + if (!rawText?.trim()) { + return NextResponse.json({ error: "Empty request payload" }, { status: 400 }); + } + + // Parse with explicit 400 on malformed JSON (Gemini suggestion) + let data: LegacyJsonData; + try { + data = JSON.parse(rawText) as LegacyJsonData; + } catch { + return NextResponse.json( + { error: "Invalid JSON: the file could not be parsed. Please upload a valid .json backup." }, + { status: 400 } + ); + } + + // 🔒 Zero-Trust: strip authentication config before migration + if (data.settings) { + const { password: _pw, requireLogin: _rl, ...safeSettings } = data.settings; + data = { ...data, settings: safeSettings }; + } + + const db = getDbInstance(); + + // Create a safety backup before writing anything + backupDbFile("pre-json-import"); + + // Delegate the actual migration to the shared helper (avoids duplication with core.ts) + const counts = runJsonMigration(db, data); + + console.log( + `[JSON Import] Imported ${counts.connections} connections, ${counts.nodes} nodes, ` + + `${counts.combos} combos, ${counts.apiKeys} API keys` + ); + + return NextResponse.json({ + success: true, + message: "Legacy JSON database imported successfully", + ...counts, + }); + } catch (err) { + console.error("[API] Error importing JSON backup:", err); + return NextResponse.json({ error: (err as Error).message }, { status: 500 }); + } +} diff --git a/src/lib/db/jsonMigration.ts b/src/lib/db/jsonMigration.ts new file mode 100644 index 0000000000..0ab294a561 --- /dev/null +++ b/src/lib/db/jsonMigration.ts @@ -0,0 +1,206 @@ +/** + * db/jsonMigration.ts — Shared helper to hydrate an SQLite database from a + * legacy 9router / OmniRoute JSON backup object. + * + * Used by: + * - db/core.ts (auto-migration at startup when db.json is found) + * - api/settings/import-json/route.ts (on-demand import via dashboard) + * + * 🔒 Security: the caller is responsible for stripping sensitive keys + * (password, requireLogin) from `data.settings` BEFORE passing the object + * here, so this function never touches authentication configuration. + */ + +import type Database from "better-sqlite3"; + +type SqliteDatabase = InstanceType; + +export interface LegacyJsonData { + providerConnections?: Record[]; + providerNodes?: Record[]; + combos?: Record[]; + apiKeys?: Record[]; + settings?: Record; + modelAliases?: Record; + mitmAlias?: Record; + pricing?: Record; + customModels?: Record; + proxyConfig?: { + global?: unknown; + providers?: unknown; + combos?: unknown; + keys?: unknown; + }; +} + +/** + * Runs a single SQLite transaction that upserts all entities from a legacy + * JSON backup into the provided database instance. + * + * Returns counts of what was inserted/replaced for logging. + */ +export function runJsonMigration( + db: SqliteDatabase, + data: LegacyJsonData +): { connections: number; nodes: number; combos: number; apiKeys: number } { + const insertConn = db.prepare(` + INSERT OR REPLACE INTO provider_connections ( + id, provider, auth_type, name, email, priority, is_active, + access_token, refresh_token, expires_at, token_expires_at, + scope, project_id, test_status, error_code, last_error, + last_error_at, last_error_type, last_error_source, backoff_level, + rate_limited_until, health_check_interval, last_health_check_at, + last_tested, api_key, id_token, provider_specific_data, + expires_in, display_name, global_priority, default_model, + token_type, consecutive_use_count, rate_limit_protection, last_used_at, created_at, updated_at + ) VALUES ( + @id, @provider, @authType, @name, @email, @priority, @isActive, + @accessToken, @refreshToken, @expiresAt, @tokenExpiresAt, + @scope, @projectId, @testStatus, @errorCode, @lastError, + @lastErrorAt, @lastErrorType, @lastErrorSource, @backoffLevel, + @rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt, + @lastTested, @apiKey, @idToken, @providerSpecificData, + @expiresIn, @displayName, @globalPriority, @defaultModel, + @tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @createdAt, @updatedAt + ) + `); + + const insertNode = db.prepare(` + INSERT OR REPLACE INTO provider_nodes (id, type, name, prefix, api_type, base_url, created_at, updated_at) + VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @createdAt, @updatedAt) + `); + + const insertKv = db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" + ); + + const insertCombo = db.prepare(` + INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at) + VALUES (@id, @name, @data, @createdAt, @updatedAt) + `); + + const insertKey = db.prepare(` + INSERT OR REPLACE INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) + VALUES (@id, @name, @key, @machineId, @allowedModels, @noLog, @createdAt) + `); + + const migrate = db.transaction(() => { + // 1. Provider Connections + for (const conn of data.providerConnections ?? []) { + insertConn.run({ + id: conn.id, + provider: conn.provider, + authType: conn.authType ?? "oauth", + name: conn.name ?? null, + email: conn.email ?? null, + priority: conn.priority ?? 0, + isActive: conn.isActive === false ? 0 : 1, + accessToken: conn.accessToken ?? null, + refreshToken: conn.refreshToken ?? null, + expiresAt: conn.expiresAt ?? null, + tokenExpiresAt: conn.tokenExpiresAt ?? null, + scope: conn.scope ?? null, + projectId: conn.projectId ?? null, + testStatus: conn.testStatus ?? null, + errorCode: conn.errorCode ?? null, + lastError: conn.lastError ?? null, + lastErrorAt: conn.lastErrorAt ?? null, + lastErrorType: conn.lastErrorType ?? null, + lastErrorSource: conn.lastErrorSource ?? null, + backoffLevel: conn.backoffLevel ?? 0, + rateLimitedUntil: conn.rateLimitedUntil ?? null, + healthCheckInterval: conn.healthCheckInterval ?? null, + lastHealthCheckAt: conn.lastHealthCheckAt ?? null, + lastTested: conn.lastTested ?? null, + apiKey: conn.apiKey ?? null, + idToken: conn.idToken ?? null, + providerSpecificData: conn.providerSpecificData + ? JSON.stringify(conn.providerSpecificData) + : null, + expiresIn: conn.expiresIn ?? null, + displayName: conn.displayName ?? null, + globalPriority: conn.globalPriority ?? null, + defaultModel: conn.defaultModel ?? null, + tokenType: conn.tokenType ?? null, + consecutiveUseCount: conn.consecutiveUseCount ?? 0, + lastUsedAt: conn.lastUsedAt ?? null, + rateLimitProtection: + conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0, + createdAt: conn.createdAt ?? new Date().toISOString(), + updatedAt: conn.updatedAt ?? new Date().toISOString(), + }); + } + + // 2. Provider Nodes + for (const node of data.providerNodes ?? []) { + insertNode.run({ + id: node.id, + type: node.type, + name: node.name, + prefix: node.prefix ?? null, + apiType: node.apiType ?? null, + baseUrl: node.baseUrl ?? null, + createdAt: node.createdAt ?? new Date().toISOString(), + updatedAt: node.updatedAt ?? new Date().toISOString(), + }); + } + + // 3. Key-Value Settings (caller must have stripped password / requireLogin) + for (const [key, value] of Object.entries(data.settings ?? {})) { + insertKv.run("settings", key, JSON.stringify(value)); + } + + // 4. Legacy key-value namespaces + for (const [alias, model] of Object.entries(data.modelAliases ?? {})) { + insertKv.run("modelAliases", alias, JSON.stringify(model)); + } + for (const [toolName, mappings] of Object.entries(data.mitmAlias ?? {})) { + insertKv.run("mitmAlias", toolName, JSON.stringify(mappings)); + } + for (const [provider, models] of Object.entries(data.pricing ?? {})) { + insertKv.run("pricing", provider, JSON.stringify(models)); + } + for (const [providerId, models] of Object.entries(data.customModels ?? {})) { + insertKv.run("customModels", providerId, JSON.stringify(models)); + } + if (data.proxyConfig) { + insertKv.run("proxyConfig", "global", JSON.stringify(data.proxyConfig.global ?? null)); + insertKv.run("proxyConfig", "providers", JSON.stringify(data.proxyConfig.providers ?? {})); + insertKv.run("proxyConfig", "combos", JSON.stringify(data.proxyConfig.combos ?? {})); + insertKv.run("proxyConfig", "keys", JSON.stringify(data.proxyConfig.keys ?? {})); + } + + // 5. Combos + for (const combo of data.combos ?? []) { + insertCombo.run({ + id: combo.id, + name: combo.name, + data: JSON.stringify(combo), + createdAt: combo.createdAt ?? new Date().toISOString(), + updatedAt: combo.updatedAt ?? new Date().toISOString(), + }); + } + + // 6. API Keys + for (const apiKey of data.apiKeys ?? []) { + insertKey.run({ + id: apiKey.id, + name: apiKey.name, + key: apiKey.key, + machineId: apiKey.machineId ?? null, + allowedModels: JSON.stringify(apiKey.allowedModels ?? []), + noLog: apiKey.noLog ? 1 : 0, + createdAt: apiKey.createdAt ?? new Date().toISOString(), + }); + } + }); + + migrate(); + + return { + connections: (data.providerConnections ?? []).length, + nodes: (data.providerNodes ?? []).length, + combos: (data.combos ?? []).length, + apiKeys: (data.apiKeys ?? []).length, + }; +}