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 */ } } } }