diff --git a/apps/nextjs/src/app/api/backup/export/route.ts b/apps/nextjs/src/app/api/backup/export/route.ts index 6efd4699e..cf612c337 100644 --- a/apps/nextjs/src/app/api/backup/export/route.ts +++ b/apps/nextjs/src/app/api/backup/export/route.ts @@ -61,12 +61,15 @@ export async function GET() { }, }); } catch (error) { + console.error("[backup/export] Export failed:", error); const message = error instanceof Error ? error.message : "Unknown error"; return NextResponse.json({ error: `Export failed: ${message}` }, { status: 500 }); } finally { sourceDb?.close(); - if (fs.existsSync(tempPath)) { - fs.unlinkSync(tempPath); + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } catch (cleanupErr) { + console.error("[backup/export] Failed to clean up temp file:", cleanupErr); } } } diff --git a/apps/nextjs/src/app/api/backup/import/route.ts b/apps/nextjs/src/app/api/backup/import/route.ts index 7a51ba0b4..7cdd0fe0a 100644 --- a/apps/nextjs/src/app/api/backup/import/route.ts +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -50,7 +50,11 @@ const reEncryptSecrets = (tempDb: InstanceType, importedKeyHex: const transaction = tempDb.transaction(() => { for (const row of rows) { const parts = row.value.split("."); - if (parts.length !== 2) continue; + if (parts.length !== 2) { + throw new Error( + `Malformed secret value for integration ${row.integration_id} (${row.kind}): expected "data.iv" format`, + ); + } const [data, dataIv] = parts as [string, string]; try { @@ -75,7 +79,7 @@ const reEncryptSecrets = (tempDb: InstanceType, importedKeyHex: const isOnboardingActiveAsync = async (): Promise => { const onboardingRow = await db.query.onboarding.findFirst(); - if (!onboardingRow) return true; + if (!onboardingRow) return false; return onboardingRow.step === "start"; }; @@ -146,6 +150,17 @@ export async function POST(req: Request) { metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim(); + const secretCount = ( + tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number } + ).count; + + if (secretCount > 0 && !importedKey) { + throw new Error( + "Backup contains integration secrets but no encryption key. " + + "Cannot restore without the original SECRET_ENCRYPTION_KEY.", + ); + } + if (importedKey) { reEncryptSecrets(tempDb, importedKey); } @@ -155,10 +170,14 @@ export async function POST(req: Request) { fs.renameSync(tempPath, dbPath); - const walPath = `${dbPath}-wal`; - const shmPath = `${dbPath}-shm`; - if (fs.existsSync(walPath)) fs.unlinkSync(walPath); - if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath); + for (const suffix of ["-wal", "-shm"] as const) { + try { + const sidecar = `${dbPath}${suffix}`; + if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar); + } catch (cleanupErr) { + console.error(`Failed to remove ${suffix} file:`, cleanupErr); + } + } setTimeout(() => { console.log("Database restored, restarting server..."); @@ -167,6 +186,7 @@ export async function POST(req: Request) { return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." }); } catch (error) { + console.error("[backup/import] Restore failed:", error); const message = error instanceof Error ? error.message : "Unknown error"; return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 }); } finally { diff --git a/apps/nextjs/src/app/api/backup/migrations/route.ts b/apps/nextjs/src/app/api/backup/migrations/route.ts index 519ce106e..18c348829 100644 --- a/apps/nextjs/src/app/api/backup/migrations/route.ts +++ b/apps/nextjs/src/app/api/backup/migrations/route.ts @@ -28,37 +28,43 @@ const migrationsFolderCandidates = [ const findMigrationsFolder = () => migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate)); export async function GET() { - const migrationsFolder = findMigrationsFolder(); - if (!migrationsFolder) { - return NextResponse.json({ error: "Migration files not found" }, { status: 500 }); - } + try { + const migrationsFolder = findMigrationsFolder(); + if (!migrationsFolder) { + return NextResponse.json({ error: "Migration files not found" }, { status: 500 }); + } - const journalPath = path.join(migrationsFolder, "meta", "_journal.json"); - if (!fs.existsSync(journalPath)) { - return NextResponse.json({ error: "Migration journal not found" }, { status: 500 }); - } + const journalPath = path.join(migrationsFolder, "meta", "_journal.json"); + if (!fs.existsSync(journalPath)) { + return NextResponse.json({ error: "Migration journal not found" }, { status: 500 }); + } - const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] }; + const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] }; - const migrations: MigrationFile[] = []; - for (const entry of journal.entries) { - const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`); - if (!fs.existsSync(sqlPath)) continue; + const migrations: MigrationFile[] = []; + for (const entry of journal.entries) { + const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`); + if (!fs.existsSync(sqlPath)) continue; - migrations.push({ - idx: entry.idx, - tag: entry.tag, - sql: fs.readFileSync(sqlPath, "utf-8"), - when: entry.when, - }); - } + migrations.push({ + idx: entry.idx, + tag: entry.tag, + sql: fs.readFileSync(sqlPath, "utf-8"), + when: entry.when, + }); + } - return NextResponse.json( - { total: migrations.length, migrations }, - { - headers: { - "Cache-Control": "public, max-age=3600", + return NextResponse.json( + { total: migrations.length, migrations }, + { + headers: { + "Cache-Control": "public, max-age=3600", + }, }, - }, - ); + ); + } catch (error) { + console.error("[backup/migrations] Failed to read migrations:", error); + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 }); + } } diff --git a/apps/nextjs/src/components/backup/backup-preview-panel.tsx b/apps/nextjs/src/components/backup/backup-preview-panel.tsx index dfabc5bf0..98a5effc5 100644 --- a/apps/nextjs/src/components/backup/backup-preview-panel.tsx +++ b/apps/nextjs/src/components/backup/backup-preview-panel.tsx @@ -90,8 +90,8 @@ export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => { : t("migrationsOlderVersionPlural", { count: String(pendingCount) })} - - {t("upToDate")} + + {t("migrationsPending", { count: String(pendingCount) })} diff --git a/apps/nextjs/src/components/backup/database-restore-flow.tsx b/apps/nextjs/src/components/backup/database-restore-flow.tsx index 2bc8b113f..502b5d9f1 100644 --- a/apps/nextjs/src/components/backup/database-restore-flow.tsx +++ b/apps/nextjs/src/components/backup/database-restore-flow.tsx @@ -55,14 +55,17 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat }, [reset]); const tryFinalize = useCallback(() => { - if (!apiDoneRef.current || !animDoneRef.current) return; + if (!apiDoneRef.current) return; + if (apiErrorRef.current) { setImportError(apiErrorRef.current); setStep("error"); - } else { - onRestoreComplete?.(); - window.location.reload(); + return; } + + if (!animDoneRef.current) return; + onRestoreComplete?.(); + window.location.reload(); }, [onRestoreComplete]); const handleConfirm = useCallback(async () => { @@ -77,13 +80,17 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat const formData = new FormData(); formData.append("file", file); const response = await fetch("/api/backup/import", { method: "POST", body: formData }); - const data = await response.json(); if (!response.ok) { - apiErrorRef.current = data.error ?? t("analyzeError"); + try { + const data = await response.json(); + apiErrorRef.current = data.error ?? t("failed.title"); + } catch { + apiErrorRef.current = `Server returned ${response.status}`; + } } - } catch (err) { - apiErrorRef.current = err instanceof Error ? err.message : t("analyzeError"); + } catch { + apiErrorRef.current = t("failed.title"); } finally { apiDoneRef.current = true; tryFinalize(); diff --git a/apps/nextjs/src/components/backup/restore-progress-panel.tsx b/apps/nextjs/src/components/backup/restore-progress-panel.tsx index d32aa300c..4faa397be 100644 --- a/apps/nextjs/src/components/backup/restore-progress-panel.tsx +++ b/apps/nextjs/src/components/backup/restore-progress-panel.tsx @@ -17,14 +17,6 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { MigrationFile } from "./types"; -const PHASE_ICONS: Record = { - extracting: IconFileZip, - migrating: IconDatabaseImport, - encrypting: IconKey, - swapping: IconTransform, - restarting: IconRefresh, -}; - const BASE_PHASE_DURATIONS: Record = { extracting: 1200, encrypting: 1500, diff --git a/apps/nextjs/src/components/backup/use-backup-analysis.ts b/apps/nextjs/src/components/backup/use-backup-analysis.ts index 3ebb844a8..b4966a516 100644 --- a/apps/nextjs/src/components/backup/use-backup-analysis.ts +++ b/apps/nextjs/src/components/backup/use-backup-analysis.ts @@ -22,7 +22,7 @@ const getAppliedMigrationCount = (db: any): number => { const fetchMigrations = async (): Promise => { const res = await fetch("/api/backup/migrations"); - if (!res.ok) return []; + if (!res.ok) throw new Error("Failed to fetch migration files from server"); const data = await res.json(); return data.migrations ?? []; }; @@ -133,13 +133,16 @@ export const useBackupAnalysis = () => { `INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`, [migration.tag, Date.now()], ); - } catch { + } catch (migrationErr) { setMigrationProgress({ current: i + 1, total: pendingMigrations.length, tag: migration.tag, phase: "error", }); + throw new Error( + `Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, + ); } setMigrationProgress({ diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index ec37be872..88f95e6b5 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4127,7 +4127,8 @@ "migrationsOlderVersion": "This backup was from an older Homarr version. {count} schema migration was applied to bring it up to date.", "migrationsOlderVersionPlural": "This backup was from an older Homarr version. {count} schema migrations were applied to bring it up to date.", "migrationsUpToDate": "Database schema is up to date ({count} migrations applied)", - "upToDate": "Up to date" + "upToDate": "Up to date", + "migrationsPending": "{count} pending" }, "migration": { "title": "Applying migrations ({current}/{total})",