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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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})",
|
||||
|
||||
Reference in new issue
Block a user