fix: harden backup/restore error handling and security

- Stop analysis on WASM migration failure instead of continuing silently
- Fail import when secrets exist but no encryption key is provided
- Fail import on malformed secret values instead of skipping them
- Tighten onboarding auth: missing onboarding row no longer grants access
- Wrap WAL/SHM cleanup in try/catch so post-rename failure doesn't mask success
- Show API errors immediately instead of waiting for animation to finish
- Handle non-JSON server responses during restore (e.g. restart mid-request)
- Surface fetchMigrations() failures as errors instead of empty array
- Wrap migrations route I/O in try/catch with structured error response
- Add server-side console.error logging to all backup API catch blocks
- Fix export finally block: cleanup wrapped in try/catch to avoid masking
- Fix misleading "Up to date" badge shown when migrations are pending
- Remove dead PHASE_ICONS constant
This commit is contained in:
Thomas Camlong committed 2026-06-07 01:15:07 +02:00
1 parent 76eacf8353
commit 79b27815fe
8 files changed
+88 -56

No files matched your search

@@ -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);
}
}
}
+26 -6
View File
@@ -50,7 +50,11 @@ const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, 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<typeof Database>, importedKeyHex:
const isOnboardingActiveAsync = async (): Promise<boolean> => {
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 {
@@ -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 });
}
}
@@ -90,8 +90,8 @@ export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
: t("migrationsOlderVersionPlural", { count: String(pendingCount) })}
</Text>
</div>
<Badge variant="light" color="green" ml="auto">
{t("upToDate")}
<Badge variant="light" color="orange" ml="auto">
{t("migrationsPending", { count: String(pendingCount) })}
</Badge>
</Group>
</Paper>
@@ -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();
@@ -17,14 +17,6 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { MigrationFile } from "./types";
const PHASE_ICONS: Record<string, Icon> = {
extracting: IconFileZip,
migrating: IconDatabaseImport,
encrypting: IconKey,
swapping: IconTransform,
restarting: IconRefresh,
};
const BASE_PHASE_DURATIONS: Record<string, number> = {
extracting: 1200,
encrypting: 1500,
@@ -22,7 +22,7 @@ const getAppliedMigrationCount = (db: any): number => {
const fetchMigrations = async (): Promise<MigrationFile[]> => {
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({
+2 -1
View File
@@ -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})",