diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx index e0039896d..cdbe2055e 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx @@ -20,8 +20,11 @@ export const BackupExportCard = () => { try { const response = await fetch("/api/backup/export"); if (!response.ok) { - const data = await response.json(); - throw new Error(data.error ?? t("error")); + const errorMessage = await response.json().then( + (data) => data.error, + () => `Server returned ${response.status}`, + ); + throw new Error(errorMessage ?? t("error")); } const blob = await response.blob(); diff --git a/apps/nextjs/src/app/api/backup/import/route.ts b/apps/nextjs/src/app/api/backup/import/route.ts index 7cdd0fe0a..32002014a 100644 --- a/apps/nextjs/src/app/api/backup/import/route.ts +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -14,19 +14,12 @@ import { DB_CASING } from "@homarr/core/infrastructure/db/constants"; import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { db } from "@homarr/db"; +import { findMigrationsFolder } from "../shared"; + const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const; const ALGORITHM = "aes-256-cbc"; const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/; -const migrationsFolderCandidates = [ - path.join(process.cwd(), "db/migrations/sqlite"), - path.join(process.cwd(), "packages/db/migrations/sqlite"), - path.resolve(process.cwd(), "../../db/migrations/sqlite"), - "/app/db/migrations/sqlite", -]; - -const findMigrationsFolder = () => migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate)); - const reEncryptSecrets = (tempDb: InstanceType, importedKeyHex: string) => { const currentKeyHex = env.SECRET_ENCRYPTION_KEY; if (importedKeyHex === currentKeyHex) return; @@ -146,9 +139,8 @@ export async function POST(req: Request) { const metadataEntry = zip.getEntry("metadata.json")!; const metadata = JSON.parse(metadataEntry.getData().toString()); - const importedKey = - metadata.encryptionKey ?? - zip.getEntry("encryption-key.txt")?.getData().toString().trim(); + const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim(); + const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined; const secretCount = ( tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number } diff --git a/apps/nextjs/src/app/api/backup/migrations/route.ts b/apps/nextjs/src/app/api/backup/migrations/route.ts index 18c348829..13724560d 100644 --- a/apps/nextjs/src/app/api/backup/migrations/route.ts +++ b/apps/nextjs/src/app/api/backup/migrations/route.ts @@ -3,6 +3,8 @@ import path from "path"; import { NextResponse } from "next/server"; +import { findMigrationsFolder } from "../shared"; + interface JournalEntry { idx: number; version: string; @@ -18,15 +20,6 @@ interface MigrationFile { when: number; } -const migrationsFolderCandidates = [ - path.join(process.cwd(), "db/migrations/sqlite"), - path.join(process.cwd(), "packages/db/migrations/sqlite"), - path.resolve(process.cwd(), "../../db/migrations/sqlite"), - "/app/db/migrations/sqlite", -]; - -const findMigrationsFolder = () => migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate)); - export async function GET() { try { const migrationsFolder = findMigrationsFolder(); diff --git a/apps/nextjs/src/app/api/backup/shared.ts b/apps/nextjs/src/app/api/backup/shared.ts new file mode 100644 index 000000000..58978a596 --- /dev/null +++ b/apps/nextjs/src/app/api/backup/shared.ts @@ -0,0 +1,12 @@ +import fs from "fs"; +import path from "path"; + +const migrationsFolderCandidates = [ + path.join(process.cwd(), "db/migrations/sqlite"), + path.join(process.cwd(), "packages/db/migrations/sqlite"), + path.resolve(process.cwd(), "../../db/migrations/sqlite"), + "/app/db/migrations/sqlite", +]; + +export const findMigrationsFolder = () => + migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate)); diff --git a/apps/nextjs/src/components/backup/use-backup-analysis.ts b/apps/nextjs/src/components/backup/use-backup-analysis.ts index b4966a516..a563e24cd 100644 --- a/apps/nextjs/src/components/backup/use-backup-analysis.ts +++ b/apps/nextjs/src/components/backup/use-backup-analysis.ts @@ -98,72 +98,74 @@ export const useBackupAnalysis = () => { const db = new SQL.Database(dbBuffer); dbRef.current = db; - const appliedCount = getAppliedMigrationCount(db); - const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount); + try { + const appliedCount = getAppliedMigrationCount(db); + const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount); - const migrations: MigrationStatus = { - applied: appliedCount, - pending: pendingMigrations, - total: allMigrations.length, - }; + const migrations: MigrationStatus = { + applied: appliedCount, + pending: pendingMigrations, + total: allMigrations.length, + }; - if (pendingMigrations.length > 0) { - for (let i = 0; i < pendingMigrations.length; i++) { - const migration = pendingMigrations[i]!; - setMigrationProgress({ - current: i + 1, - total: pendingMigrations.length, - tag: migration.tag, - phase: "applying", - }); - - await delay(100); - - try { - const statements = migration.sql - .split("--> statement-breakpoint") - .map((s) => s.trim()) - .filter(Boolean); - - for (const stmt of statements) { - db.run(stmt); - } - - db.run( - `INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`, - [migration.tag, Date.now()], - ); - } catch (migrationErr) { + if (pendingMigrations.length > 0) { + for (let i = 0; i < pendingMigrations.length; i++) { + const migration = pendingMigrations[i]!; setMigrationProgress({ current: i + 1, total: pendingMigrations.length, tag: migration.tag, - phase: "error", + phase: "applying", }); - throw new Error( - `Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, - ); + + await delay(100); + + try { + const statements = migration.sql + .split("--> statement-breakpoint") + .map((s) => s.trim()) + .filter(Boolean); + + for (const stmt of statements) { + db.run(stmt); + } + + db.run( + `INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`, + [migration.tag, Date.now()], + ); + } 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({ + current: i + 1, + total: pendingMigrations.length, + tag: migration.tag, + phase: "done", + }); + + await delay(50); } - - setMigrationProgress({ - current: i + 1, - total: pendingMigrations.length, - tag: migration.tag, - phase: "done", - }); - - await delay(50); } + + const counts = countEntities(db); + const boardNames = getBoardNames(db); + + setMigrationProgress(null); + setAnalysis({ metadata, counts, boardNames, migrations }); + } finally { + db.close(); + dbRef.current = null; } - - const counts = countEntities(db); - const boardNames = getBoardNames(db); - - db.close(); - dbRef.current = null; - - setMigrationProgress(null); - setAnalysis({ metadata, counts, boardNames, migrations }); } catch (err) { setError(err instanceof Error ? err.message : t("analyzeError")); } finally { diff --git a/scripts/run.sh b/scripts/run.sh index 3b2c71908..83eff588e 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -10,8 +10,11 @@ if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then echo "DB migrations are disabled, skipping" else echo "Running DB migrations" - # We disable redis logs during migration as the redis client is not yet started DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT + if [ $? -ne 0 ]; then + echo "ERROR: DB migrations failed, aborting startup" + exit 1 + fi fi # Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing