Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e013c1fb | ||
|
|
786ad347a0 | ||
|
|
93ce4e4a1b | ||
|
|
4cf41f2c4c | ||
|
|
ab3c8b4d1f | ||
|
|
78ddc81b15 | ||
|
|
ad98f2d262 |
No files matched your search
@@ -5,21 +5,9 @@ import "@homarr/common/env";
|
|||||||
import "@homarr/core/infrastructure/logs/env";
|
import "@homarr/core/infrastructure/logs/env";
|
||||||
import "@homarr/docker/env";
|
import "@homarr/docker/env";
|
||||||
|
|
||||||
import { readFileSync } from "fs";
|
|
||||||
|
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
|
||||||
const homarrVersion = (() => {
|
|
||||||
for (const candidate of ["../../package.json", "./package.json"]) {
|
|
||||||
try {
|
|
||||||
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
||||||
if (pkg.name === "homarr") return pkg.version as string;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return "unknown";
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Package path does not work... so we need to use relative path
|
// Package path does not work... so we need to use relative path
|
||||||
const withNextIntl = createNextIntlPlugin({
|
const withNextIntl = createNextIntlPlugin({
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -29,9 +17,6 @@ const withNextIntl = createNextIntlPlugin({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
env: {
|
|
||||||
HOMARR_VERSION: homarrVersion,
|
|
||||||
},
|
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// react compiler breaks mantine-react-table, so disabled for now
|
// react compiler breaks mantine-react-table, so disabled for now
|
||||||
@@ -42,7 +27,7 @@ const nextConfig: NextConfig = {
|
|||||||
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
|
||||||
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
|
||||||
*/
|
*/
|
||||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||||
turbopackFileSystemCacheForDev: true,
|
turbopackFileSystemCacheForDev: true,
|
||||||
|
|||||||
@@ -72,13 +72,9 @@
|
|||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"dayjs": "catalog:",
|
"dayjs": "catalog:",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
|
||||||
"flag-icons": "catalog:",
|
"flag-icons": "catalog:",
|
||||||
"glob": "catalog:",
|
"glob": "catalog:",
|
||||||
"adm-zip": "catalog:",
|
|
||||||
"better-sqlite3": "catalog:",
|
|
||||||
"isomorphic-dompurify": "catalog:",
|
"isomorphic-dompurify": "catalog:",
|
||||||
"jszip": "catalog:",
|
|
||||||
"jotai": "catalog:",
|
"jotai": "catalog:",
|
||||||
"mantine-react-table": "catalog:",
|
"mantine-react-table": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
@@ -89,7 +85,6 @@
|
|||||||
"react-error-boundary": "catalog:",
|
"react-error-boundary": "catalog:",
|
||||||
"react-simple-code-editor": "catalog:",
|
"react-simple-code-editor": "catalog:",
|
||||||
"sass": "catalog:",
|
"sass": "catalog:",
|
||||||
"sql.js": "catalog:",
|
|
||||||
"superjson": "catalog:",
|
"superjson": "catalog:",
|
||||||
"swagger-ui-react": "catalog:",
|
"swagger-ui-react": "catalog:",
|
||||||
"use-deep-compare-effect": "catalog:",
|
"use-deep-compare-effect": "catalog:",
|
||||||
@@ -98,8 +93,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/adm-zip": "catalog:",
|
|
||||||
"@types/better-sqlite3": "catalog:",
|
|
||||||
"@types/chroma-js": "catalog:",
|
"@types/chroma-js": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/prismjs": "catalog:",
|
"@types/prismjs": "catalog:",
|
||||||
|
|||||||
Binary file not shown.
@@ -2,16 +2,13 @@ import { Stack, Text } from "@mantine/core";
|
|||||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { getMantineColor } from "@homarr/common";
|
import { getMantineColor } from "@homarr/common";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
import { InitStepCard } from "../../_components/init-step-card";
|
import { InitStepCard } from "../../_components/init-step-card";
|
||||||
import { InitSqliteRestore } from "./sqlite-restore";
|
|
||||||
import { InitStartButton } from "./next-button";
|
import { InitStartButton } from "./next-button";
|
||||||
|
|
||||||
export const InitStart = async () => {
|
export const InitStart = async () => {
|
||||||
const tStart = await getScopedI18n("init.step.start");
|
const tStart = await getScopedI18n("init.step.start");
|
||||||
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InitStepCard>
|
<InitStepCard>
|
||||||
@@ -30,7 +27,6 @@ export const InitStart = async () => {
|
|||||||
>
|
>
|
||||||
{tStart("action.importOldmarr")}
|
{tStart("action.importOldmarr")}
|
||||||
</InitStartButton>
|
</InitStartButton>
|
||||||
{isSqlite && <InitSqliteRestore />}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</InitStepCard>
|
</InitStepCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button, Collapse, Stack, Text } from "@mantine/core";
|
|
||||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { getMantineColor } from "@homarr/common";
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
|
||||||
|
|
||||||
export const InitSqliteRestore = () => {
|
|
||||||
const t = useScopedI18n("init.step.start.action");
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => setExpanded(false)}
|
|
||||||
>
|
|
||||||
{t("backToOptions")}
|
|
||||||
</Button>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("restoreSqliteDescription")}
|
|
||||||
</Text>
|
|
||||||
<DatabaseRestoreFlow variant="standalone" />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={() => setExpanded(true)}
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
|
|
||||||
>
|
|
||||||
{t("restoreSqlite")}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
IconBrandTablerFilled,
|
IconBrandTablerFilled,
|
||||||
IconCertificate,
|
IconCertificate,
|
||||||
IconClipboardListFilled,
|
IconClipboardListFilled,
|
||||||
IconDatabaseExport,
|
|
||||||
IconDirectionsFilled,
|
IconDirectionsFilled,
|
||||||
IconGitFork,
|
IconGitFork,
|
||||||
IconHelpSquareRoundedFilled,
|
IconHelpSquareRoundedFilled,
|
||||||
@@ -29,7 +28,6 @@ import {
|
|||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
import { isProviderEnabled } from "@homarr/auth/server";
|
import { isProviderEnabled } from "@homarr/auth/server";
|
||||||
import { createDocumentationLink } from "@homarr/definitions";
|
import { createDocumentationLink } from "@homarr/definitions";
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { env } from "@homarr/docker/env";
|
import { env } from "@homarr/docker/env";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
@@ -156,12 +154,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
href: "/manage/tools/tasks",
|
href: "/manage/tools/tasks",
|
||||||
hidden: !session?.user.permissions.includes("admin"),
|
hidden: !session?.user.permissions.includes("admin"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t("items.tools.items.backup"),
|
|
||||||
icon: IconDatabaseExport,
|
|
||||||
href: "/manage/tools/backup",
|
|
||||||
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
|
|
||||||
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
|
|
||||||
|
|
||||||
export const BackupExportCard = () => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.export");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
|
||||||
|
|
||||||
const handleExport = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/backup/export");
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorMessage = await response.json().then(
|
|
||||||
(data) => data.error,
|
|
||||||
() => `Server returned ${response.status}`,
|
|
||||||
);
|
|
||||||
throw new Error(errorMessage ?? t("error"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
|
|
||||||
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
setShowConfirm(false);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : t("error"));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group gap="sm">
|
|
||||||
<IconDatabaseExport size={24} />
|
|
||||||
<Title order={4}>{t("title")}</Title>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("description")}
|
|
||||||
</Text>
|
|
||||||
{error && (
|
|
||||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showConfirm ? (
|
|
||||||
<DangerousActionConfirmation
|
|
||||||
title={t("confirm.title")}
|
|
||||||
warningTitle={t("confirm.warningTitle")}
|
|
||||||
warningBody={t("confirm.warningBody")}
|
|
||||||
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
|
|
||||||
submitLabel={t("confirm.submit")}
|
|
||||||
submitIcon={<IconDownload size={16} />}
|
|
||||||
cancelLabel={t("confirm.cancel")}
|
|
||||||
onConfirm={handleExport}
|
|
||||||
onCancel={() => setShowConfirm(false)}
|
|
||||||
disabled={loading}
|
|
||||||
color="orange"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Group>
|
|
||||||
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
|
|
||||||
{t("button")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Card, Group, Stack, Text, Title } from "@mantine/core";
|
|
||||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DatabaseRestoreFlow } from "~/components/backup";
|
|
||||||
|
|
||||||
export const BackupImportCard = () => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.import");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group gap="sm">
|
|
||||||
<IconDatabaseImport size={24} />
|
|
||||||
<Title order={4}>{t("title")}</Title>
|
|
||||||
</Group>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("description")}
|
|
||||||
</Text>
|
|
||||||
<DatabaseRestoreFlow />
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { Stack, Title } from "@mantine/core";
|
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
|
||||||
|
|
||||||
import { createMetaTitle } from "~/metadata";
|
|
||||||
import { BackupExportCard } from "./_components/backup-export-card";
|
|
||||||
import { BackupImportCard } from "./_components/backup-import-card";
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await getScopedI18n("management.page.tool.backup");
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: createMetaTitle(t("metaTitle")),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BackupPage() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await getScopedI18n("management.page.tool.backup");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Title>{t("title")}</Title>
|
|
||||||
<BackupExportCard />
|
|
||||||
<BackupImportCard />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import AdmZip from "adm-zip";
|
|
||||||
import Database from "better-sqlite3";
|
|
||||||
|
|
||||||
import { auth } from "@homarr/auth/next";
|
|
||||||
import { env } from "@homarr/common/env";
|
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user.permissions.includes("admin")) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dbEnv.DRIVER !== "better-sqlite3") {
|
|
||||||
return NextResponse.json({ error: "SQLite backup is only available for SQLite databases" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbPath = dbEnv.URL as string | undefined;
|
|
||||||
if (!dbPath || !fs.existsSync(dbPath)) {
|
|
||||||
return NextResponse.json({ error: "Database file not found" }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempPath = path.join(path.dirname(dbPath), "db.backup.sqlite");
|
|
||||||
let sourceDb: InstanceType<typeof Database> | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
sourceDb = new Database(dbPath, { readonly: true });
|
|
||||||
sourceDb.pragma("wal_checkpoint(TRUNCATE)");
|
|
||||||
sourceDb.exec(`VACUUM INTO '${tempPath}'`);
|
|
||||||
sourceDb.close();
|
|
||||||
sourceDb = null;
|
|
||||||
|
|
||||||
const dbBuffer = fs.readFileSync(tempPath);
|
|
||||||
|
|
||||||
const metadata = {
|
|
||||||
homarrVersion: process.env.HOMARR_VERSION ?? "unknown",
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
dbDialect: "sqlite",
|
|
||||||
encryptionKey: env.SECRET_ENCRYPTION_KEY,
|
|
||||||
};
|
|
||||||
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("db.sqlite", dbBuffer);
|
|
||||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata, null, 2)));
|
|
||||||
|
|
||||||
const zipBuffer = zip.toBuffer();
|
|
||||||
|
|
||||||
const date = new Date().toISOString().split("T")[0];
|
|
||||||
const filename = `homarr-backup-${date}.zip`;
|
|
||||||
|
|
||||||
return new NextResponse(zipBuffer, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/zip",
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
"Content-Length": zipBuffer.length.toString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} 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();
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
||||||
} catch (cleanupErr) {
|
|
||||||
console.error("[backup/export] Failed to clean up temp file:", cleanupErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
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 { auth } from "@homarr/auth/next";
|
|
||||||
import { env } from "@homarr/common/env";
|
|
||||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
|
||||||
import { dbEnv } from "@homarr/core/infrastructure/db/env";
|
|
||||||
import { db } from "@homarr/db";
|
|
||||||
|
|
||||||
import { findMigrationsFolder } from "../shared";
|
|
||||||
|
|
||||||
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
|
|
||||||
const ALGORITHM = "aes-256-cbc";
|
|
||||||
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
|
|
||||||
|
|
||||||
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
|
|
||||||
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
|
|
||||||
if (importedKeyHex === currentKeyHex) return;
|
|
||||||
|
|
||||||
if (!HEX_KEY_REGEX.test(importedKeyHex)) {
|
|
||||||
throw new Error("Imported encryption key is not a valid 64-character hex string");
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldKey = Buffer.from(importedKeyHex, "hex");
|
|
||||||
const newKey = Buffer.from(currentKeyHex, "hex");
|
|
||||||
|
|
||||||
const rows = tempDb.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"').all() as {
|
|
||||||
integration_id: string;
|
|
||||||
kind: string;
|
|
||||||
value: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
const updateStmt = tempDb.prepare('UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?');
|
|
||||||
const transaction = tempDb.transaction(() => {
|
|
||||||
for (const row of rows) {
|
|
||||||
const parts = row.value.split(".");
|
|
||||||
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 {
|
|
||||||
const iv = Buffer.from(dataIv, "hex");
|
|
||||||
const encrypted = Buffer.from(data, "hex");
|
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, oldKey, iv);
|
|
||||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
|
||||||
|
|
||||||
const newIv = crypto.randomBytes(16);
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, newKey, newIv);
|
|
||||||
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
|
|
||||||
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
|
|
||||||
|
|
||||||
updateStmt.run(newValue, row.integration_id, row.kind);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Failed to re-encrypt secret for integration ${row.integration_id} (${row.kind})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
transaction();
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOnboardingActiveAsync = async (): Promise<boolean> => {
|
|
||||||
const onboardingRow = await db.query.onboarding.findFirst();
|
|
||||||
if (!onboardingRow) return false;
|
|
||||||
return onboardingRow.step === "start";
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const session = await auth();
|
|
||||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
|
||||||
const isOnboarding = !isAdmin && (await isOnboardingActiveAsync());
|
|
||||||
|
|
||||||
if (!isAdmin && !isOnboarding) {
|
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dbEnv.DRIVER !== "better-sqlite3") {
|
|
||||||
return NextResponse.json({ error: "SQLite restore is only available for SQLite databases" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbPath = dbEnv.URL as string | undefined;
|
|
||||||
if (!dbPath) {
|
|
||||||
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");
|
|
||||||
let tempDb: InstanceType<typeof Database> | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dbEntry = zip.getEntry("db.sqlite")!;
|
|
||||||
fs.writeFileSync(tempPath, dbEntry.getData());
|
|
||||||
|
|
||||||
tempDb = new Database(tempPath);
|
|
||||||
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
|
|
||||||
migrate(drizzleDb, { migrationsFolder });
|
|
||||||
|
|
||||||
const metadataEntry = zip.getEntry("metadata.json")!;
|
|
||||||
const metadata = JSON.parse(metadataEntry.getData().toString());
|
|
||||||
|
|
||||||
const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim();
|
|
||||||
const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
|
|
||||||
|
|
||||||
const secretCount = (
|
|
||||||
tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number }
|
|
||||||
).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);
|
|
||||||
}
|
|
||||||
|
|
||||||
tempDb.close();
|
|
||||||
tempDb = null;
|
|
||||||
|
|
||||||
fs.renameSync(tempPath, dbPath);
|
|
||||||
|
|
||||||
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...");
|
|
||||||
process.exit(0);
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
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 {
|
|
||||||
tempDb?.close();
|
|
||||||
if (fs.existsSync(tempPath)) {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(tempPath);
|
|
||||||
} catch {
|
|
||||||
// temp file may have been renamed already
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import { findMigrationsFolder } from "../shared";
|
|
||||||
|
|
||||||
interface JournalEntry {
|
|
||||||
idx: number;
|
|
||||||
version: string;
|
|
||||||
when: number;
|
|
||||||
tag: string;
|
|
||||||
breakpoints: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MigrationFile {
|
|
||||||
idx: number;
|
|
||||||
tag: string;
|
|
||||||
sql: string;
|
|
||||||
when: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
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 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;
|
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
const migrationsFolderCandidates = [
|
|
||||||
path.join(process.cwd(), "db/migrations/sqlite"),
|
|
||||||
path.join(process.cwd(), "packages/db/migrations/sqlite"),
|
|
||||||
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
|
|
||||||
"/app/db/migrations/sqlite",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const findMigrationsFolder = () =>
|
|
||||||
migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
const wasmCandidates = [
|
|
||||||
path.join(process.cwd(), "public/sql-wasm.wasm"),
|
|
||||||
path.join(process.cwd(), "apps/nextjs/public/sql-wasm.wasm"),
|
|
||||||
path.resolve(process.cwd(), "../../node_modules/sql.js/dist/sql-wasm.wasm"),
|
|
||||||
path.join(process.cwd(), "node_modules/sql.js/dist/sql-wasm.wasm"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let cachedWasm: Buffer | null = null;
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
if (!cachedWasm) {
|
|
||||||
const found = wasmCandidates.find((p) => fs.existsSync(p));
|
|
||||||
if (!found) {
|
|
||||||
return NextResponse.json({ error: "WASM binary not found" }, { status: 500 });
|
|
||||||
}
|
|
||||||
cachedWasm = fs.readFileSync(found);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(cachedWasm, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/wasm",
|
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
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 } from "vitest";
|
|
||||||
|
|
||||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
|
||||||
|
|
||||||
const ALGORITHM = "aes-256-cbc";
|
|
||||||
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
|
|
||||||
|
|
||||||
const generateHexKey = () => crypto.randomBytes(32).toString("hex");
|
|
||||||
|
|
||||||
const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => {
|
|
||||||
const key = Buffer.from(keyHex, "hex");
|
|
||||||
const iv = crypto.randomBytes(16);
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
||||||
let encrypted = cipher.update(text);
|
|
||||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
||||||
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => {
|
|
||||||
const key = Buffer.from(keyHex, "hex");
|
|
||||||
const [data, dataIv] = value.split(".") as [string, string];
|
|
||||||
const iv = Buffer.from(dataIv, "hex");
|
|
||||||
const encrypted = Buffer.from(data, "hex");
|
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
||||||
let decrypted = decipher.update(encrypted);
|
|
||||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
||||||
return decrypted.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const createTestDb = (dbPath: string) => {
|
|
||||||
const sqlite = new Database(dbPath);
|
|
||||||
const db = drizzle(sqlite, { casing: DB_CASING });
|
|
||||||
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
||||||
return { db, sqlite };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("SQLite backup", () => {
|
|
||||||
let tmpDir: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("export ZIP structure", () => {
|
|
||||||
it("should create a ZIP containing all required entries", () => {
|
|
||||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
||||||
const { sqlite } = createTestDb(dbPath);
|
|
||||||
|
|
||||||
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
|
||||||
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
|
||||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
|
||||||
sqlite.close();
|
|
||||||
|
|
||||||
const dbBuffer = fs.readFileSync(backupPath);
|
|
||||||
const metadata = {
|
|
||||||
homarrVersion: "test",
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
dbDialect: "sqlite",
|
|
||||||
encryptionKey: generateHexKey(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("db.sqlite", dbBuffer);
|
|
||||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata)));
|
|
||||||
|
|
||||||
const zipPath = path.join(tmpDir, "backup.zip");
|
|
||||||
zip.writeZip(zipPath);
|
|
||||||
|
|
||||||
const readBack = new AdmZip(zipPath);
|
|
||||||
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
|
|
||||||
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
|
|
||||||
|
|
||||||
const dbEntry = readBack.getEntry("db.sqlite");
|
|
||||||
expect(dbEntry).toBeDefined();
|
|
||||||
expect(dbEntry!.header.size).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should produce a valid SQLite database in the ZIP", () => {
|
|
||||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
||||||
const { sqlite } = createTestDb(dbPath);
|
|
||||||
|
|
||||||
const backupPath = path.join(tmpDir, "db.backup.sqlite");
|
|
||||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
|
||||||
sqlite.close();
|
|
||||||
|
|
||||||
const backupDb = new Database(backupPath, { readonly: true });
|
|
||||||
const tables = backupDb
|
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
||||||
.all() as { name: string }[];
|
|
||||||
|
|
||||||
expect(tables.map((t) => t.name)).toContain("board");
|
|
||||||
expect(tables.map((t) => t.name)).toContain("user");
|
|
||||||
expect(tables.map((t) => t.name)).toContain("integrationSecret");
|
|
||||||
|
|
||||||
backupDb.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("import ZIP validation", () => {
|
|
||||||
it("should detect missing required entries", () => {
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
|
||||||
|
|
||||||
const entries = zip.getEntries().map((e) => e.entryName);
|
|
||||||
const required = ["db.sqlite", "metadata.json"];
|
|
||||||
const missing = required.filter((name) => !entries.includes(name));
|
|
||||||
|
|
||||||
expect(missing).toEqual(["metadata.json"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept valid backup ZIP with all entries", () => {
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
|
||||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() })));
|
|
||||||
|
|
||||||
const entries = zip.getEntries().map((e) => e.entryName);
|
|
||||||
const required = ["db.sqlite", "metadata.json"];
|
|
||||||
const missing = required.filter((name) => !entries.includes(name));
|
|
||||||
|
|
||||||
expect(missing).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept legacy backup ZIP with encryption-key.txt", () => {
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("db.sqlite", Buffer.from("dummy"));
|
|
||||||
zip.addFile("encryption-key.txt", Buffer.from(generateHexKey()));
|
|
||||||
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" })));
|
|
||||||
|
|
||||||
const entries = zip.getEntries().map((e) => e.entryName);
|
|
||||||
const required = ["db.sqlite", "metadata.json"];
|
|
||||||
const missing = required.filter((name) => !entries.includes(name));
|
|
||||||
|
|
||||||
expect(missing).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("secret re-encryption", () => {
|
|
||||||
it("should re-encrypt secrets from old key to new key", () => {
|
|
||||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
||||||
const { sqlite } = createTestDb(dbPath);
|
|
||||||
|
|
||||||
const oldKey = generateHexKey();
|
|
||||||
const newKey = generateHexKey();
|
|
||||||
const plaintext = "my-secret-password";
|
|
||||||
|
|
||||||
const encryptedValue = encryptWithKey(plaintext, oldKey);
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO "integration" ("id", "name", "url", "kind")
|
|
||||||
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
|
||||||
`);
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
|
||||||
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
|
||||||
`);
|
|
||||||
|
|
||||||
const decryptedBefore = decryptWithKey(encryptedValue, oldKey);
|
|
||||||
expect(decryptedBefore).toBe(plaintext);
|
|
||||||
|
|
||||||
const oldKeyBuf = Buffer.from(oldKey, "hex");
|
|
||||||
const newKeyBuf = Buffer.from(newKey, "hex");
|
|
||||||
|
|
||||||
const rows = sqlite
|
|
||||||
.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"')
|
|
||||||
.all() as { integration_id: string; kind: string; value: string }[];
|
|
||||||
|
|
||||||
const updateStmt = sqlite.prepare(
|
|
||||||
'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?',
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const [data, dataIv] = row.value.split(".") as [string, string];
|
|
||||||
const iv = Buffer.from(dataIv, "hex");
|
|
||||||
const encrypted = Buffer.from(data, "hex");
|
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv);
|
|
||||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
|
|
||||||
|
|
||||||
const newIv = crypto.randomBytes(16);
|
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv);
|
|
||||||
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
|
|
||||||
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
|
|
||||||
|
|
||||||
updateStmt.run(newValue, row.integration_id, row.kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedRow = sqlite
|
|
||||||
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
|
||||||
.get("int-1") as { value: string };
|
|
||||||
|
|
||||||
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
|
|
||||||
expect(decryptedAfter).toBe(plaintext);
|
|
||||||
|
|
||||||
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
|
|
||||||
|
|
||||||
sqlite.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should skip re-encryption when keys match", () => {
|
|
||||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
||||||
const { sqlite } = createTestDb(dbPath);
|
|
||||||
|
|
||||||
const sameKey = generateHexKey();
|
|
||||||
const plaintext = "my-secret";
|
|
||||||
const encryptedValue = encryptWithKey(plaintext, sameKey);
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO "integration" ("id", "name", "url", "kind")
|
|
||||||
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
|
|
||||||
`);
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
|
|
||||||
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
|
|
||||||
`);
|
|
||||||
|
|
||||||
const rowBefore = sqlite
|
|
||||||
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
|
|
||||||
.get("int-1") as { value: string };
|
|
||||||
|
|
||||||
expect(rowBefore.value).toBe(encryptedValue);
|
|
||||||
|
|
||||||
const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey);
|
|
||||||
expect(decrypted).toBe(plaintext);
|
|
||||||
|
|
||||||
sqlite.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("VACUUM INTO consistency", () => {
|
|
||||||
it("should produce a consistent copy via VACUUM INTO", () => {
|
|
||||||
const dbPath = path.join(tmpDir, "db.sqlite");
|
|
||||||
const { sqlite } = createTestDb(dbPath);
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO "app" ("id", "name", "icon_url")
|
|
||||||
VALUES ('app-1', 'TestApp', 'https://example.com/icon.png')
|
|
||||||
`);
|
|
||||||
|
|
||||||
const backupPath = path.join(tmpDir, "db.vacuum.sqlite");
|
|
||||||
sqlite.pragma("wal_checkpoint(TRUNCATE)");
|
|
||||||
sqlite.exec(`VACUUM INTO '${backupPath}'`);
|
|
||||||
|
|
||||||
const backupDb = new Database(backupPath, { readonly: true });
|
|
||||||
const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined;
|
|
||||||
|
|
||||||
expect(app).toBeDefined();
|
|
||||||
expect(app!.name).toBe("TestApp");
|
|
||||||
|
|
||||||
backupDb.close();
|
|
||||||
sqlite.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("migration on imported DB", () => {
|
|
||||||
it("should successfully migrate an imported database", () => {
|
|
||||||
const sourcePath = path.join(tmpDir, "source.sqlite");
|
|
||||||
const { sqlite: sourceDb } = createTestDb(sourcePath);
|
|
||||||
|
|
||||||
sourceDb.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 ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0)
|
|
||||||
`);
|
|
||||||
sourceDb.close();
|
|
||||||
|
|
||||||
const importPath = path.join(tmpDir, "import.sqlite");
|
|
||||||
fs.copyFileSync(sourcePath, importPath);
|
|
||||||
|
|
||||||
const importDb = new Database(importPath);
|
|
||||||
const drizzleDb = drizzle(importDb, { casing: DB_CASING });
|
|
||||||
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
|
|
||||||
|
|
||||||
const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined;
|
|
||||||
expect(board).toBeDefined();
|
|
||||||
expect(board!.name).toBe("TestBoard");
|
|
||||||
|
|
||||||
importDb.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("encryption key validation", () => {
|
|
||||||
it("should reject invalid hex keys", () => {
|
|
||||||
const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""];
|
|
||||||
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
|
||||||
|
|
||||||
for (const key of invalidKeys) {
|
|
||||||
expect(hexKeyRegex.test(key)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept valid 64-char hex keys", () => {
|
|
||||||
const validKey = generateHexKey();
|
|
||||||
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
|
|
||||||
expect(hexKeyRegex.test(validKey)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core";
|
|
||||||
import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { BackupAnalysis } from "./types";
|
|
||||||
import { PREVIEW_TABLE_KEYS } from "./types";
|
|
||||||
|
|
||||||
interface BackupPreviewPanelProps {
|
|
||||||
analysis: BackupAnalysis;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.restore.preview");
|
|
||||||
const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString();
|
|
||||||
const pendingCount = analysis.migrations.pending.length;
|
|
||||||
const hasPendingMigrations = pendingCount > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Paper p="md" radius="md" withBorder>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group gap="sm">
|
|
||||||
<ThemeIcon variant="light" color="blue" size="lg" radius="xl">
|
|
||||||
<IconDatabase size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Title order={5}>{t("title")}</Title>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t("exported", { date: exportDate })}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Badge variant="light" color="gray" ml="auto">
|
|
||||||
Homarr {analysis.metadata.homarrVersion}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Table striped withTableBorder>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t("entity")}</Table.Th>
|
|
||||||
<Table.Th ta="right">{t("count")}</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{PREVIEW_TABLE_KEYS.map((key) => (
|
|
||||||
<Table.Tr key={key}>
|
|
||||||
<Table.Td>{t(`entities.${key}` as never)}</Table.Td>
|
|
||||||
<Table.Td ta="right">
|
|
||||||
<Group gap={4} justify="flex-end">
|
|
||||||
{(analysis.counts[key] ?? 0) > 0 && (
|
|
||||||
<IconCheck size={14} color="var(--mantine-color-green-6)" />
|
|
||||||
)}
|
|
||||||
<Text size="sm" ff="monospace">{analysis.counts[key] ?? 0}</Text>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
{analysis.boardNames.length > 0 && (
|
|
||||||
<Group gap="xs" wrap="wrap">
|
|
||||||
<Text size="sm" fw={500}>{t("boards")}:</Text>
|
|
||||||
{analysis.boardNames.map((name) => (
|
|
||||||
<Badge key={name} variant="light" size="sm">{name}</Badge>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{hasPendingMigrations && (
|
|
||||||
<Paper p="md" radius="md" withBorder bd="1px solid var(--mantine-color-orange-8)">
|
|
||||||
<Group gap="sm">
|
|
||||||
<ThemeIcon variant="light" color="orange" size="lg" radius="xl">
|
|
||||||
<IconGitBranch size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{pendingCount === 1
|
|
||||||
? t("migrationsApplied", { count: String(pendingCount) })
|
|
||||||
: t("migrationsAppliedPlural", { count: String(pendingCount) })}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{pendingCount === 1
|
|
||||||
? t("migrationsOlderVersion", { count: String(pendingCount) })
|
|
||||||
: t("migrationsOlderVersionPlural", { count: String(pendingCount) })}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Badge variant="light" color="orange" ml="auto">
|
|
||||||
{t("migrationsPending", { count: String(pendingCount) })}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasPendingMigrations && (
|
|
||||||
<Paper p="md" radius="md" withBorder>
|
|
||||||
<Group gap="sm">
|
|
||||||
<ThemeIcon variant="light" color="green" size="lg" radius="xl">
|
|
||||||
<IconGitBranch size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" fw={500} c="dimmed">
|
|
||||||
{t("migrationsUpToDate", { count: String(analysis.migrations.total) })}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Alert, Box, Button, Group, Paper, Stack, Text, TextInput } from "@mantine/core";
|
|
||||||
import { IconAlertTriangle, IconShieldExclamation } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
const CONFIRMATION_PHRASE = "I understand";
|
|
||||||
|
|
||||||
interface DangerousActionConfirmationProps {
|
|
||||||
title: string;
|
|
||||||
warningTitle: string;
|
|
||||||
warningBody: string;
|
|
||||||
typePrompt: string;
|
|
||||||
submitLabel: string;
|
|
||||||
submitIcon: ReactNode;
|
|
||||||
cancelLabel: string;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DangerousActionConfirmation = ({
|
|
||||||
title,
|
|
||||||
warningTitle,
|
|
||||||
warningBody,
|
|
||||||
typePrompt,
|
|
||||||
submitLabel,
|
|
||||||
submitIcon,
|
|
||||||
cancelLabel,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
disabled,
|
|
||||||
color = "red",
|
|
||||||
}: DangerousActionConfirmationProps) => {
|
|
||||||
const [value, setValue] = useState("");
|
|
||||||
const isMatch = value.trim().toLowerCase() === CONFIRMATION_PHRASE.toLowerCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper p="lg" radius="md" withBorder bd={`1px solid var(--mantine-color-${color}-9)`}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group gap="sm">
|
|
||||||
<IconShieldExclamation size={24} color={`var(--mantine-color-${color}-6)`} />
|
|
||||||
<Text size="lg" fw={700} c={color}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Alert color={color} variant="light" icon={<IconAlertTriangle size={20} />}>
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Text size="sm" fw={600}>{warningTitle}</Text>
|
|
||||||
<Text size="sm">{warningBody}</Text>
|
|
||||||
</Stack>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Text size="sm" fw={500} mb={4}>
|
|
||||||
{typePrompt}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.currentTarget.value)}
|
|
||||||
placeholder={CONFIRMATION_PHRASE}
|
|
||||||
disabled={disabled}
|
|
||||||
styles={{
|
|
||||||
input: {
|
|
||||||
fontFamily: "var(--mantine-font-family-monospace)",
|
|
||||||
borderColor: isMatch ? "var(--mantine-color-green-6)" : undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="subtle" color="gray" onClick={onCancel} disabled={disabled}>
|
|
||||||
{cancelLabel}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color={color}
|
|
||||||
leftSection={submitIcon}
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={!isMatch || disabled}
|
|
||||||
>
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, 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 { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { RestoreStep } from "./types";
|
|
||||||
import { BackupPreviewPanel } from "./backup-preview-panel";
|
|
||||||
import { MigrationProgressPanel } from "./migration-progress-panel";
|
|
||||||
import { RestoreConfirmation } from "./restore-confirmation";
|
|
||||||
import { RestoreProgressPanel } from "./restore-progress-panel";
|
|
||||||
import { useBackupAnalysis } from "./use-backup-analysis";
|
|
||||||
|
|
||||||
interface DatabaseRestoreFlowProps {
|
|
||||||
variant?: "card" | "standalone";
|
|
||||||
onRestoreComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
|
|
||||||
|
|
||||||
const handleFileDrop = useCallback(
|
|
||||||
(files: FileWithPath[]) => {
|
|
||||||
const droppedFile = files[0];
|
|
||||||
if (!droppedFile) return;
|
|
||||||
setFile(droppedFile);
|
|
||||||
setStep("preview");
|
|
||||||
setImportError(null);
|
|
||||||
void analyzeFile(droppedFile);
|
|
||||||
},
|
|
||||||
[analyzeFile],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
|
||||||
setFile(null);
|
|
||||||
setStep("upload");
|
|
||||||
setImportError(null);
|
|
||||||
apiDoneRef.current = false;
|
|
||||||
animDoneRef.current = false;
|
|
||||||
apiErrorRef.current = null;
|
|
||||||
reset();
|
|
||||||
}, [reset]);
|
|
||||||
|
|
||||||
const tryFinalize = useCallback(() => {
|
|
||||||
if (!apiDoneRef.current) return;
|
|
||||||
|
|
||||||
if (apiErrorRef.current) {
|
|
||||||
setImportError(apiErrorRef.current);
|
|
||||||
setStep("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!animDoneRef.current) return;
|
|
||||||
onRestoreComplete?.();
|
|
||||||
window.location.reload();
|
|
||||||
}, [onRestoreComplete]);
|
|
||||||
|
|
||||||
const handleConfirm = useCallback(async () => {
|
|
||||||
if (!file) return;
|
|
||||||
setStep("restoring");
|
|
||||||
setImportError(null);
|
|
||||||
apiDoneRef.current = false;
|
|
||||||
animDoneRef.current = false;
|
|
||||||
apiErrorRef.current = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
const response = await fetch("/api/backup/import", { method: "POST", body: formData });
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
try {
|
|
||||||
const data = await response.json();
|
|
||||||
apiErrorRef.current = data.error ?? t("failed.title");
|
|
||||||
} catch {
|
|
||||||
apiErrorRef.current = `Server returned ${response.status}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
apiErrorRef.current = t("failed.title");
|
|
||||||
} finally {
|
|
||||||
apiDoneRef.current = true;
|
|
||||||
tryFinalize();
|
|
||||||
}
|
|
||||||
}, [file, t, tryFinalize]);
|
|
||||||
|
|
||||||
const handleAnimationComplete = useCallback(() => {
|
|
||||||
animDoneRef.current = true;
|
|
||||||
tryFinalize();
|
|
||||||
}, [tryFinalize]);
|
|
||||||
|
|
||||||
if (step === "upload") {
|
|
||||||
return (
|
|
||||||
<Dropzone
|
|
||||||
onDrop={handleFileDrop}
|
|
||||||
acceptColor="blue.6"
|
|
||||||
rejectColor="red.6"
|
|
||||||
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
|
|
||||||
multiple={false}
|
|
||||||
maxSize={1024 * 1024 * 256}
|
|
||||||
radius="md"
|
|
||||||
>
|
|
||||||
<Group justify="center" gap="xl" mih={variant === "standalone" ? 200 : 160} style={{ pointerEvents: "none" }}>
|
|
||||||
<Dropzone.Accept>
|
|
||||||
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
|
|
||||||
</Dropzone.Accept>
|
|
||||||
<Dropzone.Reject>
|
|
||||||
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
|
|
||||||
</Dropzone.Reject>
|
|
||||||
<Dropzone.Idle>
|
|
||||||
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
|
|
||||||
</Dropzone.Idle>
|
|
||||||
<div>
|
|
||||||
<Text size="xl" inline>
|
|
||||||
{t("dropzone.title")}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed" inline mt={7}>
|
|
||||||
{t("dropzone.description")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Dropzone>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === "preview") {
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Group gap="sm">
|
|
||||||
<IconFileZip size={20} />
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500}>{file?.name}</Text>
|
|
||||||
<Text size="xs" c="dimmed">{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Button variant="subtle" size="xs" onClick={handleClear}>
|
|
||||||
{t("changeFile")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{loading && migrationProgress && (
|
|
||||||
<MigrationProgressPanel progress={migrationProgress} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && !migrationProgress && (
|
|
||||||
<Group justify="center" py="xl">
|
|
||||||
<Loader size="sm" />
|
|
||||||
<Text size="sm" c="dimmed">{t("analyzing")}</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{analysisError && (
|
|
||||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("analysisError")}>
|
|
||||||
{analysisError}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{analysis && (
|
|
||||||
<>
|
|
||||||
<BackupPreviewPanel analysis={analysis} />
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button
|
|
||||||
rightSection={<IconArrowRight size={16} />}
|
|
||||||
onClick={() => setStep("confirm")}
|
|
||||||
>
|
|
||||||
{t("continueToRestore")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === "confirm") {
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
{analysis && <BackupPreviewPanel analysis={analysis} />}
|
|
||||||
<RestoreConfirmation
|
|
||||||
onConfirm={handleConfirm}
|
|
||||||
onCancel={() => setStep("preview")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === "restoring") {
|
|
||||||
return (
|
|
||||||
<RestoreProgressPanel
|
|
||||||
active
|
|
||||||
migrations={analysis?.migrations.pending ?? []}
|
|
||||||
onComplete={handleAnimationComplete}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step === "error") {
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("failed.title")} radius="md">
|
|
||||||
<Text size="sm">{importError}</Text>
|
|
||||||
</Alert>
|
|
||||||
<Group>
|
|
||||||
<Button variant="subtle" onClick={handleClear}>
|
|
||||||
{t("tryAgain")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { DatabaseRestoreFlow } from "./database-restore-flow";
|
|
||||||
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
|
||||||
export type { BackupAnalysis, RestoreStep } from "./types";
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
|
||||||
import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { MigrationProgress } from "./use-backup-analysis";
|
|
||||||
|
|
||||||
interface MigrationProgressPanelProps {
|
|
||||||
progress: MigrationProgress;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.restore.migration");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper p="md" radius="md" bg="var(--mantine-color-dark-7)">
|
|
||||||
<Stack gap="sm">
|
|
||||||
<Group gap="xs">
|
|
||||||
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
|
|
||||||
<IconDatabase size={12} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("title", { current: String(progress.current), total: String(progress.total) })}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Box pl={4}>
|
|
||||||
<Timeline active={progress.current - 1} bulletSize={20} lineWidth={2} color="blue">
|
|
||||||
{Array.from({ length: progress.total }, (_, i) => {
|
|
||||||
const isDone = i < progress.current - 1 || (i === progress.current - 1 && progress.phase === "done");
|
|
||||||
const isActive = i === progress.current - 1 && progress.phase === "applying";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Timeline.Item
|
|
||||||
key={i}
|
|
||||||
bullet={
|
|
||||||
isDone ? (
|
|
||||||
<IconCheck size={12} />
|
|
||||||
) : isActive ? (
|
|
||||||
<Loader size={10} color="white" />
|
|
||||||
) : (
|
|
||||||
<IconLoader size={12} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<Text size="xs" ff="monospace" c={isDone ? "dimmed" : isActive ? "white" : "dimmed"}>
|
|
||||||
{i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Timeline>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { IconDatabaseImport } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
|
|
||||||
|
|
||||||
interface RestoreConfirmationProps {
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.restore.confirm");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DangerousActionConfirmation
|
|
||||||
title={t("title")}
|
|
||||||
warningTitle={t("warningTitle")}
|
|
||||||
warningBody={t("warningBody")}
|
|
||||||
typePrompt={t("typePrompt", { phrase: "I understand" })}
|
|
||||||
submitLabel={t("submit")}
|
|
||||||
submitIcon={<IconDatabaseImport size={16} />}
|
|
||||||
cancelLabel={t("cancel")}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
onCancel={onCancel}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"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 { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { MigrationFile } from "./types";
|
|
||||||
|
|
||||||
const BASE_PHASE_DURATIONS: Record<string, number> = {
|
|
||||||
extracting: 1200,
|
|
||||||
encrypting: 1500,
|
|
||||||
swapping: 800,
|
|
||||||
restarting: 10000,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MIGRATION_STEP_DURATION = 100;
|
|
||||||
|
|
||||||
interface TimelineStep {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
icon: Icon;
|
|
||||||
duration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RestoreProgressPanelProps {
|
|
||||||
active: boolean;
|
|
||||||
migrations: MigrationFile[];
|
|
||||||
onComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.restore.progress");
|
|
||||||
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
|
|
||||||
|
|
||||||
const steps = useMemo((): TimelineStep[] => {
|
|
||||||
const result: TimelineStep[] = [
|
|
||||||
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! },
|
|
||||||
];
|
|
||||||
|
|
||||||
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 (let i = 0; i < steps.length; i++) {
|
|
||||||
if (cancelled) return;
|
|
||||||
setCurrentStepIdx(i);
|
|
||||||
await new Promise((r) => setTimeout(r, steps[i]!.duration));
|
|
||||||
}
|
|
||||||
if (!cancelled) {
|
|
||||||
onComplete?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void run();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [active, onComplete, steps]);
|
|
||||||
|
|
||||||
if (!active && currentStepIdx === -1) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group gap="xs">
|
|
||||||
<Loader size="sm" color="blue" />
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{t("title")}
|
|
||||||
</Text>
|
|
||||||
</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>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
export interface BackupMetadata {
|
|
||||||
homarrVersion: string;
|
|
||||||
exportedAt: string;
|
|
||||||
dbDialect: string;
|
|
||||||
encryptionKey?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MigrationFile {
|
|
||||||
idx: number;
|
|
||||||
tag: string;
|
|
||||||
sql: string;
|
|
||||||
when: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MigrationStatus {
|
|
||||||
applied: number;
|
|
||||||
pending: MigrationFile[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackupAnalysis {
|
|
||||||
metadata: BackupMetadata;
|
|
||||||
counts: Record<string, number>;
|
|
||||||
boardNames: string[];
|
|
||||||
migrations: MigrationStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
|
|
||||||
|
|
||||||
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
|
||||||
|
|
||||||
import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types";
|
|
||||||
import { PREVIEW_TABLE_KEYS } from "./types";
|
|
||||||
|
|
||||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
|
||||||
|
|
||||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
const getAppliedMigrationCount = (db: any): number => {
|
|
||||||
try {
|
|
||||||
const result = db.exec(`SELECT COUNT(*) FROM "${DRIZZLE_MIGRATIONS_TABLE}"`);
|
|
||||||
return (result[0]?.values[0]?.[0] as number) ?? 0;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMigrations = async (): Promise<MigrationFile[]> => {
|
|
||||||
const res = await fetch("/api/backup/migrations");
|
|
||||||
if (!res.ok) throw new Error("Failed to fetch migration files from server");
|
|
||||||
const data = await res.json();
|
|
||||||
return data.migrations ?? [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const countEntities = (db: any): Record<string, number> => {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const key of PREVIEW_TABLE_KEYS) {
|
|
||||||
try {
|
|
||||||
const result = db.exec(`SELECT COUNT(*) FROM "${key}"`);
|
|
||||||
counts[key] = (result[0]?.values[0]?.[0] as number) ?? 0;
|
|
||||||
} catch {
|
|
||||||
counts[key] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBoardNames = (db: any): string[] => {
|
|
||||||
try {
|
|
||||||
const result = db.exec('SELECT "name" FROM "board" ORDER BY "name"');
|
|
||||||
return result[0]?.values.map((row: unknown[]) => row[0] as string) ?? [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface MigrationProgress {
|
|
||||||
current: number;
|
|
||||||
total: number;
|
|
||||||
tag: string;
|
|
||||||
phase: "pending" | "applying" | "done" | "error";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBackupAnalysis = () => {
|
|
||||||
const t = useScopedI18n("management.page.tool.backup.restore");
|
|
||||||
const [analysis, setAnalysis] = useState<BackupAnalysis | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [migrationProgress, setMigrationProgress] = useState<MigrationProgress | null>(null);
|
|
||||||
const dbRef = useRef<any>(null);
|
|
||||||
|
|
||||||
const analyzeFile = useCallback(async (file: File) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setAnalysis(null);
|
|
||||||
setMigrationProgress(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [JSZip, initSqlJs, allMigrations, wasmBinary] = await Promise.all([
|
|
||||||
import("jszip").then((m) => m.default),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
import("sql.js").then((m: any) => m.default),
|
|
||||||
fetchMigrations(),
|
|
||||||
fetch("/api/backup/sql-wasm").then((r) => {
|
|
||||||
if (!r.ok) throw new Error("Failed to load SQL WASM binary");
|
|
||||||
return r.arrayBuffer();
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
|
||||||
|
|
||||||
const metadataFile = zip.file("metadata.json");
|
|
||||||
if (!metadataFile) throw new Error(t("invalidMissingMetadata"));
|
|
||||||
const dbFile = zip.file("db.sqlite");
|
|
||||||
if (!dbFile) throw new Error(t("invalidMissingDb"));
|
|
||||||
|
|
||||||
const metadata = JSON.parse(await metadataFile.async("string"));
|
|
||||||
const dbBuffer = await dbFile.async("uint8array");
|
|
||||||
|
|
||||||
const SQL = await initSqlJs({ wasmBinary });
|
|
||||||
|
|
||||||
const db = new SQL.Database(dbBuffer);
|
|
||||||
dbRef.current = db;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const appliedCount = getAppliedMigrationCount(db);
|
|
||||||
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
|
|
||||||
|
|
||||||
const migrations: MigrationStatus = {
|
|
||||||
applied: appliedCount,
|
|
||||||
pending: pendingMigrations,
|
|
||||||
total: allMigrations.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (pendingMigrations.length > 0) {
|
|
||||||
for (let i = 0; i < pendingMigrations.length; i++) {
|
|
||||||
const migration = pendingMigrations[i]!;
|
|
||||||
setMigrationProgress({
|
|
||||||
current: i + 1,
|
|
||||||
total: pendingMigrations.length,
|
|
||||||
tag: migration.tag,
|
|
||||||
phase: "applying",
|
|
||||||
});
|
|
||||||
|
|
||||||
await delay(100);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statements = migration.sql
|
|
||||||
.split("--> statement-breakpoint")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
for (const stmt of statements) {
|
|
||||||
db.run(stmt);
|
|
||||||
}
|
|
||||||
|
|
||||||
db.run(
|
|
||||||
`INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`,
|
|
||||||
[migration.tag, Date.now()],
|
|
||||||
);
|
|
||||||
} catch (migrationErr) {
|
|
||||||
setMigrationProgress({
|
|
||||||
current: i + 1,
|
|
||||||
total: pendingMigrations.length,
|
|
||||||
tag: migration.tag,
|
|
||||||
phase: "error",
|
|
||||||
});
|
|
||||||
throw new Error(
|
|
||||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setMigrationProgress({
|
|
||||||
current: i + 1,
|
|
||||||
total: pendingMigrations.length,
|
|
||||||
tag: migration.tag,
|
|
||||||
phase: "done",
|
|
||||||
});
|
|
||||||
|
|
||||||
await delay(50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const counts = countEntities(db);
|
|
||||||
const boardNames = getBoardNames(db);
|
|
||||||
|
|
||||||
setMigrationProgress(null);
|
|
||||||
setAnalysis({ metadata, counts, boardNames, migrations });
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
dbRef.current = null;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : t("analyzeError"));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
if (dbRef.current) {
|
|
||||||
try { dbRef.current.close(); } catch { /* already closed */ }
|
|
||||||
dbRef.current = null;
|
|
||||||
}
|
|
||||||
setAnalysis(null);
|
|
||||||
setError(null);
|
|
||||||
setMigrationProgress(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { analysis, loading, error, migrationProgress, analyzeFile, reset };
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { objectEntries } from "@homarr/common";
|
||||||
|
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||||
|
import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||||
|
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
|
||||||
|
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
|
|
||||||
|
export type Translator = {
|
||||||
|
(key: string): string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface HoverMetadataItem {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxMetadataItems = 3;
|
||||||
|
|
||||||
|
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
|
||||||
|
app: true,
|
||||||
|
anchorNote: true,
|
||||||
|
dynamicSelect: true,
|
||||||
|
sortableItemList: true,
|
||||||
|
multiReleasesRepositories: true,
|
||||||
|
umamiEventName: true,
|
||||||
|
umamiEventNames: true,
|
||||||
|
umamiWebsite: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
|
||||||
|
type OptionDefinitionWithRules = WidgetOptionDefinition & {
|
||||||
|
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
|
||||||
|
options?: readonly { value: unknown; label: unknown }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
|
||||||
|
|
||||||
|
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
|
||||||
|
const match = definition.options?.find((option) => option.value === value);
|
||||||
|
const label = match?.label;
|
||||||
|
const fallbackLabel = () => String(value);
|
||||||
|
const labelResolvers: Record<string, () => string> = {
|
||||||
|
function: () => (label as (translator: Translator) => string)(t),
|
||||||
|
string: () => label as string,
|
||||||
|
missing: fallbackLabel,
|
||||||
|
};
|
||||||
|
const labelType = typeof label;
|
||||||
|
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
|
||||||
|
return resolveLabel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
|
||||||
|
switch: (value, _, t) => {
|
||||||
|
const switchLabels: Record<string, string> = {
|
||||||
|
true: t("common.action.show"),
|
||||||
|
false: t("common.action.hide"),
|
||||||
|
};
|
||||||
|
return switchLabels[String(Boolean(value))] ?? null;
|
||||||
|
},
|
||||||
|
text: (value) => {
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text.length > 0 ? text : null;
|
||||||
|
},
|
||||||
|
number: (value) => String(value),
|
||||||
|
slider: (value) => String(value),
|
||||||
|
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
|
||||||
|
multiSelect: (value) => {
|
||||||
|
const items = value as unknown[];
|
||||||
|
return items.length > 0 ? String(items.length) : null;
|
||||||
|
},
|
||||||
|
multiText: (value) => {
|
||||||
|
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
|
||||||
|
return items.length > 0 ? items.join(", ") : null;
|
||||||
|
},
|
||||||
|
location: (value) => {
|
||||||
|
const location = value as { name?: string };
|
||||||
|
const name = location.name?.trim() ?? "";
|
||||||
|
return name.length > 0 ? name : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatOptionValue = (
|
||||||
|
type: WidgetOptionType,
|
||||||
|
value: unknown,
|
||||||
|
definition: OptionDefinitionWithRules,
|
||||||
|
t: Translator,
|
||||||
|
) => {
|
||||||
|
const formatter = optionTypeFormatters[type];
|
||||||
|
return formatter?.(value, definition, t) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
|
||||||
|
const labelKey = `widget.${kind}.option.${key}.label`;
|
||||||
|
const translated = t(labelKey);
|
||||||
|
return translated === labelKey ? key : translated;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collectWidgetHoverMetadata = (
|
||||||
|
item: SectionItem,
|
||||||
|
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||||
|
integrationKinds: IntegrationKind[],
|
||||||
|
t: Translator,
|
||||||
|
): HoverMetadataItem[] => {
|
||||||
|
const { definition } = widgetImports[item.kind];
|
||||||
|
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
|
||||||
|
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||||
|
|
||||||
|
return objectEntries(optionDefinitions)
|
||||||
|
.filter(([key, optionDefinition]) => {
|
||||||
|
const skip = skippedOptionTypes[optionDefinition.type];
|
||||||
|
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
|
||||||
|
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
|
||||||
|
return !skip && !hidden && !unchanged;
|
||||||
|
})
|
||||||
|
.map(([key, optionDefinition]) => {
|
||||||
|
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
|
||||||
|
return {
|
||||||
|
label: getOptionLabel(item.kind, key, t),
|
||||||
|
value: formattedValue ?? "",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.value.length > 0)
|
||||||
|
.slice(0, maxMetadataItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DisplayNameSource {
|
||||||
|
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
|
||||||
|
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayNameSources: DisplayNameSource[] = [
|
||||||
|
{
|
||||||
|
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
|
||||||
|
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
matches: (item, options) =>
|
||||||
|
item.kind === "clock" &&
|
||||||
|
Boolean(options.customTitleToggle) &&
|
||||||
|
String(options.customTitle ?? "").trim().length > 0,
|
||||||
|
resolve: (_, options) => String(options.customTitle).trim(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const resolveWidgetDisplayName = (
|
||||||
|
item: SectionItem,
|
||||||
|
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
|
||||||
|
t: Translator,
|
||||||
|
) => {
|
||||||
|
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||||
|
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
|
||||||
|
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
.wrapper {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: calc(100% - 10px);
|
||||||
|
margin-bottom: 1px;
|
||||||
|
z-index: 200;
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
transition:
|
||||||
|
opacity 150ms ease,
|
||||||
|
transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
||||||
|
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
@mixin dark {
|
||||||
|
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
|
||||||
|
--border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
--background-color: rgb(from var(--mantine-color-white) r g b / 0.96);
|
||||||
|
--border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9);
|
||||||
|
}
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nameSection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgetIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrations {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.integrationAvatar {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadataItem {
|
||||||
|
@mixin dark {
|
||||||
|
--chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
--chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95);
|
||||||
|
}
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 140px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
background-color: var(--chip-background);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadataLabel {
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadataValue {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
|
import { getIconUrl } from "@homarr/definitions";
|
||||||
|
import { useSettings } from "@homarr/settings";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { widgetImports } from "@homarr/widgets";
|
||||||
|
|
||||||
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
|
import type { Translator } from "./widget-hover-metadata";
|
||||||
|
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
|
||||||
|
import classes from "./widget-hover-overlay.module.css";
|
||||||
|
|
||||||
|
interface WidgetHoverOverlayProps {
|
||||||
|
item: SectionItem;
|
||||||
|
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
|
||||||
|
const [isEditMode] = useEditMode();
|
||||||
|
const settings = useSettings();
|
||||||
|
const t = useI18n();
|
||||||
|
const { definition } = widgetImports[item.kind];
|
||||||
|
const WidgetIcon = definition.icon;
|
||||||
|
|
||||||
|
const translate = ((key: string) => t(key as never)) as Translator;
|
||||||
|
const displayName = resolveWidgetDisplayName(item, settings, translate);
|
||||||
|
|
||||||
|
const connectedIntegrations = (integrations ?? []).filter((integration) =>
|
||||||
|
item.integrationIds.includes(integration.id),
|
||||||
|
);
|
||||||
|
const metadataItems = collectWidgetHoverMetadata(
|
||||||
|
item,
|
||||||
|
settings,
|
||||||
|
connectedIntegrations.map((integration) => integration.kind),
|
||||||
|
translate,
|
||||||
|
);
|
||||||
|
const hasIntegrations = connectedIntegrations.length > 0;
|
||||||
|
const hasMetadata = metadataItems.length > 0;
|
||||||
|
|
||||||
|
if (isEditMode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.wrapper}>
|
||||||
|
<div className={classes.panel}>
|
||||||
|
<div className={classes.nameSection}>
|
||||||
|
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
|
||||||
|
<Text className={classes.name} title={displayName}>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasIntegrations && (
|
||||||
|
<Avatar.Group spacing="xs" className={classes.integrations}>
|
||||||
|
{connectedIntegrations.map((integration) => (
|
||||||
|
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
|
||||||
|
<Avatar
|
||||||
|
src={getIconUrl(integration.kind)}
|
||||||
|
size={20}
|
||||||
|
radius="sm"
|
||||||
|
className={classes.integrationAvatar}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</Avatar.Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasMetadata && (
|
||||||
|
<Group className={classes.metadata} gap={4}>
|
||||||
|
{metadataItems.map((metadataItem) => (
|
||||||
|
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
|
||||||
|
<span className={classes.metadataLabel}>{metadataItem.label}</span>
|
||||||
|
<span className={classes.metadataValue}>{metadataItem.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,8 +2,12 @@ import { useMemo } from "react";
|
|||||||
|
|
||||||
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
import type { GridItemHTMLElement } from "@homarr/gridstack";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
|
||||||
import { BoardItemContent } from "../items/item-content";
|
import { BoardItemContent } from "../items/item-content";
|
||||||
|
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
|
||||||
import { BoardDynamicSection } from "./dynamic-section";
|
import { BoardDynamicSection } from "./dynamic-section";
|
||||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||||
import { useSectionContext } from "./section-context";
|
import { useSectionContext } from "./section-context";
|
||||||
@@ -11,6 +15,7 @@ import { useSectionItems } from "./use-section-items";
|
|||||||
|
|
||||||
export const SectionContent = () => {
|
export const SectionContent = () => {
|
||||||
const { innerSections, items, refs } = useSectionContext();
|
const { innerSections, items, refs } = useSectionContext();
|
||||||
|
const { data: integrations } = clientApi.integration.all.useQuery();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
|
||||||
@@ -18,7 +23,7 @@ export const SectionContent = () => {
|
|||||||
* @see https://github.com/homarr-labs/homarr/pull/1770
|
* @see https://github.com/homarr-labs/homarr/pull/1770
|
||||||
*/
|
*/
|
||||||
const sortedItems = useMemo(() => {
|
const sortedItems = useMemo(() => {
|
||||||
return [...items, ...innerSections].sort((itemA, itemB) => {
|
return [...items, ...innerSections].toSorted((itemA, itemB) => {
|
||||||
if (itemA.yOffset === itemB.yOffset) {
|
if (itemA.yOffset === itemB.yOffset) {
|
||||||
return itemA.xOffset - itemB.xOffset;
|
return itemA.xOffset - itemB.xOffset;
|
||||||
}
|
}
|
||||||
@@ -30,7 +35,7 @@ export const SectionContent = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortedItems.map((item) => (
|
{sortedItems.map((item) => (
|
||||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
|
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -39,12 +44,12 @@ export const SectionContent = () => {
|
|||||||
interface ItemProps {
|
interface ItemProps {
|
||||||
item: DynamicSectionItem | SectionItem;
|
item: DynamicSectionItem | SectionItem;
|
||||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||||
|
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Item = ({ item, innerRef }: ItemProps) => {
|
const Item = ({ item, innerRef, integrations }: ItemProps) => {
|
||||||
const minWidth = useMinSize(item, "x");
|
const minWidth = useMinSize(item, "x");
|
||||||
const minHeight = useMinSize(item, "y");
|
const minHeight = useMinSize(item, "y");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridStackItem
|
<GridStackItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -59,6 +64,7 @@ const Item = ({ item, innerRef }: ItemProps) => {
|
|||||||
minWidth={minWidth}
|
minWidth={minWidth}
|
||||||
minHeight={minHeight}
|
minHeight={minHeight}
|
||||||
>
|
>
|
||||||
|
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
|
||||||
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
|
||||||
</GridStackItem>
|
</GridStackItem>
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
-1
@@ -2,4 +2,3 @@ declare module "*.png";
|
|||||||
declare module "*.svg";
|
declare module "*.svg";
|
||||||
declare module "*.jpeg";
|
declare module "*.jpeg";
|
||||||
declare module "*.jpg";
|
declare module "*.jpg";
|
||||||
declare module "sql.js";
|
|
||||||
@@ -102,6 +102,10 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid-stack > .grid-stack-item[data-type="item"] {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-stack.grid-stack-animate {
|
.grid-stack.grid-stack-animate {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
client_max_body_size 256M;
|
client_max_body_size 32M;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1604,24 +1604,22 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
|
|||||||
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
collapsed: collapseStates.at(0)?.collapsed ?? false,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
items: items
|
items: items.map(({ integrations: itemIntegrations, ...item }) =>
|
||||||
.map(({ integrations: itemIntegrations, ...item }) =>
|
parseItem({
|
||||||
parseItem({
|
...item,
|
||||||
...item,
|
layouts: item.layouts.map((layout) => ({
|
||||||
layouts: item.layouts.map((layout) => ({
|
xOffset: layout.xOffset,
|
||||||
xOffset: layout.xOffset,
|
yOffset: layout.yOffset,
|
||||||
yOffset: layout.yOffset,
|
width: layout.width,
|
||||||
width: layout.width,
|
height: layout.height,
|
||||||
height: layout.height,
|
layoutId: layout.layoutId,
|
||||||
layoutId: layout.layoutId,
|
sectionId: layout.sectionId,
|
||||||
sectionId: layout.sectionId,
|
})),
|
||||||
})),
|
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
||||||
integrationIds: itemIntegrations.map((item) => item.integration.id),
|
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
||||||
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
|
options: superjson.parse<Record<string, unknown>>(item.options),
|
||||||
options: superjson.parse<Record<string, unknown>>(item.options),
|
}),
|
||||||
}),
|
),
|
||||||
)
|
|
||||||
.filter((item) => item !== null),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1637,7 +1635,7 @@ const parseItem = (item: unknown) => {
|
|||||||
const result = outputItemSchema.safeParse(item);
|
const result = outputItemSchema.safeParse(item);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return null;
|
throw new Error(result.error.message);
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,7 @@
|
|||||||
"description": "To get started, please select how you want to set up your Homarr instance.",
|
"description": "To get started, please select how you want to set up your Homarr instance.",
|
||||||
"action": {
|
"action": {
|
||||||
"scratch": "Start from scratch",
|
"scratch": "Start from scratch",
|
||||||
"importOldmarr": "Import from Homarr before 1.0",
|
"importOldmarr": "Import from Homarr before 1.0"
|
||||||
"restoreSqlite": "Restore from SQLite backup",
|
|
||||||
"restoreSqliteDescription": "Upload a full SQLite database backup from a previous Homarr instance. This will restore all boards, users, apps, integrations, and settings.",
|
|
||||||
"backToOptions": "← Back to setup options"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
@@ -3501,8 +3498,7 @@
|
|||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"api": "API",
|
"api": "API",
|
||||||
"certificates": "Certificates",
|
"certificates": "Certificates",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks"
|
||||||
"backup": "Backup"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -4042,125 +4038,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"backup": {
|
|
||||||
"title": "Full Database Backup",
|
|
||||||
"metaTitle": "Backup & Restore",
|
|
||||||
"export": {
|
|
||||||
"title": "Export Database",
|
|
||||||
"description": "Download a full backup of your SQLite database including all settings, users, boards, and encrypted integration secrets. The backup is bundled as a ZIP file with the encryption key and metadata.",
|
|
||||||
"button": "Download Backup",
|
|
||||||
"error": "Export failed",
|
|
||||||
"confirm": {
|
|
||||||
"title": "Sensitive data export",
|
|
||||||
"warningTitle": "This export contains your entire database.",
|
|
||||||
"warningBody": "The backup includes all users, integration secrets (encrypted), API keys, board configurations, and app data. Store the exported file securely and do not share it.",
|
|
||||||
"typePrompt": "Type \"{phrase}\" to continue",
|
|
||||||
"submit": "Export Database",
|
|
||||||
"cancel": "Cancel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"import": {
|
|
||||||
"title": "Restore Database",
|
|
||||||
"description": "Upload a previously exported backup ZIP to restore your database. This will replace your entire database. If the backup was created with a different encryption key, secrets will be automatically re-encrypted.",
|
|
||||||
"warning": "Restoring a backup will completely replace your current database and restart the application. This action cannot be undone.",
|
|
||||||
"dropzone": {
|
|
||||||
"title": "Drop your backup ZIP here or click to browse",
|
|
||||||
"description": "Only .zip files from a Homarr SQLite backup are accepted"
|
|
||||||
},
|
|
||||||
"changeFile": "Change file",
|
|
||||||
"button": "Restore Database",
|
|
||||||
"success": {
|
|
||||||
"title": "Restore Complete",
|
|
||||||
"message": "Database has been restored successfully. The application is restarting..."
|
|
||||||
},
|
|
||||||
"error": "Import failed"
|
|
||||||
},
|
|
||||||
"preview": {
|
|
||||||
"title": "Backup Contents",
|
|
||||||
"exported": "Exported {date}",
|
|
||||||
"loading": "Analyzing backup file...",
|
|
||||||
"errorTitle": "Preview Error",
|
|
||||||
"entity": "Entity",
|
|
||||||
"count": "Count",
|
|
||||||
"boards": "Boards",
|
|
||||||
"entities": {
|
|
||||||
"board": "Boards",
|
|
||||||
"user": "Users",
|
|
||||||
"app": "Apps",
|
|
||||||
"integration": "Integrations",
|
|
||||||
"item": "Widgets",
|
|
||||||
"media": "Media",
|
|
||||||
"search_engine": "Search Engines"
|
|
||||||
},
|
|
||||||
"analyzeError": "Failed to analyze backup file",
|
|
||||||
"invalidMissingMetadata": "Invalid backup: missing metadata.json",
|
|
||||||
"invalidMissingDb": "Invalid backup: missing db.sqlite"
|
|
||||||
},
|
|
||||||
"restore": {
|
|
||||||
"dropzone": {
|
|
||||||
"title": "Drop your backup ZIP here",
|
|
||||||
"description": "Accepts .zip files from a Homarr SQLite backup (max 256 MB)"
|
|
||||||
},
|
|
||||||
"analyzing": "Analyzing backup...",
|
|
||||||
"changeFile": "Change file",
|
|
||||||
"continueToRestore": "Continue to restore",
|
|
||||||
"tryAgain": "Try again",
|
|
||||||
"analysisError": "Analysis Error",
|
|
||||||
"preview": {
|
|
||||||
"title": "Backup Contents",
|
|
||||||
"exported": "Exported {date}",
|
|
||||||
"entity": "Entity",
|
|
||||||
"count": "Count",
|
|
||||||
"boards": "Boards",
|
|
||||||
"entities": {
|
|
||||||
"board": "Boards",
|
|
||||||
"user": "Users",
|
|
||||||
"app": "Apps",
|
|
||||||
"integration": "Integrations",
|
|
||||||
"item": "Widgets",
|
|
||||||
"media": "Media",
|
|
||||||
"search_engine": "Search Engines"
|
|
||||||
},
|
|
||||||
"migrationsApplied": "{count} migration applied",
|
|
||||||
"migrationsAppliedPlural": "{count} migrations applied",
|
|
||||||
"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",
|
|
||||||
"migrationsPending": "{count} pending"
|
|
||||||
},
|
|
||||||
"migration": {
|
|
||||||
"title": "Applying migrations ({current}/{total})",
|
|
||||||
"migrationLabel": "Migration {index}"
|
|
||||||
},
|
|
||||||
"confirm": {
|
|
||||||
"title": "Destructive action",
|
|
||||||
"warningTitle": "This will permanently replace your entire database.",
|
|
||||||
"warningBody": "All current boards, users, apps, integrations, settings, and sessions will be lost. This action cannot be undone. The application will restart after the restore.",
|
|
||||||
"typePrompt": "Type \"{phrase}\" to continue",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"submit": "Restore Database"
|
|
||||||
},
|
|
||||||
"progress": {
|
|
||||||
"title": "Restoring database...",
|
|
||||||
"extracting": "Extracting backup archive",
|
|
||||||
"migrating": "Applying database migrations",
|
|
||||||
"encrypting": "Re-encrypting integration secrets",
|
|
||||||
"swapping": "Replacing database file",
|
|
||||||
"restarting": "Restarting server"
|
|
||||||
},
|
|
||||||
"complete": {
|
|
||||||
"title": "Restore Complete",
|
|
||||||
"message": "Database has been restored successfully. The server is restarting — please refresh the page in a few seconds."
|
|
||||||
},
|
|
||||||
"failed": {
|
|
||||||
"title": "Restore Failed"
|
|
||||||
},
|
|
||||||
"invalidMissingMetadata": "Invalid backup: missing metadata.json",
|
|
||||||
"invalidMissingDb": "Invalid backup: missing db.sqlite",
|
|
||||||
"analyzeError": "Failed to analyze backup"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import * as weather from "./weather";
|
|||||||
|
|
||||||
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
|
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
|
||||||
export type { WidgetComponentProps };
|
export type { WidgetComponentProps };
|
||||||
|
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
|
||||||
|
|
||||||
export const widgetImports = {
|
export const widgetImports = {
|
||||||
clock,
|
clock,
|
||||||
|
|||||||
Generated
+6
-65
@@ -384,9 +384,6 @@ catalogs:
|
|||||||
json5:
|
json5:
|
||||||
specifier: ^2.2.3
|
specifier: ^2.2.3
|
||||||
version: 2.2.3
|
version: 2.2.3
|
||||||
jszip:
|
|
||||||
specifier: ^3.10.1
|
|
||||||
version: 3.10.1
|
|
||||||
ldapts:
|
ldapts:
|
||||||
specifier: 8.1.8
|
specifier: 8.1.8
|
||||||
version: 8.1.8
|
version: 8.1.8
|
||||||
@@ -799,15 +796,9 @@ importers:
|
|||||||
'@xterm/xterm':
|
'@xterm/xterm':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
adm-zip:
|
|
||||||
specifier: 'catalog:'
|
|
||||||
version: 0.5.17
|
|
||||||
babel-plugin-react-compiler:
|
babel-plugin-react-compiler:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
better-sqlite3:
|
|
||||||
specifier: 'catalog:'
|
|
||||||
version: 12.10.0
|
|
||||||
chroma-js:
|
chroma-js:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.2.0
|
version: 3.2.0
|
||||||
@@ -820,9 +811,6 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 17.4.2
|
version: 17.4.2
|
||||||
drizzle-orm:
|
|
||||||
specifier: 'catalog:'
|
|
||||||
version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1)
|
|
||||||
flag-icons:
|
flag-icons:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 7.5.0
|
version: 7.5.0
|
||||||
@@ -862,9 +850,6 @@ importers:
|
|||||||
sass:
|
sass:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.100.0
|
version: 1.100.0
|
||||||
sql.js:
|
|
||||||
specifier: 'catalog:'
|
|
||||||
version: 1.14.1
|
|
||||||
superjson:
|
superjson:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 2.2.6
|
version: 2.2.6
|
||||||
@@ -884,12 +869,6 @@ importers:
|
|||||||
'@homarr/tsconfig':
|
'@homarr/tsconfig':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../../tooling/typescript
|
version: link:../../tooling/typescript
|
||||||
'@types/adm-zip':
|
|
||||||
specifier: 'catalog:'
|
|
||||||
version: 0.5.8
|
|
||||||
'@types/better-sqlite3':
|
|
||||||
specifier: 'catalog:'
|
|
||||||
version: 7.6.13
|
|
||||||
'@types/chroma-js':
|
'@types/chroma-js':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.1.2
|
version: 3.1.2
|
||||||
@@ -1359,7 +1338,7 @@ importers:
|
|||||||
version: 0.2.9
|
version: 0.2.9
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1)
|
version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)
|
||||||
ioredis:
|
ioredis:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.11.0
|
version: 5.11.0
|
||||||
@@ -1531,10 +1510,10 @@ importers:
|
|||||||
version: 0.31.10
|
version: 0.31.10
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1)
|
version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)
|
||||||
drizzle-zod:
|
drizzle-zod:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1))(zod@4.4.3)
|
version: 0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0))(zod@4.4.3)
|
||||||
mysql2:
|
mysql2:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.22.4(@types/node@24.12.4)
|
version: 3.22.4(@types/node@24.12.4)
|
||||||
@@ -7060,9 +7039,6 @@ packages:
|
|||||||
ieee754@1.2.1:
|
ieee754@1.2.1:
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||||
|
|
||||||
immediate@3.0.6:
|
|
||||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
|
||||||
|
|
||||||
immer@10.2.0:
|
immer@10.2.0:
|
||||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||||
|
|
||||||
@@ -7359,9 +7335,6 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jszip@3.10.1:
|
|
||||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
|
||||||
|
|
||||||
keygrip@1.1.0:
|
keygrip@1.1.0:
|
||||||
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
|
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -7385,9 +7358,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==}
|
resolution: {integrity: sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
lie@3.3.0:
|
|
||||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
|
||||||
|
|
||||||
light-my-request@6.6.0:
|
light-my-request@6.6.0:
|
||||||
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
|
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
|
||||||
|
|
||||||
@@ -8079,9 +8049,6 @@ packages:
|
|||||||
package-json-from-dist@1.0.1:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
pako@1.0.11:
|
|
||||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
|
||||||
|
|
||||||
parchment@3.0.0:
|
parchment@3.0.0:
|
||||||
resolution: {integrity: sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==}
|
resolution: {integrity: sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==}
|
||||||
|
|
||||||
@@ -8824,9 +8791,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
setimmediate@1.0.5:
|
|
||||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
@@ -8963,9 +8927,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
|
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
|
||||||
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
|
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
|
||||||
|
|
||||||
sql.js@1.14.1:
|
|
||||||
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
|
|
||||||
|
|
||||||
ssh-remote-port-forward@1.0.4:
|
ssh-remote-port-forward@1.0.4:
|
||||||
resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==}
|
resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==}
|
||||||
|
|
||||||
@@ -13782,7 +13743,7 @@ snapshots:
|
|||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
tsx: 4.21.0
|
tsx: 4.21.0
|
||||||
|
|
||||||
drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1):
|
drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@libsql/client-wasm': 0.14.0
|
'@libsql/client-wasm': 0.14.0
|
||||||
'@types/better-sqlite3': 7.6.13
|
'@types/better-sqlite3': 7.6.13
|
||||||
@@ -13791,11 +13752,10 @@ snapshots:
|
|||||||
gel: 2.0.0
|
gel: 2.0.0
|
||||||
mysql2: 3.22.4(@types/node@24.12.4)
|
mysql2: 3.22.4(@types/node@24.12.4)
|
||||||
pg: 8.21.0
|
pg: 8.21.0
|
||||||
sql.js: 1.14.1
|
|
||||||
|
|
||||||
drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1))(zod@4.4.3):
|
drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0))(zod@4.4.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1)
|
drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)
|
||||||
zod: 4.4.3
|
zod: 4.4.3
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
@@ -14469,8 +14429,6 @@ snapshots:
|
|||||||
|
|
||||||
ieee754@1.2.1: {}
|
ieee754@1.2.1: {}
|
||||||
|
|
||||||
immediate@3.0.6: {}
|
|
||||||
|
|
||||||
immer@10.2.0: {}
|
immer@10.2.0: {}
|
||||||
|
|
||||||
immer@11.1.4: {}
|
immer@11.1.4: {}
|
||||||
@@ -14736,13 +14694,6 @@ snapshots:
|
|||||||
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
|
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
|
||||||
jsep: 1.4.0
|
jsep: 1.4.0
|
||||||
|
|
||||||
jszip@3.10.1:
|
|
||||||
dependencies:
|
|
||||||
lie: 3.3.0
|
|
||||||
pako: 1.0.11
|
|
||||||
readable-stream: 2.3.8
|
|
||||||
setimmediate: 1.0.5
|
|
||||||
|
|
||||||
keygrip@1.1.0:
|
keygrip@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tsscmp: 1.0.6
|
tsscmp: 1.0.6
|
||||||
@@ -14763,10 +14714,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
strict-event-emitter-types: 2.0.0
|
strict-event-emitter-types: 2.0.0
|
||||||
|
|
||||||
lie@3.3.0:
|
|
||||||
dependencies:
|
|
||||||
immediate: 3.0.6
|
|
||||||
|
|
||||||
light-my-request@6.6.0:
|
light-my-request@6.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie: 1.1.1
|
cookie: 1.1.1
|
||||||
@@ -15520,8 +15467,6 @@ snapshots:
|
|||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
pako@1.0.11: {}
|
|
||||||
|
|
||||||
parchment@3.0.0: {}
|
parchment@3.0.0: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
@@ -16394,8 +16339,6 @@ snapshots:
|
|||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
has-property-descriptors: 1.0.2
|
has-property-descriptors: 1.0.2
|
||||||
|
|
||||||
setimmediate@1.0.5: {}
|
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
sha.js@2.4.12:
|
sha.js@2.4.12:
|
||||||
@@ -16564,8 +16507,6 @@ snapshots:
|
|||||||
|
|
||||||
sql-escaper@1.3.3: {}
|
sql-escaper@1.3.3: {}
|
||||||
|
|
||||||
sql.js@1.14.1: {}
|
|
||||||
|
|
||||||
ssh-remote-port-forward@1.0.4:
|
ssh-remote-port-forward@1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/ssh2': 0.5.52
|
'@types/ssh2': 0.5.52
|
||||||
|
|||||||
@@ -98,8 +98,6 @@ catalog:
|
|||||||
"@xterm/addon-fit": 0.11.0
|
"@xterm/addon-fit": 0.11.0
|
||||||
"@xterm/xterm": ^6.0.0
|
"@xterm/xterm": ^6.0.0
|
||||||
adm-zip: 0.5.17
|
adm-zip: 0.5.17
|
||||||
jszip: ^3.10.1
|
|
||||||
sql.js: ^1.12.0
|
|
||||||
babel-plugin-react-compiler: ^1.0.0
|
babel-plugin-react-compiler: ^1.0.0
|
||||||
bcrypt: ^6.0.0
|
bcrypt: ^6.0.0
|
||||||
better-sqlite3: ^12.10.0
|
better-sqlite3: ^12.10.0
|
||||||
|
|||||||
+13
-28
@@ -10,11 +10,8 @@ if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then
|
|||||||
echo "DB migrations are disabled, skipping"
|
echo "DB migrations are disabled, skipping"
|
||||||
else
|
else
|
||||||
echo "Running DB migrations"
|
echo "Running DB migrations"
|
||||||
|
# We disable redis logs during migration as the redis client is not yet started
|
||||||
DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
|
DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "ERROR: DB migrations failed, aborting startup"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing
|
# Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing
|
||||||
@@ -39,38 +36,26 @@ else
|
|||||||
REDIS_PID=$!
|
REDIS_PID=$!
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SHUTTING_DOWN=false
|
node apps/nextjs/server.js &
|
||||||
|
NEXTJS_PID=$!
|
||||||
|
|
||||||
|
# Function to handle SIGTERM and shut down services
|
||||||
terminate() {
|
terminate() {
|
||||||
SHUTTING_DOWN=true
|
echo "Received SIGTERM. Shutting down..."
|
||||||
echo "Shutting down..."
|
kill -TERM $NGINX_PID $NEXTJS_PID 2>/dev/null
|
||||||
kill -TERM $NEXTJS_PID 2>/dev/null
|
wait
|
||||||
wait $NEXTJS_PID 2>/dev/null
|
# kill redis-server last because of logging of other services and only if $REDIS_PID is set
|
||||||
kill -TERM $NGINX_PID 2>/dev/null
|
|
||||||
wait $NGINX_PID 2>/dev/null
|
|
||||||
if [ -n "$REDIS_PID" ]; then
|
if [ -n "$REDIS_PID" ]; then
|
||||||
kill -TERM $REDIS_PID 2>/dev/null
|
kill -TERM $REDIS_PID 2>/dev/null
|
||||||
wait $REDIS_PID 2>/dev/null
|
wait
|
||||||
fi
|
fi
|
||||||
echo "Shutdown complete."
|
echo "Shutdown complete."
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# When SIGTERM (docker stop <container>) / SIGINT (ctrl+c) is received, run the terminate function
|
||||||
trap terminate TERM INT
|
trap terminate TERM INT
|
||||||
|
|
||||||
node apps/nextjs/server.js &
|
# Wait for all processes
|
||||||
NEXTJS_PID=$!
|
wait $NEXTJS_PID
|
||||||
|
terminate
|
||||||
while true; do
|
|
||||||
wait $NEXTJS_PID
|
|
||||||
EXIT_CODE=$?
|
|
||||||
|
|
||||||
if [ "$SHUTTING_DOWN" = true ]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Next.js exited with code $EXIT_CODE, restarting in 1s..."
|
|
||||||
sleep 1
|
|
||||||
node apps/nextjs/server.js &
|
|
||||||
NEXTJS_PID=$!
|
|
||||||
done
|
|
||||||
Reference in new issue
Block a user