Spaces:
Runtime error
Runtime error
File size: 7,799 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | import { NextResponse } from "next/server";
import path from "path";
import fs from "fs";
import os from "os";
import { getDbInstance, resetDbInstance, SQLITE_FILE } from "@/lib/db/core";
import { openDatabaseAsync } from "@/lib/db/adapters/driverFactory";
import type { SqliteAdapter } from "@/lib/db/adapters/types";
import { backupDbFile, getTableNamesFromAdapter, countImportedRows } from "@/lib/db/backup";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { getSettings } from "@/lib/db/settings";
import { setSystemPromptConfig } from "@omniroute/open-sse/services/systemPrompt.ts";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
const DEFAULT_MAX_UPLOAD_MB = 100;
// Hard ceiling so a misconfigured/hostile value can't ask the route to buffer an
// unbounded file into memory.
const MAX_UPLOAD_MB_CEILING = 4096;
/**
* Resolve the maximum accepted backup size (bytes) from the environment.
*
* Real databases bloat well past the historical 100 MB cap (#4719 β a 156 MB file that
* VACUUMs down to 5 MB still can't be re-imported), so the limit is now operator-tunable
* via `OMNIROUTE_DB_IMPORT_MAX_MB`. Invalid / out-of-range values fall back to the 100 MB
* default and are clamped to a 4 GB ceiling.
*/
export function resolveMaxUploadSizeBytes(
env: NodeJS.ProcessEnv = process.env
): number {
const raw = env.OMNIROUTE_DB_IMPORT_MAX_MB;
const parsed = raw === undefined ? NaN : Number(raw);
const mb =
Number.isFinite(parsed) && parsed >= 1
? Math.min(Math.floor(parsed), MAX_UPLOAD_MB_CEILING)
: DEFAULT_MAX_UPLOAD_MB;
return mb * 1024 * 1024;
}
// Required tables that must exist in a valid OmniRoute database
const REQUIRED_TABLES = ["provider_connections", "provider_nodes", "combos", "api_keys"];
/**
* POST /api/db-backups/import β Upload a .sqlite file to replace the current database.
*
* Accepts multipart/form-data with a single "file" field containing the .sqlite backup.
* Validates integrity, schema, and required tables before replacing the active database.
*
* π Auth-guarded: requires JWT cookie or Bearer API key (finding #258-3).
*/
export async function POST(request: Request) {
if (await isAuthRequired(request)) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
let tmpPath: string | null = null;
try {
let fileBuffer: Buffer | null = null;
let fileName = "";
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 file provided. Upload a .sqlite file." },
{ status: 400 }
);
}
fileName = file.name;
fileBuffer = Buffer.from(await file.arrayBuffer());
} else {
// Direct binary transfer to bypass Reverse Proxy / Next.js FormData parsing errors under chunked encoding (Bug #770)
const buffer = await request.arrayBuffer();
if (!buffer || buffer.byteLength === 0) {
return NextResponse.json({ error: "No file content provided." }, { status: 400 });
}
fileBuffer = Buffer.from(buffer);
const url = new URL(request.url);
fileName = url.searchParams.get("filename") || "import.sqlite";
}
// Validate filename extension
if (!fileName.endsWith(".sqlite")) {
return NextResponse.json(
{ error: "Invalid file type. Only .sqlite files are accepted." },
{ status: 400 }
);
}
// Validate file size
const maxUploadSize = resolveMaxUploadSizeBytes();
const fileSize = fileBuffer.length;
if (fileSize > maxUploadSize) {
return NextResponse.json(
{
error:
`File too large. Maximum allowed size is ${maxUploadSize / (1024 * 1024)} MB. ` +
`Set OMNIROUTE_DB_IMPORT_MAX_MB to raise it, or VACUUM the database before exporting.`,
},
{ status: 400 }
);
}
if (fileSize < 4096) {
return NextResponse.json(
{ error: "File too small to be a valid SQLite database." },
{ status: 400 }
);
}
// Write uploaded file to temp location
tmpPath = path.join(os.tmpdir(), `omniroute-import-${Date.now()}.sqlite`);
fs.writeFileSync(tmpPath, fileBuffer!);
// Validate SQLite integrity.
// Use the resilient driver factory (better-sqlite3 β node:sqlite β sql.js) rather than
// a direct `better-sqlite3` import: in the packaged Electron app that native module is
// absent from the standalone server's node_modules, so a hard import crashes the route
// with "Cannot find module 'better-sqlite3'" even though node:sqlite is available (#3025).
let testDb: SqliteAdapter | null = null;
try {
testDb = await openDatabaseAsync(tmpPath, { readonly: true });
const result = testDb.pragma("integrity_check") as any[];
if (result[0]?.integrity_check !== "ok") {
return NextResponse.json(
{ error: "Database integrity check failed. The file may be corrupted." },
{ status: 400 }
);
}
// Validate required tables exist
const tables = getTableNamesFromAdapter(testDb);
const missingTables = REQUIRED_TABLES.filter((t) => !tables.includes(t));
if (missingTables.length > 0) {
return NextResponse.json(
{
error: `Invalid OmniRoute database. Missing tables: ${missingTables.join(", ")}`,
},
{ status: 400 }
);
}
testDb.close();
testDb = null;
} catch (e) {
if (testDb) testDb.close();
return NextResponse.json(
{ error: `Invalid database file: ${sanitizeErrorMessage(e)}` },
{ status: 400 }
);
}
// Create pre-import backup
backupDbFile("pre-import");
// Close and reset current DB connection
resetDbInstance();
// Remove main file and WAL sidecars
const sqliteFilesToReplace = [
SQLITE_FILE,
`${SQLITE_FILE}-wal`,
`${SQLITE_FILE}-shm`,
`${SQLITE_FILE}-journal`,
];
for (const filePath of sqliteFilesToReplace) {
if (!filePath) continue;
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
// Copy imported file over current DB
fs.copyFileSync(tmpPath, SQLITE_FILE!);
// Reopen and verify
getDbInstance();
const { connCount, nodeCount, comboCount, keyCount } = countImportedRows();
console.log(
`[DB] Imported database from upload: ${connCount} connections, ${nodeCount} nodes, ${comboCount} combos, ${keyCount} API keys`
);
// The DB was replaced wholesale β re-hydrate the in-memory Global System Prompt so it
// reflects the imported settings without requiring a restart (#2470).
try {
const importedSettings = await getSettings();
if (importedSettings.systemPrompt) {
setSystemPromptConfig(importedSettings.systemPrompt);
}
} catch {
// non-fatal: import succeeded; system prompt will hydrate on next restart
}
return NextResponse.json({
imported: true,
filename: fileName,
connectionCount: connCount,
nodeCount,
comboCount,
apiKeyCount: keyCount,
});
} catch (error) {
console.error("[API] Error importing database:", error);
return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 });
} finally {
// Cleanup temp file
if (tmpPath && fs.existsSync(tmpPath)) {
try {
fs.unlinkSync(tmpPath);
} catch {
/* best effort */
}
}
}
}
|