fix: harden onboarding completion
This commit is contained in:
1 parent
576cc0b418
commit
07770e862c
44 files changed
+3003
-1000
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user