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.
This commit is contained in:
Luan Dias
2026-04-07 17:27:22 -03:00
committed by GitHub
parent b338cc88fb
commit 6c2b37c595
4 changed files with 469 additions and 33 deletions

View File

@@ -23,6 +23,7 @@ export default function SystemStorageTab() {
const [purgeLogsLoading, setPurgeLogsLoading] = useState(false);
const [purgeLogsStatus, setPurgeLogsStatus] = useState({ type: "", message: "" });
const fileInputRef = useRef<HTMLInputElement>(null);
const jsonInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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}
/>
<Button variant="outline" size="sm" onClick={handleExportJson} loading={exportLoading}>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
data_object
</span>
Export JSON
</Button>
<Button variant="outline" size="sm" onClick={handleImportJsonClick} loading={importLoading}>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
data_object
</span>
Import JSON
</Button>
<input
ref={jsonInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleJsonSelected}
/>
</div>
{/* Import confirmation dialog */}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

206
src/lib/db/jsonMigration.ts Normal file
View File

@@ -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<typeof Database>;
export interface LegacyJsonData {
providerConnections?: Record<string, unknown>[];
providerNodes?: Record<string, unknown>[];
combos?: Record<string, unknown>[];
apiKeys?: Record<string, unknown>[];
settings?: Record<string, unknown>;
modelAliases?: Record<string, unknown>;
mitmAlias?: Record<string, unknown>;
pricing?: Record<string, unknown>;
customModels?: Record<string, unknown>;
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,
};
}