fix: harden onboarding completion

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 01:20:27 +02:00
1 parent 576cc0b418
commit 07770e862c
44 files changed
+3003 -1000

No files matched your search

+25 -16
View File
@@ -1,5 +1,6 @@
import { headers } from "next/headers";
import { getOnboardingClaimTokenFromCookieHeader, isOnboardingClaimValidAsync } from "@homarr/api/onboarding-claim";
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
@@ -16,28 +17,38 @@ import { DatabaseRestoreFlow } from "~/components/backup";
import { env } from "~/env";
export default async function InitPage() {
const [state, session, requestHeaders, defaultGroup, firstUser, availableBoards] = await Promise.all([
const [state, session, requestHeaders, firstUser] = await Promise.all([
db.query.onboarding.findFirst(),
auth(),
headers(),
db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
columns: { homeBoardId: true },
}),
db.query.users.findFirst({ columns: { id: true } }),
db.query.boards.findMany({ columns: { id: true, name: true } }),
]);
const canConfigurePrivileged = session?.user.permissions.includes("admin") ?? false;
const onboardingClaim = getOnboardingClaimTokenFromCookieHeader(requestHeaders.get("cookie"));
const canReadSetupContext =
canConfigurePrivileged || (!firstUser && (await isOnboardingClaimValidAsync(db, onboardingClaim)));
const [defaultGroup, availableBoards] = canReadSetupContext
? await Promise.all([
db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
columns: { homeBoardId: true },
}),
db.query.boards.findMany({ columns: { id: true, name: true } }),
])
: [undefined, []];
const homeBoardId = defaultGroup?.homeBoardId;
const homeBoard = availableBoards.find((board) => board.id === homeBoardId);
const initialBoard = homeBoard ?? (availableBoards.length === 1 ? availableBoards[0] : null);
const currentStep = normalizeOnboardingStep(state?.step);
const databaseDriver =
dbEnv.DRIVER === "better-sqlite3" ? "sqlite" : dbEnv.DRIVER === "mysql2" ? "mysql" : "postgresql";
const { workshopApiUrl, workshopWebUrl } = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
const { workshopApiUrl, workshopWebUrl } = canReadSetupContext
? resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
})
: { workshopApiUrl: "", workshopWebUrl: "" };
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
return (
@@ -45,15 +56,13 @@ export default async function InitPage() {
environment={{
currentStep,
databaseDriver,
credentialsEnabled: isProviderEnabled("credentials"),
externalAuthEnabled: isProviderEnabled("ldap") || isProviderEnabled("oidc"),
dockerConfigured: dockerEnv.ENABLE_DOCKER,
kubernetesConfigured: dockerEnv.ENABLE_KUBERNETES,
workshopEnabled: true,
dockerConfigured: canReadSetupContext && dockerEnv.ENABLE_DOCKER,
kubernetesConfigured: canReadSetupContext && dockerEnv.ENABLE_KUBERNETES,
workshopApiUrl,
workshopUrl: workshopWebUrl,
mcpEndpoint: `${baseUrl}/api/mcp`,
canConfigurePrivileged: session?.user.permissions.includes("admin") ?? false,
canConfigurePrivileged,
hasUsers: Boolean(firstUser),
initialBoard: initialBoard ?? null,
availableBoards,
+6 -2
View File
@@ -173,8 +173,12 @@ export default async function Layout(props: {
>
<head>
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
{session ? (
<>
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
</>
) : null}
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
@@ -51,8 +51,6 @@ interface NewIntegrationFormProps {
initialName?: string;
onSuccess: () => void;
onCancel?: () => void;
onSkip?: () => void;
isOnboarding?: boolean;
}
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
@@ -69,14 +67,12 @@ export const NewIntegrationForm = ({
initialName,
onSuccess,
onCancel,
onSkip,
isOnboarding = false,
}: NewIntegrationFormProps) => {
const t = useI18n();
const secretKinds = getAllSecretKindOptions(kind);
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const { data: session } = useSession();
const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false);
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? "";
if (hasUrlSecret) {
@@ -90,9 +86,9 @@ export const NewIntegrationForm = ({
kind,
value: "",
})),
attemptSearchEngineCreation: !isOnboarding,
hasApp: !isOnboarding,
appHref: isOnboarding ? null : url,
attemptSearchEngineCreation: true,
hasApp: true,
appHref: url,
appId: null,
},
});
@@ -104,13 +100,6 @@ export const NewIntegrationForm = ({
await utils.integration.invalidate();
},
});
const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } =
clientApi.onboard.createIntegration.useMutation({
async onSuccess() {
await utils.integration.invalidate();
},
});
const isPending = isCreatePending || isOnboardingCreatePending;
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
@@ -143,14 +132,6 @@ export const NewIntegrationForm = ({
});
};
if (isOnboarding) {
await createOnboardingIntegrationAsync(
{ kind, name: values.name, url, secrets: values.secrets },
{ onSuccess: onMutationSuccess, onError: onMutationError },
);
return;
}
const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
const app = hasApp
@@ -206,7 +187,7 @@ export const NewIntegrationForm = ({
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
{!isOnboarding && supportsSearchEngine && (
{supportsSearchEngine && (
<Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", {
@@ -216,7 +197,7 @@ export const NewIntegrationForm = ({
/>
)}
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
<AppForm form={form} canCreateApps={canCreateApps} />
<Group justify="end" align="center">
{onCancel ? (
@@ -228,12 +209,7 @@ export const NewIntegrationForm = ({
{t("common.action.backToOverview")}
</Button>
)}
{onSkip && (
<Button variant="default" onClick={onSkip}>
{t("common.action.skip")}
</Button>
)}
<Button type="submit" loading={isPending}>
<Button type="submit" loading={isCreatePending}>
{t("integration.testConnection.action.create")}
</Button>
</Group>
+161 -35
View File
@@ -24,6 +24,101 @@ 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 MEBIBYTE = 1024 * 1024;
const MAX_COMPRESSED_BACKUP_BYTES = 256 * MEBIBYTE;
const MAX_MULTIPART_REQUEST_BYTES = MAX_COMPRESSED_BACKUP_BYTES + MEBIBYTE;
const MAX_UNCOMPRESSED_DATABASE_BYTES = 512 * MEBIBYTE;
const MAX_UNCOMPRESSED_ARCHIVE_BYTES = MAX_UNCOMPRESSED_DATABASE_BYTES + 2 * MEBIBYTE;
const MAX_METADATA_BYTES = MEBIBYTE;
const MAX_ENCRYPTION_KEY_BYTES = 1024;
const RESTART_DELAY_MS = 500;
let restoreInProgress = false;
class BackupTooLargeError extends Error {}
const readBoundedRequestBodyAsync = async (request: Request): Promise<Uint8Array<ArrayBuffer>> => {
const declaredLength = request.headers.get("content-length");
if (declaredLength !== null) {
const parsedLength = Number(declaredLength);
if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) {
throw new Error("Invalid Content-Length header");
}
if (parsedLength > MAX_MULTIPART_REQUEST_BYTES) {
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
}
}
if (!request.body) return new Uint8Array();
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) {
await reader.cancel();
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const body = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return body;
};
const parseBoundedFormDataAsync = async (request: Request): Promise<FormData> => {
const contentType = request.headers.get("content-type");
if (!contentType) throw new Error("Missing Content-Type header");
const body = await readBoundedRequestBodyAsync(request);
return new Response(body, { headers: { "content-type": contentType } }).formData();
};
const assertArchiveSizeLimits = (zip: AdmZip) => {
const entries = zip.getEntries();
let totalSize = 0;
for (const entry of entries) {
if (entry.isDirectory) continue;
const entrySize = entry.header.size;
if (!Number.isSafeInteger(entrySize) || entrySize < 0) {
throw new Error(`Invalid uncompressed size for ${entry.entryName}`);
}
totalSize += entrySize;
if (totalSize > MAX_UNCOMPRESSED_ARCHIVE_BYTES) {
throw new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MB limit");
}
}
const dbEntry = zip.getEntry("db.sqlite");
if (dbEntry && dbEntry.header.size > MAX_UNCOMPRESSED_DATABASE_BYTES) {
throw new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MB limit");
}
const metadataEntry = zip.getEntry("metadata.json");
if (metadataEntry && metadataEntry.header.size > MAX_METADATA_BYTES) {
throw new BackupTooLargeError("Backup metadata exceeds the 1 MB limit");
}
const encryptionKeyEntry = zip.getEntry("encryption-key.txt");
if (encryptionKeyEntry && encryptionKeyEntry.header.size > MAX_ENCRYPTION_KEY_BYTES) {
throw new BackupTooLargeError("Backup encryption key entry is too large");
}
};
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
@@ -105,41 +200,62 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
}
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
let zip: AdmZip;
try {
zip = new AdmZip(Buffer.from(arrayBuffer));
} catch {
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
}
const entries = zip.getEntries().map((e) => e.entryName);
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
if (missingEntries.length > 0) {
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
}
const migrationsFolder = findMigrationsFolder();
if (!migrationsFolder) {
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
}
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
if (restoreInProgress) {
return NextResponse.json({ error: "A database restore is already in progress" }, { status: 409 });
}
restoreInProgress = true;
let tempDirectory: string | null = null;
let tempDb: InstanceType<typeof Database> | null = null;
try {
let formData: FormData;
try {
formData = await parseBoundedFormDataAsync(req);
} catch (error) {
if (error instanceof BackupTooLargeError) {
return NextResponse.json({ error: error.message }, { status: 413 });
}
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (file.size > MAX_COMPRESSED_BACKUP_BYTES) {
return NextResponse.json({ error: "Backup upload exceeds the 256 MB limit" }, { status: 413 });
}
let zip: AdmZip;
try {
zip = new AdmZip(Buffer.from(await file.arrayBuffer()));
} catch {
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
}
const entries = zip.getEntries().map((entry) => entry.entryName);
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
if (missingEntries.length > 0) {
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
}
try {
assertArchiveSizeLimits(zip);
} catch (error) {
if (error instanceof BackupTooLargeError) {
return NextResponse.json({ error: error.message }, { status: 413 });
}
throw error;
}
tempDirectory = fs.mkdtempSync(path.join(path.dirname(dbPath), ".homarr-restore-"));
const tempPath = path.join(tempDirectory, "db.sqlite");
const dbEntry = zip.getEntry("db.sqlite");
if (!dbEntry) {
return NextResponse.json({ error: "Invalid backup: missing db.sqlite" }, { status: 400 });
@@ -187,23 +303,33 @@ export async function POST(req: Request) {
}
}
setTimeout(() => {
const restartTimer = setTimeout(() => {
console.log("Database restored, restarting server...");
process.exit(0);
}, 500);
}, RESTART_DELAY_MS);
restartTimer.unref();
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
return NextResponse.json({
success: true,
message: "Database restored. Server is restarting...",
restartAfterMs: RESTART_DELAY_MS,
});
} 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 {
tempDb?.close();
if (fs.existsSync(tempPath)) {
restoreInProgress = false;
try {
tempDb?.close();
} catch (error) {
console.error("[backup/import] Failed to close temporary database:", error);
}
if (tempDirectory) {
try {
fs.unlinkSync(tempPath);
} catch {
// temp file may have been renamed already
fs.rmSync(tempDirectory, { recursive: true, force: true });
} catch (error) {
console.error("[backup/import] Failed to remove temporary restore directory:", error);
}
}
}
@@ -0,0 +1,258 @@
// @vitest-environment node
import fs from "fs";
import os from "os";
import path from "path";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
const routeMocks = vi.hoisted(() => ({
auth: vi.fn(),
claimAllowed: vi.fn(),
onboardingFindFirst: vi.fn(),
dbEnv: {
DRIVER: "better-sqlite3",
URL: "",
},
commonEnv: {
SECRET_ENCRYPTION_KEY: "1".repeat(64),
},
}));
vi.mock("@homarr/auth/next", () => ({ auth: routeMocks.auth }));
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false }));
vi.mock("@homarr/api/onboarding-claim", () => ({
getOnboardingClaimTokenFromCookieHeader: () => "test-claim",
isClaimOnlyOnboardingAccessAllowedAsync: routeMocks.claimAllowed,
}));
vi.mock("@homarr/common/env", () => ({ env: routeMocks.commonEnv }));
vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: routeMocks.dbEnv }));
vi.mock("@homarr/db", () => ({
db: {
query: {
onboarding: {
findFirst: routeMocks.onboardingFindFirst,
},
},
},
}));
import { POST } from "../import/route";
const MEBIBYTE = 1024 * 1024;
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
const createMigratedDatabase = (databasePath: string, boardId: string, boardName: string) => {
const sqlite = new Database(databasePath);
migrate(drizzle(sqlite, { casing: DB_CASING }), { migrationsFolder: MIGRATIONS_FOLDER });
sqlite.exec(`
INSERT INTO "board" (
"id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius",
"background_image_attachment", "background_image_repeat", "background_image_size", "disable_status"
) VALUES (
'${boardId}', '${boardName}', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0
)
`);
sqlite.close();
};
const createBackup = (directory: string, name: string, boardName: string) => {
const sourcePath = path.join(directory, `${name}.sqlite`);
createMigratedDatabase(sourcePath, `board-${name}`, boardName);
const zip = new AdmZip();
zip.addFile("db.sqlite", fs.readFileSync(sourcePath));
zip.addFile(
"metadata.json",
Buffer.from(
JSON.stringify({
dbDialect: "sqlite",
encryptionKey: routeMocks.commonEnv.SECRET_ENCRYPTION_KEY,
}),
),
);
return zip.toBuffer();
};
const createImportRequest = (backup: Buffer) => {
const formData = new FormData();
formData.set("file", new File([Uint8Array.from(backup)], "homarr-backup.zip", { type: "application/zip" }));
return new Request("http://homarr.test/api/backup/import", {
method: "POST",
body: formData,
});
};
const readBoardNames = (databasePath: string) => {
const sqlite = new Database(databasePath, { readonly: true });
const rows = sqlite.prepare('SELECT "name" FROM "board" ORDER BY "name"').all() as { name: string }[];
sqlite.close();
return rows.map((row) => row.name);
};
describe("POST /api/backup/import", () => {
let temporaryDirectory: string;
let activeDatabasePath: string;
beforeEach(() => {
vi.useFakeTimers();
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-import-route-test-"));
activeDatabasePath = path.join(temporaryDirectory, "db.sqlite");
routeMocks.dbEnv.DRIVER = "better-sqlite3";
routeMocks.dbEnv.URL = activeDatabasePath;
routeMocks.auth.mockResolvedValue({ user: { permissions: ["admin"] } });
routeMocks.claimAllowed.mockResolvedValue(false);
routeMocks.onboardingFindFirst.mockResolvedValue({ step: "start" });
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
});
it("accepts the legacy import step through normalized onboarding claim access", async () => {
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
const backup = createBackup(temporaryDirectory, "legacy", "Restored board");
routeMocks.auth.mockResolvedValue(null);
routeMocks.claimAllowed.mockResolvedValue(true);
routeMocks.onboardingFindFirst.mockResolvedValue({ step: "import" });
const response = await POST(createImportRequest(backup));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ success: true, restartAfterMs: 500 });
expect(readBoardNames(activeDatabasePath)).toEqual(["Restored board"]);
});
it("rejects an oversized declared upload before reading its body", async () => {
const originalDatabase = Buffer.from("active database remains untouched");
fs.writeFileSync(activeDatabasePath, originalDatabase);
const request = new Request("http://homarr.test/api/backup/import", {
method: "POST",
headers: {
"content-length": String(258 * MEBIBYTE),
"content-type": "multipart/form-data; boundary=oversized",
},
body: "--oversized--\r\n",
});
const response = await POST(request);
expect(response.status).toBe(413);
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
});
it("rejects an oversized uncompressed SQLite entry without replacing the active database", async () => {
const originalDatabase = Buffer.from("active database remains untouched");
fs.writeFileSync(activeDatabasePath, originalDatabase);
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("small compressed payload"));
zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}'));
const databaseEntry = zip.getEntry("db.sqlite");
if (!databaseEntry) throw new Error("Expected database entry");
databaseEntry.header.size = 513 * MEBIBYTE;
const response = await POST(createImportRequest(zip.toBuffer()));
expect(response.status).toBe(413);
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
});
it("rejects an oversized uncompressed archive before extracting any entry", async () => {
const originalDatabase = Buffer.from("active database remains untouched");
fs.writeFileSync(activeDatabasePath, originalDatabase);
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("small compressed payload"));
zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}'));
zip.addFile("unexpected.bin", Buffer.from("small compressed payload"));
const unexpectedEntry = zip.getEntry("unexpected.bin");
if (!unexpectedEntry) throw new Error("Expected oversized test entry");
unexpectedEntry.header.size = 515 * MEBIBYTE;
const response = await POST(createImportRequest(zip.toBuffer()));
expect(response.status).toBe(413);
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
});
it("uses and cleans a unique temporary directory for every restore", async () => {
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
const createdDirectories: string[] = [];
const makeTemporaryDirectory = fs.mkdtempSync.bind(fs);
vi.spyOn(fs, "mkdtempSync").mockImplementation((prefix, options) => {
const directory = makeTemporaryDirectory(prefix, options);
createdDirectories.push(directory);
return directory;
});
const firstResponse = await POST(createImportRequest(createBackup(temporaryDirectory, "first", "First restore")));
const secondResponse = await POST(
createImportRequest(createBackup(temporaryDirectory, "second", "Second restore")),
);
expect(firstResponse.status).toBe(200);
expect(secondResponse.status).toBe(200);
expect(new Set(createdDirectories).size).toBe(2);
expect(createdDirectories.every((directory) => !fs.existsSync(directory))).toBe(true);
expect(readBoardNames(activeDatabasePath)).toEqual(["Second restore"]);
});
it("keeps a successful restore response when temporary directory cleanup fails", async () => {
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
const backup = createBackup(temporaryDirectory, "cleanup-failure", "Restored despite cleanup failure");
const removeDirectory = fs.rmSync.bind(fs);
const cleanupError = new Error("simulated cleanup failure");
const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(fs, "rmSync").mockImplementation((target, options) => {
if (String(target).includes(".homarr-restore-")) throw cleanupError;
return removeDirectory(target, options);
});
const response = await POST(createImportRequest(backup));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ success: true });
expect(readBoardNames(activeDatabasePath)).toEqual(["Restored despite cleanup failure"]);
expect(errorLog).toHaveBeenCalledWith(
"[backup/import] Failed to remove temporary restore directory:",
cleanupError,
);
});
it("rejects a concurrent restore while the first request owns the restore lock", async () => {
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
const backup = createBackup(temporaryDirectory, "concurrent", "Concurrent restore");
const originalArrayBuffer = File.prototype.arrayBuffer;
let signalStarted!: () => void;
let releaseFirstRequest!: () => void;
const started = new Promise<void>((resolve) => {
signalStarted = resolve;
});
const release = new Promise<void>((resolve) => {
releaseFirstRequest = resolve;
});
vi.spyOn(File.prototype, "arrayBuffer").mockImplementationOnce(async function (this: File) {
signalStarted();
await release;
return originalArrayBuffer.call(this);
});
const firstRestore = POST(createImportRequest(backup));
await started;
const concurrentResponse = await POST(createImportRequest(backup));
releaseFirstRequest();
const firstResponse = await firstRestore;
expect(concurrentResponse.status).toBe(409);
expect(firstResponse.status).toBe(200);
expect(readBoardNames(activeDatabasePath)).toEqual(["Concurrent restore"]);
});
});
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { waitForServerReadinessAsync } from "./database-restore-flow";
describe("waitForServerReadinessAsync", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts checking after the server-provided restart delay and returns when ready", async () => {
const controller = new AbortController();
const requestReady = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const readiness = waitForServerReadinessAsync({
restartAfterMs: 500,
signal: controller.signal,
timeoutMs: 5_000,
pollIntervalMs: 250,
requestReady,
});
await vi.advanceTimersByTimeAsync(499);
expect(requestReady).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(requestReady).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(250);
await expect(readiness).resolves.toBe("ready");
expect(requestReady).toHaveBeenCalledTimes(2);
});
it("times out even when a readiness request never settles on its own", async () => {
const controller = new AbortController();
const requestReady = vi.fn(
(signal: AbortSignal) =>
new Promise<boolean>((resolve) => {
signal.addEventListener("abort", () => resolve(false), { once: true });
}),
);
const readiness = waitForServerReadinessAsync({
restartAfterMs: 0,
signal: controller.signal,
timeoutMs: 1_000,
pollIntervalMs: 100,
requestTimeoutMs: 400,
requestReady,
});
await vi.advanceTimersByTimeAsync(1_001);
await expect(readiness).resolves.toBe("timedOut");
expect(requestReady).toHaveBeenCalledTimes(3);
});
it("stops cleanly when the restore flow is unmounted", async () => {
const controller = new AbortController();
const requestReady = vi.fn().mockResolvedValue(false);
const readiness = waitForServerReadinessAsync({
restartAfterMs: 500,
signal: controller.signal,
requestReady,
});
controller.abort();
await expect(readiness).resolves.toBe("aborted");
expect(requestReady).not.toHaveBeenCalled();
});
});
@@ -1,12 +1,12 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import "@mantine/dropzone/styles.css"; // oxlint-disable-line import/no-unassigned-import
import { useScopedI18n } from "@homarr/translation/client";
@@ -22,16 +22,102 @@ interface DatabaseRestoreFlowProps {
onRestoreComplete?: () => void;
}
const DEFAULT_RESTART_DELAY_MS = 500;
const RESTART_READINESS_TIMEOUT_MS = 45_000;
const RESTART_POLL_INTERVAL_MS = 750;
const RESTART_REQUEST_TIMEOUT_MS = 5_000;
export type ServerReadinessResult = "ready" | "timedOut" | "aborted";
interface WaitForServerReadinessOptions {
restartAfterMs: number;
signal: AbortSignal;
timeoutMs?: number;
pollIntervalMs?: number;
requestTimeoutMs?: number;
requestReady?: (signal: AbortSignal) => Promise<boolean>;
}
const waitAsync = (durationMs: number, signal: AbortSignal) =>
new Promise<boolean>((resolve) => {
if (signal.aborted) {
resolve(false);
return;
}
const timer = window.setTimeout(() => {
signal.removeEventListener("abort", handleAbort);
resolve(true);
}, durationMs);
const handleAbort = () => {
window.clearTimeout(timer);
resolve(false);
};
signal.addEventListener("abort", handleAbort, { once: true });
});
const requestServerReadinessAsync = async (signal: AbortSignal) => {
const response = await fetch("/api/health/ready", { cache: "no-store", signal });
return response.ok;
};
export const waitForServerReadinessAsync = async ({
restartAfterMs,
signal,
timeoutMs = RESTART_READINESS_TIMEOUT_MS,
pollIntervalMs = RESTART_POLL_INTERVAL_MS,
requestTimeoutMs = RESTART_REQUEST_TIMEOUT_MS,
requestReady = requestServerReadinessAsync,
}: WaitForServerReadinessOptions): Promise<ServerReadinessResult> => {
if (!(await waitAsync(Math.max(0, restartAfterMs), signal))) return "aborted";
const deadline = Date.now() + Math.max(0, timeoutMs);
while (!signal.aborted) {
const remainingMs = deadline - Date.now();
if (remainingMs < 0) return "timedOut";
const requestController = new AbortController();
const abortRequest = () => requestController.abort();
signal.addEventListener("abort", abortRequest, { once: true });
const requestTimer = window.setTimeout(abortRequest, Math.min(requestTimeoutMs, remainingMs));
let ready = false;
try {
ready = await requestReady(requestController.signal);
} catch {
// The server is expected to be temporarily unreachable while it restarts.
} finally {
window.clearTimeout(requestTimer);
signal.removeEventListener("abort", abortRequest);
}
if (signal.aborted) return "aborted";
if (ready) return "ready";
const delayMs = Math.min(pollIntervalMs, deadline - Date.now());
if (delayMs <= 0) return "timedOut";
if (!(await waitAsync(delayMs, signal))) return "aborted";
}
return "aborted";
};
export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => {
const t = useScopedI18n("management.page.tool.backup.restore");
const [file, setFile] = useState<FileWithPath | null>(null);
const [step, setStep] = useState<RestoreStep>("upload");
const [importError, setImportError] = useState<string | null>(null);
const apiDoneRef = useRef(false);
const animDoneRef = useRef(false);
const apiErrorRef = useRef<string | null>(null);
const [restoreStatus, setRestoreStatus] = useState<"restoring" | "restarting" | "timedOut">("restoring");
const restartControllerRef = useRef<AbortController | null>(null);
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
useEffect(
() => () => {
restartControllerRef.current?.abort();
},
[],
);
const handleFileDrop = useCallback(
(files: FileWithPath[]) => {
const droppedFile = files[0];
@@ -39,6 +125,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
setFile(droppedFile);
setStep("preview");
setImportError(null);
setRestoreStatus("restoring");
void analyzeFile(droppedFile);
},
[analyzeFile],
@@ -48,59 +135,70 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
setFile(null);
setStep("upload");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
setRestoreStatus("restoring");
restartControllerRef.current?.abort();
restartControllerRef.current = null;
reset();
}, [reset]);
const tryFinalize = useCallback(() => {
if (!apiDoneRef.current) return;
const startReadinessCheck = useCallback((restartAfterMs: number) => {
restartControllerRef.current?.abort();
const restartController = new AbortController();
restartControllerRef.current = restartController;
setRestoreStatus("restarting");
if (apiErrorRef.current) {
setImportError(apiErrorRef.current);
setStep("error");
return;
}
if (!animDoneRef.current) return;
onRestoreComplete?.();
window.location.reload();
}, [onRestoreComplete]);
void waitForServerReadinessAsync({ restartAfterMs, signal: restartController.signal }).then((result) => {
if (restartControllerRef.current !== restartController || result === "aborted") return;
if (result === "ready") {
window.location.reload();
return;
}
restartControllerRef.current = null;
setRestoreStatus("timedOut");
});
}, []);
const handleConfirm = useCallback(async () => {
if (!file) return;
setStep("restoring");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
setRestoreStatus("restoring");
restartControllerRef.current?.abort();
restartControllerRef.current = null;
let restartAfterMs = DEFAULT_RESTART_DELAY_MS;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/backup/import", { method: "POST", body: formData });
const data = (await response.json().catch(() => null)) as {
error?: unknown;
restartAfterMs?: unknown;
} | null;
if (!response.ok) {
try {
const data = await response.json();
apiErrorRef.current = data.error ?? t("failed.title");
} catch {
apiErrorRef.current = `Server returned ${response.status}`;
}
const message = typeof data?.error === "string" ? data.error : `Server returned ${response.status}`;
setImportError(message);
setStep("error");
return;
}
if (
typeof data?.restartAfterMs === "number" &&
Number.isFinite(data.restartAfterMs) &&
data.restartAfterMs >= 0
) {
restartAfterMs = data.restartAfterMs;
}
} catch {
apiErrorRef.current = t("failed.title");
} finally {
apiDoneRef.current = true;
tryFinalize();
setImportError(t("failed.title"));
setStep("error");
return;
}
}, [file, t, tryFinalize]);
const handleAnimationComplete = useCallback(() => {
animDoneRef.current = true;
tryFinalize();
}, [tryFinalize]);
onRestoreComplete?.();
startReadinessCheck(restartAfterMs);
}, [file, onRestoreComplete, startReadinessCheck, t]);
if (step === "upload") {
return (
@@ -206,8 +304,9 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
return (
<RestoreProgressPanel
active
migrations={analysis?.migrations.pending ?? []}
onComplete={handleAnimationComplete}
status={restoreStatus}
onRetry={() => startReadinessCheck(0)}
onReload={() => window.location.reload()}
/>
);
}
@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@homarr/translation/client", () => {
const translations: Record<string, string> = {
"progress.title": "Restoring database...",
"progress.restarting": "Restarting server",
"timeout.title": "Server did not come back online",
"timeout.message": "Retry the connection check or refresh this page manually.",
tryAgain: "Try again",
"docker.action.refresh.label": "Refresh",
};
return {
useScopedI18n: () => (key: string) => translations[key] ?? key,
useI18n: () => (key: string) => translations[key] ?? key,
};
});
import { RestoreProgressPanel } from "./restore-progress-panel";
describe("RestoreProgressPanel", () => {
let host: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
matches: query.includes("prefers-reduced-motion"),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
const renderPanel = async (
status: "restoring" | "restarting" | "timedOut",
onRetry = vi.fn(),
onReload = vi.fn(),
) => {
await act(async () =>
root.render(
createElement(
MantineProvider,
null,
createElement(RestoreProgressPanel, { active: true, status, onRetry, onReload }),
),
),
);
return { onRetry, onReload };
};
it("announces real restore and restart state without a motion-only cue", async () => {
await renderPanel("restoring");
const output = host.querySelector("output");
expect(output?.getAttribute("aria-live")).toBe("polite");
expect(output?.getAttribute("aria-busy")).toBe("true");
expect(output?.textContent).toContain("Restoring database...");
expect(output?.querySelector("svg")).not.toBeNull();
await renderPanel("restarting");
expect(output?.getAttribute("aria-busy")).toBe("true");
expect(output?.textContent).toContain("Restarting server");
});
it("exposes keyboard-native retry and manual refresh actions after timeout", async () => {
const { onRetry, onReload } = await renderPanel("timedOut");
const output = host.querySelector("output");
const buttons = Array.from(host.querySelectorAll("button"));
expect(output?.getAttribute("aria-busy")).toBe("false");
expect(output?.textContent).toContain("Server did not come back online");
expect(output?.textContent).toContain("Retry the connection check or refresh this page manually.");
expect(buttons.map((button) => button.textContent)).toEqual(["Try again", "Refresh"]);
buttons[0]?.click();
buttons[1]?.click();
expect(onRetry).toHaveBeenCalledOnce();
expect(onReload).toHaveBeenCalledOnce();
});
});
@@ -1,164 +1,78 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import {
IconCheck,
IconDatabaseImport,
IconFileZip,
IconKey,
IconLoader,
IconRefresh,
IconTransform,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { Button, Group, Loader, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import { IconAlertTriangle, IconCheck, IconDatabaseImport, IconRefresh } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { MigrationFile } from "./types";
const BASE_PHASE_DURATIONS = {
extracting: 1200,
encrypting: 1500,
swapping: 800,
restarting: 10000,
} as const;
const MIGRATION_STEP_DURATION = 100;
interface TimelineStep {
key: string;
label: string;
icon: Icon;
duration: number;
}
export type RestoreProgressStatus = "restoring" | "restarting" | "timedOut";
interface RestoreProgressPanelProps {
active: boolean;
migrations: MigrationFile[];
onComplete?: () => void;
status: RestoreProgressStatus;
onRetry: () => void;
onReload: () => void;
}
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.progress");
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
export const RestoreProgressPanel = ({ active, status, onRetry, onReload }: RestoreProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore");
const tRoot = useI18n();
const reduceMotion = useReducedMotion();
const steps = useMemo((): TimelineStep[] => {
const result: TimelineStep[] = [
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting },
];
if (!active) return null;
if (migrations.length > 0) {
for (const migration of migrations) {
result.push({
key: `migration-${migration.idx}`,
label: migration.tag,
icon: IconDatabaseImport,
duration: MIGRATION_STEP_DURATION,
});
}
} else {
result.push({
key: "migrating",
label: t("migrating"),
icon: IconDatabaseImport,
duration: 300,
});
}
result.push(
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting },
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping },
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting },
);
return result;
}, [migrations, t]);
useEffect(() => {
if (!active) return;
let cancelled = false;
const run = async () => {
for (const [i, step] of steps.entries()) {
if (cancelled) return;
setCurrentStepIdx(i);
await new Promise((r) => setTimeout(r, step.duration));
}
if (!cancelled) {
onComplete?.();
}
};
void run();
return () => {
cancelled = true;
};
}, [active, onComplete, steps]);
if (!active && currentStepIdx === -1) return null;
const isRestoring = status === "restoring";
const isTimedOut = status === "timedOut";
const statusLabel = isRestoring ? t("progress.title") : isTimedOut ? t("timeout.message") : t("progress.restarting");
return (
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
<Paper
component="output"
p="lg"
radius="md"
bg="var(--mantine-color-dark-7)"
aria-live="polite"
aria-atomic="true"
aria-busy={!isTimedOut}
>
<Stack gap="md">
<Group gap="xs">
<Loader size="sm" color="blue" />
<Text size="sm" fw={600}>
{t("title")}
</Text>
<Group gap="sm">
{isTimedOut ? (
<ThemeIcon size="lg" radius="xl" color="orange" variant="light" aria-hidden="true">
<IconAlertTriangle size={18} />
</ThemeIcon>
) : !isRestoring ? (
<ThemeIcon size="lg" radius="xl" color="green" variant="light" aria-hidden="true">
<IconCheck size={18} />
</ThemeIcon>
) : reduceMotion ? (
<ThemeIcon size="lg" radius="xl" color="blue" variant="light" aria-hidden="true">
<IconDatabaseImport size={18} />
</ThemeIcon>
) : (
<Loader size="sm" color="blue" aria-hidden="true" />
)}
<div>
<Text size="sm" fw={600}>
{isTimedOut ? t("timeout.title") : t("progress.title")}
</Text>
<Text size="xs" c="dimmed">
{statusLabel}
</Text>
</div>
</Group>
<Box pl={4}>
<Timeline active={currentStepIdx} bulletSize={24} lineWidth={2} color="blue">
{steps.map((step, i) => {
const isDone = i < currentStepIdx;
const isActive = i === currentStepIdx;
const isMigration = step.key.startsWith("migration-");
return (
<Timeline.Item
key={step.key}
bullet={
isDone ? (
<ThemeIcon size={24} radius="xl" color="green" variant="filled">
<IconCheck size={14} />
</ThemeIcon>
) : isActive ? (
<ThemeIcon size={24} radius="xl" color="blue" variant="filled">
<Loader size={12} color="white" />
</ThemeIcon>
) : (
<ThemeIcon size={24} radius="xl" color="gray" variant="light">
<IconLoader size={14} />
</ThemeIcon>
)
}
>
<Group gap="xs" mt={-4}>
<step.icon
size={16}
color={
isDone
? "var(--mantine-color-green-6)"
: isActive
? "var(--mantine-color-blue-5)"
: "var(--mantine-color-dimmed)"
}
/>
<Text
size={isMigration ? "xs" : "sm"}
fw={isActive ? 600 : 400}
ff={isMigration ? "monospace" : undefined}
c={isDone ? "green" : isActive ? "white" : "dimmed"}
>
{step.label}
</Text>
</Group>
</Timeline.Item>
);
})}
</Timeline>
</Box>
{isTimedOut ? (
<Group>
<Button variant="light" onClick={onRetry} leftSection={<IconRefresh size={16} />}>
{t("tryAgain")}
</Button>
<Button variant="default" onClick={onReload}>
{tRoot("docker.action.refresh.label")}
</Button>
</Group>
) : null}
</Stack>
</Paper>
);