Compare commits

..
Author SHA1 Message Date
Thomas 79cb9277e6 Merge branch 'dev' into feat/sqlite-import-export 2026-06-10 18:26:00 +02:00
Thomas 484b8cf9f1 fix: gracefully skip widgets with unknown kind instead of crashing (#5915) 2026-06-10 18:19:13 +02:00
homarr-renovate[bot] b3cdab579b chore(deps): update dependency isomorphic-dompurify to ^3.16.0 (#5913)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-10 14:31:02 +00:00
homarr-renovate[bot] 44a0c6ce54 chore(deps): update dependency semver-parser to ^4.2.1 (#5868)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 12:47:00 +00:00
homarr-renovate[bot] 7f05e0325d chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.1 (#5906)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 09:53:16 +00:00
homarr-renovate[bot] f71b145403 chore(deps): update tiptap monorepo to v3.25.0 (#5898)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 05:17:18 +00:00
homarr-renovate[bot] 607fd498f4 chore(deps): update tanstack-query monorepo to ^5.101.0 (#5894)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 00:17:40 +00:00
homarr-renovate[bot] 3f2fb54cdf chore(deps): update mantine monorepo to ^9.3.0 (#5890)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 22:39:48 +00:00
Thomasandhomarr-releases[bot] 69412f2d10 chore(release): 1.64.0 [skip ci] (#5902)
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-08 22:25:40 +02:00
Afonso Jorge Ramos f6d5f97933 fix(integrations): support truenas json-rpc api, certificate trust, and api key auth (#5861)
* chore(integrations): add ws dependency for the truenas client

* feat(definitions): allow api key authentication for truenas

* fix(integrations): connect to truenas over trusted-certificate websocket with protocol autodetection

* test(integrations): cover truenas protocol detection and authentication

* refactor(integrations): avoid shorthand index variable in truenas mapping

* feat(integrations): add a websocket url helper to the integration base

* refactor(integrations): extract truenas connection handling into a client
2026-06-08 22:20:42 +02:00
homarr-renovate[bot] a447a9f615 chore(deps): update dependency axios@>=1.0.0 <1.8.2 to >=1.17.0 (#5897)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 14:15:50 +00:00
homarr-renovate[bot] 87d3f6a0a1 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.4 (#5896)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 09:30:40 +00:00
Thomas Camlong 3d2c4979a6 fix: audit fixes — memory leak, input validation, deduplication
- Fix WASM DB memory leak: close database in finally block so errors
  during analysis don't leave it open until manual reset
- Validate metadata.encryptionKey is a non-empty string before use
- Extract shared findMigrationsFolder to backup/shared.ts (was
  duplicated in import and migrations routes)
- Wrap export card error response JSON parse to handle non-JSON
- Check migration exit code in run.sh — abort startup on failure
2026-06-07 01:18:22 +02:00
Thomas Camlong 79b27815fe fix: harden backup/restore error handling and security
- Stop analysis on WASM migration failure instead of continuing silently
- Fail import when secrets exist but no encryption key is provided
- Fail import on malformed secret values instead of skipping them
- Tighten onboarding auth: missing onboarding row no longer grants access
- Wrap WAL/SHM cleanup in try/catch so post-rename failure doesn't mask success
- Show API errors immediately instead of waiting for animation to finish
- Handle non-JSON server responses during restore (e.g. restart mid-request)
- Surface fetchMigrations() failures as errors instead of empty array
- Wrap migrations route I/O in try/catch with structured error response
- Add server-side console.error logging to all backup API catch blocks
- Fix export finally block: cleanup wrapped in try/catch to avoid masking
- Fix misleading "Up to date" badge shown when migrations are pending
- Remove dead PHASE_ICONS constant
2026-06-07 01:15:07 +02:00
homarr-renovate[bot] f2705eeb57 chore(deps): update dependency oxfmt to ^0.53.0 (#5883)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 23:12:23 +00:00
Thomas Camlong 76eacf8353 feat: expand migration phase into per-migration timeline steps
Show each migration as its own 100ms step in the restore progress
timeline instead of a single generic "Applying migrations" phase.
2026-06-07 01:06:45 +02:00
Thomas Camlong 0c14e715a0 feat: add SQLite backup & restore with WASM preview and onboarding integration
Full database backup/restore for SQLite instances with client-side WASM
migration preview, type-to-confirm safety dialogs, and graceful server
restart without container recycling.

- Export/import API routes with encryption key re-encryption
- Client-side backup analysis via sql.js WASM (served through API route)
- Drizzle migrations applied in-browser with animated progress timeline
- "Type I understand" confirmation for both export and import
- Onboarding flow integration for restoring during initial setup
- SQLite-only guards on sidebar, page, onboarding, and all API routes
- run.sh restart loop: only Next.js restarts, nginx/redis stay up
- Backward-compatible with older backups using encryption-key.txt
- Version injected at build time via next.config.ts
2026-06-07 00:20:13 +02:00
homarr-renovate[bot] ac37d2c73f chore(deps): update react monorepo (#5886)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 21:13:36 +00:00
homarr-renovate[bot] e00ea624cd chore(deps): update nextjs monorepo to v16.2.7 (#5885)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 19:32:30 +00:00
homarr-renovate[bot] 20b62c42e3 chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.0 (#5878)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 13:54:21 +00:00
homarr-renovate[bot] 59971991c7 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.16 (#5880)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 11:36:28 +00:00
homarr-renovate[bot] a629681ae5 chore(deps): update vitest monorepo to ^4.1.8 (#5879)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 09:34:48 +00:00
Releases Homarr 8a1ead3674 chore(release): 1.64.0 [skip ci] 2026-06-05 17:35:36 +00:00
homarr-releases[bot] 9c05f44916 chore(release): automatic release v1.64.0 2026-06-05 17:34:47 +00:00
137 changed files with 4085 additions and 15318 deletions

No files matched your search

-8
View File
@@ -1,5 +1,4 @@
Dockerfile
Dockerfile.dev
.dockerignore
node_modules
**/node_modules
@@ -15,10 +14,3 @@ apps/nextjs/.next
.env
.env.*
!.env.example
.docker-data
.docker-cache
coverage
.cursor
agent-transcripts
**/*.spec.ts
**/*.test.ts
-4
View File
@@ -72,7 +72,3 @@ en.d.json.ts
# personal agent config
AGENTS.md
.opencode
# local docker dev
.docker-data/
.docker-cache/
+16 -1
View File
@@ -5,9 +5,21 @@ import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env";
import { readFileSync } from "fs";
import type { NextConfig } from "next";
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
const withNextIntl = createNextIntlPlugin({
experimental: {
@@ -17,6 +29,9 @@ const withNextIntl = createNextIntlPlugin({
});
const nextConfig: NextConfig = {
env: {
HOMARR_VERSION: homarrVersion,
},
output: "standalone",
reactStrictMode: true,
// react compiler breaks mantine-react-table, so disabled for now
@@ -27,7 +42,7 @@ const nextConfig: NextConfig = {
* 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
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
+8 -1
View File
@@ -22,7 +22,6 @@
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/board-portability": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
@@ -65,6 +64,7 @@
"@trpc/next": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@xterm/addon-canvas": "catalog:",
"@xterm/addon-fit": "catalog:",
"@xterm/xterm": "catalog:",
"babel-plugin-react-compiler": "catalog:",
@@ -72,9 +72,13 @@
"clsx": "catalog:",
"dayjs": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
"adm-zip": "catalog:",
"better-sqlite3": "catalog:",
"isomorphic-dompurify": "catalog:",
"jszip": "catalog:",
"jotai": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
@@ -85,6 +89,7 @@
"react-error-boundary": "catalog:",
"react-simple-code-editor": "catalog:",
"sass": "catalog:",
"sql.js": "catalog:",
"superjson": "catalog:",
"swagger-ui-react": "catalog:",
"use-deep-compare-effect": "catalog:",
@@ -93,6 +98,8 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "catalog:",
"@types/better-sqlite3": "catalog:",
"@types/chroma-js": "catalog:",
"@types/node": "catalog:",
"@types/prismjs": "catalog:",
BIN
View File
Binary file not shown.
@@ -6,14 +6,11 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useI18n } from "@homarr/translation/client";
import { clearInitImportMode } from "./import/use-init-import-mode";
export const BackToStart = () => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
const handleBackToStartAsync = async () => {
clearInitImportMode();
await mutateAsync();
await revalidatePathActionAsync("/init");
};
@@ -1,116 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button, Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
import { FileInfoCard } from "./file-info-card";
export const InitConfigImport = () => {
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
const [file, setFile] = useState<FileWithPath | null>(null);
const [fileContent, setFileContent] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const hasOpenedRef = useRef(false);
const {
mutate: runPreview,
data: preview,
isPending: previewLoading,
reset: resetPreview,
} = clientApi.import.previewInitialConfigImport.useMutation();
const { mutateAsync, isPending } = clientApi.import.importInitialConfigImport.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
useEffect(() => {
if (hasOpenedRef.current) return;
hasOpenedRef.current = true;
setTimeout(() => inputRef.current?.click(), 100);
}, []);
useEffect(() => {
if (!fileContent) {
resetPreview();
return;
}
runPreview({ content: fileContent });
}, [fileContent, runPreview, resetPreview]);
const importAllowed = preview?.compatibility.status === "compatible";
const handleImport = async () => {
if (!file || !importAllowed) return;
await mutateAsync(
{ content: fileContent },
{
onError() {
showErrorNotification({
title: t("management.page.importExport.importConfig.notification.error.title"),
message: t("management.page.importExport.importConfig.notification.error.message"),
});
},
},
);
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target.files?.[0];
if (!selectedFile) return;
void selectedFile.text().then((content) => {
setFileContent(content);
setFile(selectedFile as FileWithPath);
});
};
return (
<Stack w="100%" mb="sm">
<input
ref={inputRef}
type="file"
accept="application/json,.json"
onChange={handleFileChange}
style={{ display: "none" }}
/>
{file && (
<>
<FileInfoCard
file={file}
onRemove={() => {
setFile(null);
setFileContent("");
}}
/>
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
<Button
loading={isPending}
disabled={!importAllowed || previewLoading}
onClick={() => void handleImport()}
>
{t("init.step.import.configImport.action")}
</Button>
</>
)}
{!file && (
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => inputRef.current?.click()}>
{t("init.step.import.configImport.selectFile")}
</Button>
)}
</Stack>
);
};
@@ -11,20 +11,8 @@ import { InitialOldmarrImport } from "@homarr/old-import/components";
import { InitStepCard } from "../../_components/init-step-card";
import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone";
import { InitConfigImport } from "./init-config-import";
import { useInitImportMode } from "./use-init-import-mode";
export const InitImport = () => {
const importMode = useInitImportMode();
if (importMode === "config") {
return <InitConfigImport />;
}
return <LegacyInitImport />;
};
const LegacyInitImport = () => {
const [file, setFile] = useState<FileWithPath | null>(null);
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
@@ -1,30 +0,0 @@
"use client";
import { useSyncExternalStore } from "react";
export type InitImportMode = "legacy" | "config";
const storageKey = "homarr-init-import-mode";
const readMode = (): InitImportMode => {
const stored = sessionStorage.getItem(storageKey);
return stored === "config" ? "config" : "legacy";
};
const subscribe = (onStoreChange: () => void) => {
window.addEventListener("storage", onStoreChange);
return () => window.removeEventListener("storage", onStoreChange);
};
export const setInitImportMode = (mode: InitImportMode) => {
sessionStorage.setItem(storageKey, mode);
window.dispatchEvent(new Event("storage"));
};
export const clearInitImportMode = () => {
sessionStorage.removeItem(storageKey);
window.dispatchEvent(new Event("storage"));
};
export const useInitImportMode = () =>
useSyncExternalStore(subscribe, readMode, () => "legacy" as InitImportMode);
@@ -1,14 +1,17 @@
import { Stack, Text } from "@mantine/core";
import { IconFileImport, IconJson, IconPlayerPlay } from "@tabler/icons-react";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStepCard } from "../../_components/init-step-card";
import { InitSqliteRestore } from "./sqlite-restore";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
return (
<InitStepCard>
@@ -23,18 +26,11 @@ export const InitStart = async () => {
</InitStartButton>
<InitStartButton
preferredStep="import"
importMode="legacy"
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importOldmarr")}
</InitStartButton>
<InitStartButton
preferredStep="import"
importMode="config"
icon={<IconJson color={getMantineColor("blue", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importConfig")}
</InitStartButton>
{isSqlite && <InitSqliteRestore />}
</Stack>
</InitStepCard>
);
@@ -7,28 +7,15 @@ import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { OnboardingStep } from "@homarr/definitions";
import { clearInitImportMode, setInitImportMode, type InitImportMode } from "../import/use-init-import-mode";
interface InitStartButtonProps {
icon: ReactNode;
preferredStep: OnboardingStep | undefined;
importMode?: InitImportMode;
}
export const InitStartButton = ({
preferredStep,
icon,
children,
importMode,
}: PropsWithChildren<InitStartButtonProps>) => {
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
const handleClickAsync = async () => {
if (importMode) {
setInitImportMode(importMode);
} else {
clearInitImportMode();
}
await mutateAsync({ preferredStep });
await revalidatePathActionAsync("/init");
};
@@ -0,0 +1,43 @@
"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>
);
};
@@ -2,14 +2,14 @@
import { useCallback } from "react";
import { Menu } from "@mantine/core";
import { IconCopy, IconDeviceMobile, IconDownload, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal, ExportBoardModal } from "@homarr/modals-collection";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
@@ -28,10 +28,7 @@ interface BoardCardMenuDropdownProps {
}
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action" as never) as unknown as (
key: string,
values?: Record<string, unknown>,
) => string;
const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
@@ -39,7 +36,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const { openModal: openExportModal } = useModalAction(ExportBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
@@ -91,15 +87,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
});
}, [board.id, board.name, openDuplicateModal]);
const handleExportBoard = useCallback(() => {
openExportModal({
board: {
id: board.id,
name: board.name,
},
});
}, [board.id, board.name, openExportModal]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
@@ -108,9 +95,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
{t("setMobileHomeBoard.label")}
</Menu.Item>
<Menu.Item onClick={handleExportBoard} leftSection={<IconDownload {...iconProps} />}>
{t("export.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")}
@@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => {
const t = useI18n() as unknown as (key: string) => string;
const t = useI18n();
const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
@@ -29,6 +29,7 @@ import {
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
@@ -156,10 +157,10 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
hidden: !session?.user.permissions.includes("admin"),
},
{
label: (t as unknown as (key: string) => string)("items.tools.items.importExport"),
label: t("items.tools.items.backup"),
icon: IconDatabaseExport,
href: "/manage/tools/import-export",
hidden: !session?.user.permissions.includes("admin"),
href: "/manage/tools/backup",
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
},
],
},
@@ -1,58 +0,0 @@
"use client";
import { NumberInput, Switch, TextInput } from "@mantine/core";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
export const DockerSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["docker"] }) => {
const tDocker = useScopedI18n("management.page.settings.section.docker" as never) as unknown as (
key: string,
) => string;
return (
<CommonSettingsForm settingKey="docker" defaultValues={defaultValues}>
{(form) => (
<>
<TextInput
{...form.getInputProps("targetBoardName")}
value={form.getInputProps("targetBoardName").value ?? ""}
label={tDocker("targetBoardName.label")}
description={tDocker("targetBoardName.description")}
/>
<Switch
{...form.getInputProps("readHomepageLabels", { type: "checkbox" })}
label={tDocker("readHomepageLabels.label")}
description={tDocker("readHomepageLabels.description")}
/>
<Switch
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
label={tDocker("createIntegrations.label")}
description={tDocker("createIntegrations.description")}
/>
<Switch
{...form.getInputProps("pruneRemoved", { type: "checkbox" })}
label={tDocker("pruneRemoved.label")}
description={tDocker("pruneRemoved.description")}
/>
<NumberInput
{...form.getInputProps("defaultItemWidth")}
label={tDocker("defaultItemWidth.label")}
description={tDocker("defaultItemWidth.description")}
min={1}
max={10}
/>
<NumberInput
{...form.getInputProps("defaultItemHeight")}
label={tDocker("defaultItemHeight.label")}
description={tDocker("defaultItemHeight.description")}
min={1}
max={10}
/>
</>
)}
</CommonSettingsForm>
);
};
@@ -5,15 +5,12 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { env } from "@homarr/docker/env";
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AnalyticsSettings } from "./_components/analytics.settings";
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
import { DockerSettingsForm } from "./_components/docker-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form";
import { UserSettingsForm } from "./_components/user-settings-form";
@@ -62,12 +59,6 @@ export default async function SettingsPage() {
<Title order={2}>{tSettings("section.culture.title")}</Title>
<CultureSettingsForm defaultValues={serverSettings.culture} />
</Stack>
{env.ENABLE_DOCKER && (
<Stack>
<Title order={2}>{(tSettings as unknown as (key: string) => string)("section.docker.title")}</Title>
<DockerSettingsForm defaultValues={serverSettings.docker} />
</Stack>
)}
</Stack>
</>
);
@@ -0,0 +1,87 @@
"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>
);
};
@@ -0,0 +1,27 @@
"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>
);
};
@@ -0,0 +1,40 @@
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>
);
}
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
@@ -44,34 +45,34 @@ export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) =>
return () => undefined;
}
const fitAddon = new FitAddon();
const terminal = new Terminal({
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize: 14,
});
terminal.open(ref.current);
terminal.loadAddon(fitAddon);
terminalRef.current = terminal;
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
isTerminalReadyRef.current = true;
const terminal = terminalRef.current;
pendingLogsRef.current.forEach((data) => terminal.write(data));
pendingLogsRef.current = [];
terminal.refresh(0, terminal.rows - 1);
const fitTimeout = setTimeout(() => fitAddon.fit());
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
resizeObserver.observe(ref.current);
setTimeout(() => {
const fitAddon = new FitAddon();
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
return () => {
clearTimeout(fitTimeout);
resizeObserver.disconnect();
isTerminalReadyRef.current = false;
pendingLogsRef.current = [];
fitAddon.dispose();
terminal.dispose();
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [writeToTerminal]);
@@ -1,124 +0,0 @@
"use client";
import { Avatar, Badge, Button, Card, Group, Stack, Table, Text, Title } from "@mantine/core";
import { IconRefresh, IconSearch } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
export const DockerDiscoveryPanel = () => {
const tDiscovery = useScopedI18n("docker.discovery" as never) as unknown as (
key: string,
values?: Record<string, unknown>,
) => string;
const { data: preview, refetch, isLoading: isLoadingPreview, isFetched } =
clientApi.docker.getDiscoveredPreview.useQuery(undefined, {
enabled: false,
});
const { mutate: syncDiscovery, isPending: isSyncing } = clientApi.docker.syncDiscovery.useMutation({
onSuccess(result) {
showSuccessNotification({
title: tDiscovery("sync.notification.success.title"),
message: tDiscovery("sync.notification.success.message", {
created: result.created,
updated: result.updated,
}),
});
void refetch();
},
onError() {
showErrorNotification({
title: tDiscovery("sync.notification.error.title"),
message: tDiscovery("sync.notification.error.message"),
});
},
});
const services = preview ?? [];
return (
<Card withBorder>
<Stack>
<Group justify="space-between">
<Title order={3}>{tDiscovery("title")}</Title>
<Group gap="xs">
<Button
variant="light"
leftSection={<IconSearch size={16} />}
loading={isLoadingPreview}
onClick={() => refetch()}
>
{tDiscovery("preview.label")}
</Button>
<Button
leftSection={<IconRefresh size={16} />}
loading={isSyncing}
onClick={() => syncDiscovery()}
disabled={services.length === 0}
>
{tDiscovery("sync.label")}
</Button>
</Group>
</Group>
{!isFetched ? (
<Text c="dimmed" ta="center" py="xl">
{tDiscovery("preview.idle")}
</Text>
) : services.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{tDiscovery("preview.empty")}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{tDiscovery("field.name")}</Table.Th>
<Table.Th>{tDiscovery("field.group")}</Table.Th>
<Table.Th>{tDiscovery("field.href")}</Table.Th>
<Table.Th>{tDiscovery("field.host")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{services.map((service) => (
<Table.Tr key={`${service.host}/${service.containerId}`}>
<Table.Td>
<Group gap="xs">
{service.icon && (
<Avatar size="sm" radius="sm" src={service.icon}>
{service.name.charAt(0)}
</Avatar>
)}
<Text size="sm">{service.name}</Text>
{service.integrationKind && (
<Badge size="xs" variant="light">
{service.integrationKind}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm">
{service.group}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={1}>
{service.href}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{service.host}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Card>
);
};
@@ -1,138 +0,0 @@
"use client";
import { useState } from "react";
import { Alert, Button, Card, Group, List, Loader, Popover, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconDatabaseExport } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { ConfigSummaryList } from "~/components/config-import/config-summary-list";
const exportWarningPointKeys = [
"management.page.importExport.export.warning.message",
"management.page.importExport.export.warning.storage",
"management.page.importExport.export.warning.sharing",
] as const;
export const ExportConfigCard = () => {
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
const { mutateAsync, isPending } = clientApi.config.exportFull.useMutation();
const [popoverOpened, setPopoverOpened] = useState(false);
const { data: preview, isLoading: previewLoading } = clientApi.config.previewExport.useQuery(undefined, {
enabled: popoverOpened,
});
const closePopover = () => setPopoverOpened(false);
const runExport = async () => {
await mutateAsync(undefined, {
onSuccess({ content, filename }) {
const blob = new Blob([content], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
showSuccessNotification({
title: t("management.page.importExport.export.notification.success.title"),
message: t("management.page.importExport.export.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.export.notification.error.title"),
message: t("management.page.importExport.export.notification.error.message"),
});
},
});
};
const handleConfirmExport = () => {
closePopover();
void runExport();
};
return (
<Card withBorder>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.export.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.export.description")}
</Text>
</Stack>
<Popover
opened={popoverOpened}
onChange={setPopoverOpened}
width={420}
position="bottom-end"
withArrow
shadow="md"
>
<Popover.Target>
<Group justify="flex-end">
<Button
leftSection={<IconDatabaseExport size="1rem" />}
loading={isPending}
disabled={isPending}
onClick={() => setPopoverOpened(true)}
>
{t("management.page.importExport.export.action")}
</Button>
</Group>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="md">
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size="1.25rem" stroke={1.5} />}
title={t("management.page.importExport.export.warning.title")}
>
<List size="sm" spacing="xs">
{exportWarningPointKeys.map((key) => (
<List.Item key={key}>{t(key)}</List.Item>
))}
</List>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.summary.title")}
</Text>
{previewLoading && (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
{t("management.page.importExport.preview.exportLoading")}
</Text>
</Group>
)}
{preview && <ConfigSummaryList counts={preview} t={t} />}
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closePopover}>
{t("common.action.cancel")}
</Button>
<Button
color="red.9"
onClick={handleConfirmExport}
disabled={previewLoading}
loading={isPending}
>
{t("common.action.continue")}
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
</Stack>
</Card>
);
};
@@ -1,126 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Alert, Button, Card, FileInput, Group, List, Stack, Text, Title } from "@mantine/core";
import { IconCheck, IconFileUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
export const ImportConfigCard = () => {
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
const [file, setFile] = useState<File | null>(null);
const [fileContent, setFileContent] = useState("");
const [importResult, setImportResult] = useState<{
boards: number;
apps: number;
integrations: number;
groups: number;
searchEngines: number;
warnings: string[];
} | null>(null);
const {
mutate: runPreview,
data: preview,
isPending: previewLoading,
reset: resetPreview,
} = clientApi.config.previewImport.useMutation();
const { mutateAsync, isPending } = clientApi.config.importFull.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage");
},
});
useEffect(() => {
if (!fileContent) {
resetPreview();
return;
}
runPreview({ content: fileContent });
}, [fileContent, runPreview, resetPreview]);
const importAllowed = preview?.compatibility.status === "compatible";
const handleImport = async () => {
if (!file || !importAllowed) {
return;
}
const content = await file.text();
await mutateAsync(
{ content },
{
onSuccess(result) {
setImportResult(result);
showSuccessNotification({
title: t("management.page.importExport.importConfig.notification.success.title"),
message: t("management.page.importExport.importConfig.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.importConfig.notification.error.title"),
message: t("management.page.importExport.importConfig.notification.error.message"),
});
},
},
);
};
return (
<Card withBorder>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.importConfig.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.importConfig.description")}
</Text>
</Stack>
<FileInput
rightSection={<IconFileUpload />}
accept="application/json"
value={file}
onChange={(newFile) => {
setFile(newFile);
setImportResult(null);
setFileContent("");
if (!newFile) {
return;
}
void newFile.text().then((content) => {
setFileContent(content);
});
}}
label={t("management.page.importExport.importConfig.fileLabel")}
/>
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
{importResult && (
<Alert color="green" icon={<IconCheck />}>
<List size="sm">
<List.Item>{t("management.page.importExport.importConfig.result.boardsImported", { count: importResult.boards })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.appsCreated", { count: importResult.apps })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.integrationsCreated", { count: importResult.integrations })}</List.Item>
<List.Item>{t("management.page.importExport.importConfig.result.groupsCreated", { count: importResult.groups })}</List.Item>
</List>
</Alert>
)}
<Group justify="end">
<Button loading={isPending} disabled={!file || !importAllowed || previewLoading} onClick={() => void handleImport()}>
{t("common.action.import")}
</Button>
</Group>
</Stack>
</Card>
);
};
@@ -1,181 +0,0 @@
"use client";
import { useState } from "react";
import { Button, Card, Code, FileInput, Group, Stack, Switch, Text, TextInput, Title } from "@mantine/core";
import { IconFileUpload } from "@tabler/icons-react";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import {
extractHomepageEnvVariables,
parseServicesYaml,
replaceHomepageEnvVariables,
} from "@homarr/board-portability/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { boardNameSchema } from "@homarr/validation/board";
export const ImportHomepageCard = () => {
const t = useI18n() as unknown as (key: string) => string;
const [fileValid, setFileValid] = useState(true);
const [rawContent, setRawContent] = useState("");
const [envVars, setEnvVars] = useState<string[]>([]);
const [envValues, setEnvValues] = useState<Record<string, string>>({});
const form = useZodForm(
z.object({
file: z.file(),
boardName: boardNameSchema,
createIntegrations: z.boolean(),
}),
{
mode: "controlled",
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
boardName: "",
createIntegrations: true,
},
onValuesChange(values, previous) {
void (async () => {
if (values.file === previous.file) return;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!values.file) return;
const content = await values.file.text();
setRawContent(content);
const vars = extractHomepageEnvVariables(content);
setEnvVars(vars);
setEnvValues(Object.fromEntries(vars.map((v: string) => [v, ""])));
const result = parseServicesYaml(content);
if (!result.success) {
setFileValid(false);
return;
}
setFileValid(true);
const suggestedName =
result.services[0]?.group.replaceAll(/[^A-Za-z0-9-_]/g, "-").replaceAll(/-+/g, "-") ?? "homepage";
if (form.values.boardName.trim().length === 0) {
form.setFieldValue("boardName", suggestedName.slice(0, 255));
}
})();
},
},
);
const { mutateAsync, isPending } = clientApi.board.importHomepageServices.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/boards");
},
});
const allEnvVarsFilled = envVars.length === 0 || envVars.every((v) => (envValues[v] ?? "").trim().length > 0);
return (
<Card withBorder>
<form
onSubmit={form.onSubmit((values) => {
if (!fileValid || !allEnvVarsFilled) return;
void (async () => {
const resolvedContent =
envVars.length > 0 ? replaceHomepageEnvVariables(rawContent, envValues) : rawContent;
await mutateAsync(
{
content: resolvedContent,
boardName: values.boardName,
createIntegrations: values.createIntegrations,
},
{
onSuccess() {
showSuccessNotification({
title: t("management.page.importExport.importHomepage.notification.success.title"),
message: t("management.page.importExport.importHomepage.notification.success.message"),
});
form.reset();
setRawContent("");
setEnvVars([]);
setEnvValues({});
},
onError() {
showErrorNotification({
title: t("management.page.importExport.importHomepage.notification.error.title"),
message: t("management.page.importExport.importHomepage.notification.error.message"),
});
},
},
);
})();
})}
>
<Stack>
<Stack gap="xs">
<Title order={3}>{t("management.page.importExport.importHomepage.title")}</Title>
<Text size="sm" c="dimmed">
{t("management.page.importExport.importHomepage.description")}
</Text>
</Stack>
<FileInput
rightSection={<IconFileUpload />}
withAsterisk
accept=".yaml,.yml"
{...form.getInputProps("file")}
error={
(form.getInputProps("file").error as string | undefined) ??
(!fileValid && form.isDirty("file") ? t("management.page.importExport.importHomepage.invalidFile") : undefined)
}
type="button"
label={t("management.page.importExport.importHomepage.fileLabel")}
/>
{envVars.length > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.importHomepage.envVariables.label")}
</Text>
<Text size="xs" c="dimmed">
{t("management.page.importExport.importHomepage.envVariables.description")}
</Text>
{envVars.map((varName) => (
<TextInput
key={varName}
label={<Code>{`{{${varName}}}`}</Code>}
placeholder={varName.replace("HOMEPAGE_VAR_", "").toLowerCase().replaceAll("_", ".")}
value={envValues[varName] ?? ""}
onChange={(event) => {
setEnvValues((prev) => ({ ...prev, [varName]: event.target.value }));
}}
withAsterisk
/>
))}
</Stack>
)}
<TextInput
withAsterisk
label={t("management.page.importExport.importHomepage.boardNameLabel")}
{...form.getInputProps("boardName")}
/>
<Switch
label={t("management.page.importExport.importHomepage.createIntegrations")}
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending} disabled={!allEnvVarsFilled}>
{t("common.action.import")}
</Button>
</Group>
</Stack>
</form>
</Card>
);
};
@@ -1,34 +0,0 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { DockerDiscoveryPanel } from "./docker-discovery-panel";
import { ExportConfigCard } from "./export-config-card";
import { ImportConfigCard } from "./import-config-card";
import { ImportHomepageCard } from "./import-homepage-card";
export default async function ImportExportPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const t = await getScopedI18n("management.page.importExport" as never) as unknown as (key: string) => string;
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{t("title")}</Title>
<ExportConfigCard />
<ImportConfigCard />
<ImportHomepageCard />
{env.ENABLE_DOCKER && <DockerDiscoveryPanel />}
</Stack>
</>
);
}
@@ -2,6 +2,7 @@
import { useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
@@ -14,8 +15,8 @@ export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const { activeLevels, fontSize } = useLogContext();
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null);
clientApi.log.subscribe.useSubscription(
{
@@ -37,31 +38,28 @@ export const TerminalComponent = () => {
return () => undefined;
}
const fitAddon = new FitAddon();
const terminal = new Terminal({
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize,
});
terminal.open(ref.current);
terminal.loadAddon(fitAddon);
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
const fitTerminal = () => fitAddon.fit();
const fitTimeout = setTimeout(fitTerminal);
const resizeObserver = new ResizeObserver(fitTerminal);
resizeObserver.observe(ref.current);
setTimeout(() => {
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
return () => {
clearTimeout(fitTimeout);
resizeObserver.disconnect();
fitAddon.dispose();
terminal.dispose();
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
};
}, []);
@@ -0,0 +1,75 @@
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);
}
}
}
@@ -0,0 +1,194 @@
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
}
}
}
}
@@ -0,0 +1,63 @@
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 });
}
}
+12
View File
@@ -0,0 +1,12 @@
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));
@@ -0,0 +1,30 @@
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",
},
});
}
@@ -0,0 +1,308 @@
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);
});
});
});
@@ -0,0 +1,114 @@
"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>
);
};
@@ -0,0 +1,91 @@
"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>
);
};
@@ -0,0 +1,227 @@
"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;
};
@@ -0,0 +1,3 @@
export { DatabaseRestoreFlow } from "./database-restore-flow";
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
export type { BackupAnalysis, RestoreStep } from "./types";
@@ -0,0 +1,60 @@
"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>
);
};
@@ -0,0 +1,32 @@
"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}
/>
);
};
@@ -0,0 +1,159 @@
"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>
);
};
@@ -0,0 +1,30 @@
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;
@@ -0,0 +1,187 @@
"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 };
};
@@ -1,138 +0,0 @@
"use client";
import { Alert, Group, List, Loader, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { ConfigImportPreview } from "@homarr/board-portability";
import { ConfigSummaryList } from "./config-summary-list";
const compatibilityAlertColor: Record<ConfigImportPreview["compatibility"]["status"], string> = {
compatible: "blue",
unsupportedVersion: "red",
invalidStructure: "red",
};
type CountSection = { labelKey: string; entries: { key: string; count: number }[] };
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const buildCountSections = (preview: ConfigImportPreview): CountSection[] => [
{
labelKey: "management.page.importExport.preview.importWillReuse",
entries: Object.entries(preview.toReuse)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.reuse${capitalize(key)}`, count })),
},
{
labelKey: "management.page.importExport.preview.importWillSkip",
entries: Object.entries(preview.toSkip)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.skip${capitalize(key)}`, count })),
},
{
labelKey: "management.page.importExport.preview.importWillUpdate",
entries: Object.entries(preview.toUpdate)
.filter(([, count]) => count > 0)
.map(([key, count]) => ({ key: `management.page.importExport.preview.update${capitalize(key)}`, count })),
},
];
type ConfigImportPreviewPanelProps = {
preview: ConfigImportPreview | undefined;
previewLoading: boolean;
t: (key: string, values?: Record<string, unknown>) => string;
};
export const ConfigImportPreviewPanel = ({ preview, previewLoading, t }: ConfigImportPreviewPanelProps) => {
if (!preview && !previewLoading) {
return null;
}
const importAllowed = preview?.compatibility.status === "compatible";
return (
<Stack gap="sm">
{previewLoading && (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
{t("management.page.importExport.preview.importLoading")}
</Text>
</Group>
)}
{preview && (
<>
<Alert
variant="light"
color={compatibilityAlertColor[preview.compatibility.status]}
icon={<IconAlertTriangle size="1rem" stroke={1.5} />}
title={
preview.compatibility.status === "compatible"
? t("management.page.importExport.preview.migrationNote", {
bundleVersion: preview.meta.bundleVersion || "?",
})
: t("management.page.importExport.preview.importIncompatible")
}
>
{preview.compatibility.status === "compatible" && preview.meta.exportedAt && (
<Text size="sm" mb="xs">
{t("management.page.importExport.preview.importMeta", {
exportedAt: preview.meta.exportedAt,
homarrVersion: preview.meta.homarrVersion,
bundleVersion: preview.meta.bundleVersion,
})}
</Text>
)}
{preview.compatibility.issues.length > 0 && (
<List size="sm" spacing={4}>
{preview.compatibility.issues.map((issue) => (
<List.Item key={issue}>{issue}</List.Item>
))}
</List>
)}
</Alert>
{importAllowed && (
<>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("management.page.importExport.preview.importWillCreate")}
</Text>
<ConfigSummaryList counts={preview.toCreate} t={t} />
</Stack>
{buildCountSections(preview)
.filter((section) => section.entries.length > 0)
.map((section) => (
<Stack gap="xs" key={section.labelKey}>
<Text size="sm" fw={500}>
{t(section.labelKey)}
</Text>
<List size="sm">
{section.entries.map((entry) => (
<List.Item key={entry.key}>
{t(entry.key, { count: entry.count })}
</List.Item>
))}
</List>
</Stack>
))}
{preview.warnings.length > 0 && (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size="1rem" />}>
<List size="sm">
{preview.warnings.map((warning) => (
<List.Item key={warning}>{warning}</List.Item>
))}
</List>
</Alert>
)}
</>
)}
</>
)}
</Stack>
);
};
@@ -1,35 +0,0 @@
"use client";
import { List, Text } from "@mantine/core";
import type { ConfigEntityCounts, ConfigEntityKey } from "@homarr/board-portability";
import { CONFIG_ENTITY_KEYS } from "@homarr/board-portability";
const TRANSLATION_PREFIX = "management.page.importExport.summary";
type ConfigSummaryListProps = {
counts: ConfigEntityCounts;
t: (key: string, values?: Record<string, unknown>) => string;
};
export const ConfigSummaryList = ({ counts, t }: ConfigSummaryListProps) => {
const entries = CONFIG_ENTITY_KEYS.filter((key) => counts[key] > 0);
if (entries.length === 0) {
return (
<Text size="sm" c="dimmed">
{t(`${TRANSLATION_PREFIX}.empty`)}
</Text>
);
}
return (
<List size="sm" spacing={4}>
{entries.map((key: ConfigEntityKey) => (
<List.Item key={key}>
{t(`${TRANSLATION_PREFIX}.${key}`, { count: counts[key] })}
</List.Item>
))}
</List>
);
};
+1
View File
@@ -2,3 +2,4 @@ declare module "*.png";
declare module "*.svg";
declare module "*.jpeg";
declare module "*.jpg";
declare module "sql.js";
-153
View File
@@ -1,153 +0,0 @@
# Homarr Docker label discovery example.
# This compose file demonstrates all supported label combinations for testing.
# Prerequisites: Homarr with Docker socket mounted.
# In Manage → Tools → Import/Export, use the Docker Discovery panel to scan.
services:
# ─── Full Homarr labels (all fields) ─────────────────────────────────────────
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: homarr-sonarr
ports:
- "8989:8989"
restart: unless-stopped
labels:
homarr.name: "Sonarr"
homarr.group: "Media"
homarr.href: "http://sonarr:8989"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg"
homarr.description: "TV series management"
homarr.ping: "http://sonarr:8989/ping"
homarr.id: "sonarr-main"
homarr.board: "homelab"
homarr.integration: "sonarr"
homarr.widget: "downloads"
radarr:
image: lscr.io/linuxserver/radarr:latest
container_name: homarr-radarr
ports:
- "7878:7878"
restart: unless-stopped
labels:
homarr.name: "Radarr"
homarr.group: "Media"
homarr.href: "http://radarr:7878"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg"
homarr.description: "Movie management"
homarr.ping: "http://radarr:7878/ping"
homarr.integration: "radarr"
homarr.widget: "downloads"
# ─── Minimal Homarr labels (required fields only) ────────────────────────────
portainer:
image: portainer/portainer-ce:latest
container_name: homarr-portainer
ports:
- "9000:9000"
restart: unless-stopped
labels:
homarr.name: "Portainer"
homarr.group: "Infrastructure"
homarr.href: "http://portainer:9000"
# ─── Homepage labels only (migration scenario) ───────────────────────────────
grafana:
image: grafana/grafana:latest
container_name: homarr-grafana
ports:
- "3000:3000"
restart: unless-stopped
labels:
homepage.name: "Grafana"
homepage.group: "Monitoring"
homepage.href: "http://grafana:3000"
homepage.icon: "grafana"
homepage.description: "Metrics and dashboards"
prometheus:
image: prom/prometheus:latest
container_name: homarr-prometheus
ports:
- "9090:9090"
restart: unless-stopped
labels:
homepage.name: "Prometheus"
homepage.group: "Monitoring"
homepage.href: "http://prometheus:9090"
homepage.icon: "prometheus"
# ─── Both label sets (homarr takes priority) ─────────────────────────────────
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: homarr-qbittorrent
ports:
- "8080:8080"
restart: unless-stopped
labels:
homarr.name: "qBittorrent"
homarr.group: "Downloads"
homarr.href: "http://qbittorrent:8080"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg"
homarr.description: "Torrent client"
homarr.ping: "http://qbittorrent:8080"
homarr.integration: "qBittorrent"
homarr.widget: "downloads"
homepage.name: "qBittorrent (homepage)"
homepage.group: "Downloads (homepage)"
homepage.href: "http://qbittorrent:8080/homepage"
homepage.icon: "qbittorrent"
homepage.description: "Should be ignored"
# ─── Hidden container (excluded from discovery) ──────────────────────────────
watchtower:
image: containrrr/watchtower:latest
container_name: homarr-watchtower
restart: unless-stopped
labels:
homarr.hide: "true"
homarr.name: "Watchtower"
homarr.group: "Infrastructure"
homarr.href: "http://watchtower:8080"
# ─── Targeting a specific board ──────────────────────────────────────────────
traefik:
image: traefik:latest
container_name: homarr-traefik
ports:
- "80:80"
- "8081:8080"
restart: unless-stopped
labels:
homarr.name: "Traefik"
homarr.group: "Networking"
homarr.href: "http://traefik:8080"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg"
homarr.board: "networking"
homarr.ping: "http://traefik:8080/ping"
# ─── Custom external ID ──────────────────────────────────────────────────────
jellyfin:
image: jellyfin/jellyfin:latest
container_name: homarr-jellyfin
ports:
- "8096:8096"
restart: unless-stopped
labels:
homarr.name: "Jellyfin"
homarr.group: "Media"
homarr.href: "http://jellyfin:8096"
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg"
homarr.description: "Media streaming"
homarr.ping: "http://jellyfin:8096/health"
homarr.id: "jellyfin-production"
homarr.integration: "jellyfin"
homarr.widget: "mediaServer"
# ─── No discovery labels (should be completely ignored) ──────────────────────
redis:
image: redis:alpine
container_name: homarr-redis
ports:
- "6379:6379"
restart: unless-stopped
+1 -1
View File
@@ -23,7 +23,7 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
client_max_body_size 32M;
client_max_body_size 256M;
}
}
}
+4 -4
View File
@@ -78,7 +78,7 @@
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
"axios@>=1.0.0 <1.8.2": ">=1.17.0",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0",
@@ -88,13 +88,13 @@
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
"react-is": "^19.2.6",
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
"react-is": "^19.2.7",
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
"sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
"tmp@<=0.2.3": ">=0.2.7",
"vite@>=5.0.0 <=5.4.18": ">=8.0.15"
"vite@>=5.0.0 <=5.4.18": ">=8.0.16"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
-1
View File
@@ -22,7 +22,6 @@
},
"dependencies": {
"@homarr/auth": "workspace:^0.1.0",
"@homarr/board-portability": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
-1
View File
@@ -28,7 +28,6 @@ export const appRouter = createTRPCRouter({
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
config: lazy(() => import("./router/config").then((mod) => mod.configRouter)),
});
// export type definition of API
+19 -134
View File
@@ -35,12 +35,6 @@ import {
getPermissionsWithParents,
widgetKinds,
} from "@homarr/definitions";
import {
exportBoardBundleAsync,
importBoardBundleAsync,
importHomepageServicesAsync,
parseBundleJson,
} from "@homarr/board-portability";
import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema";
@@ -49,9 +43,6 @@ import {
boardChangeVisibilitySchema,
boardCreateSchema,
boardDuplicateSchema,
boardExportBundleSchema,
boardImportBundleSchema,
boardNameSchema,
boardRenameSchema,
boardSaveLayoutsSchema,
boardSavePartialSettingsSchema,
@@ -63,7 +54,6 @@ import { zodUnionFromArray } from "@homarr/validation/enums";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import packageJson from "../../../../package.json";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
@@ -1337,113 +1327,6 @@ export const boardRouter = createTRPCRouter({
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
}),
exportBundle: protectedProcedure.input(boardExportBundleSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
const result = await exportBoardBundleAsync(ctx.db, input.id, packageJson.version);
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
});
}
return {
filename: result.filename,
content: result.content,
};
}),
importBundle: permissionRequiredProcedure
.requiresPermission("board-create")
.input(boardImportBundleSchema)
.mutation(async ({ ctx, input }) => {
await noBoardWithSimilarNameAsync(ctx.db, input.name);
const bundle = parseBundleJson(input.content);
const existingApps = await ctx.db.query.apps.findMany();
const existingIntegrations = await ctx.db.query.integrations.findMany();
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
const integrationIdsWithAccess = hasAccessForAll
? []
: await ctx.db
.selectDistinct({
id: integrationGroupPermissions.integrationId,
})
.from(integrationGroupPermissions)
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
.where(eq(groupMembers.userId, ctx.session.user.id))
.union(
ctx.db
.selectDistinct({ id: integrationUserPermissions.integrationId })
.from(integrationUserPermissions)
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
)
.then((result) => result.map((row) => row.id));
const report = await importBoardBundleAsync(
ctx.db,
bundle,
input.name,
ctx.session.user.id,
existingApps,
existingIntegrations,
integrationIdsWithAccess,
hasAccessForAll,
);
return {
boardId: report.boardId,
created: report.created,
warnings: report.warnings,
};
}),
importHomepageServices: permissionRequiredProcedure
.requiresPermission("board-create")
.input(
z.object({
content: z.string().min(1),
boardName: boardNameSchema,
createIntegrations: z.boolean().default(true),
}),
)
.mutation(async ({ ctx, input }) => {
await noBoardWithSimilarNameAsync(ctx.db, input.boardName);
try {
const report = await importHomepageServicesAsync(
ctx.db,
input.content,
input.boardName,
ctx.session.user.id,
input.createIntegrations,
);
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
homeBoardId: true,
},
});
if (!user?.homeBoardId) {
await ctx.db.update(users).set({ homeBoardId: report.boardId }).where(eq(users.id, ctx.session.user.id));
}
return {
boardId: report.boardId,
created: report.created,
warnings: report.warnings,
unmappedWidgetTypes: report.unmappedWidgetTypes,
};
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Could not import Homepage services",
});
}
}),
});
/**
@@ -1721,22 +1604,24 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
collapsed: collapseStates.at(0)?.collapsed ?? false,
}),
),
items: items.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
),
items: items
.map(({ integrations: itemIntegrations, ...item }) =>
parseItem({
...item,
layouts: item.layouts.map((layout) => ({
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
layoutId: layout.layoutId,
sectionId: layout.sectionId,
})),
integrationIds: itemIntegrations.map((item) => item.integration.id),
advancedOptions: superjson.parse<BoardItemAdvancedOptions>(item.advancedOptions),
options: superjson.parse<Record<string, unknown>>(item.options),
}),
)
.filter((item) => item !== null),
};
};
@@ -1752,7 +1637,7 @@ const parseItem = (item: unknown) => {
const result = outputItemSchema.safeParse(item);
if (!result.success) {
throw new Error(result.error.message);
return null;
}
return result.data;
};
-42
View File
@@ -1,42 +0,0 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import {
exportFullConfigAsync,
importFullConfigAsync,
parseAndValidateBundle,
previewExportFullConfigAsync,
previewImportFullConfigAsync,
} from "@homarr/board-portability";
import packageJson from "../../../../package.json";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const configRouter = createTRPCRouter({
previewExport: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await previewExportFullConfigAsync(ctx.db);
}),
previewImport: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
}),
exportFull: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
return await exportFullConfigAsync(ctx.db, packageJson.version);
}),
importFull: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
try {
const bundle = parseAndValidateBundle(input.content, packageJson.version);
return await importFullConfigAsync(ctx.db, bundle, ctx.session.user.id);
} catch (error) {
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
}
}),
});
@@ -3,8 +3,7 @@ import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton, listDiscoveredContainersAsync, syncDiscoveredServicesAsync } from "@homarr/docker";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { DockerSingleton } from "@homarr/docker";
import {
dockerContainersRequestHandler,
getContainerLogsAsync,
@@ -109,32 +108,6 @@ export const dockerRouter = createTRPCRouter({
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
getDiscoveredPreview: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.query(async ({ ctx }) => {
const serverSettings = await getServerSettingsAsync(ctx.db);
return await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
}),
syncDiscovery: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.mutation(async ({ ctx }) => {
const serverSettings = await getServerSettingsAsync(ctx.db);
const discovered = await listDiscoveredContainersAsync({
readHomepageLabels: serverSettings.docker.readHomepageLabels,
});
return await syncDiscoveredServicesAsync(discovered, {
targetBoardName: serverSettings.docker.targetBoardName,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
defaultItemWidth: serverSettings.docker.defaultItemWidth,
defaultItemHeight: serverSettings.docker.defaultItemHeight,
});
}),
logs: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
@@ -1,8 +1,5 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { importFullConfigAsync, parseAndValidateBundle, previewImportFullConfigAsync } from "@homarr/board-portability";
import { onboarding } from "@homarr/db/schema";
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
import {
ensureValidTokenOrThrow,
@@ -10,7 +7,6 @@ import {
importInitialOldmarrInputSchema,
} from "@homarr/old-import/import";
import packageJson from "../../../../../package.json";
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
@@ -23,7 +19,12 @@ export const importRouter = createTRPCRouter({
}),
validateToken: onboardingProcedure
.requiresStep("import")
.input(z.object({ checksum: z.string(), token: z.string() }))
.input(
z.object({
checksum: z.string(),
token: z.string(),
}),
)
.mutation(({ input }) => {
try {
ensureValidTokenOrThrow(input.checksum, input.token);
@@ -39,28 +40,4 @@ export const importRouter = createTRPCRouter({
await importInitialOldmarrAsync(ctx.db, input);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
previewInitialConfigImport: onboardingProcedure
.requiresStep("import")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
}),
importInitialConfigImport: onboardingProcedure
.requiresStep("import")
.input(z.object({ content: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
try {
const bundle = parseAndValidateBundle(input.content, packageJson.version);
await importFullConfigAsync(ctx.db, bundle, null);
const hasUsers = (bundle.users ?? []).length > 0;
if (hasUsers) {
await ctx.db.update(onboarding).set({ previousStep: "import", step: "finish" });
} else {
await nextOnboardingStepAsync(ctx.db, undefined);
}
} catch (error) {
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
}
}),
});
@@ -50,19 +50,6 @@ vi.mock("@homarr/docker/env", () => ({
ENABLE_DOCKER: true,
},
}));
vi.mock("@homarr/docker", () => ({
DockerSingleton: {
getInstances: () => [],
},
listDiscoveredContainersAsync: async () => [],
syncDiscoveredServicesAsync: async () => ({ created: 0, updated: 0, skipped: 0 }),
}));
vi.mock("@homarr/db/queries", () => ({
getServerSettingsAsync: async () => ({
docker: { targetBoardName: null, readHomepageLabels: true, pruneRemoved: false },
board: { enableStatusByDefault: true, forceDisableStatus: false },
}),
}));
const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) =>
({
@@ -86,8 +73,6 @@ const validInputs: {
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
invalidate: undefined,
getDiscoveredPreview: undefined,
syncDiscovery: undefined,
logs: { id: "1", tail: 200 },
subscribeLogs: { id: "1", tail: 200 },
};
+14 -36
View File
@@ -52,45 +52,23 @@ export const userRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const existingUser = await ctx.db.query.users.findFirst({
where: eq(users.email, input.email),
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
const userId = await createUserAsync(ctx.db, input);
const groupId = createId();
await ctx.db.insert(groups).values({
id: groupId,
name: credentialsAdminGroup,
ownerId: userId,
position: maxPosition + 1,
});
const userId = existingUser ? existingUser.id : await createUserAsync(ctx.db, input);
const existingGroup = await ctx.db.query.groups.findFirst({
where: eq(groups.name, credentialsAdminGroup),
await ctx.db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
let groupId: string;
if (existingGroup) {
groupId = existingGroup.id;
} else {
const maxPosition = await getMaxGroupPositionAsync(ctx.db);
groupId = createId();
await ctx.db.insert(groups).values({
id: groupId,
name: credentialsAdminGroup,
ownerId: userId,
position: maxPosition + 1,
});
await ctx.db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
}
const existingMembership = await ctx.db.query.groupMembers.findFirst({
where: and(eq(groupMembers.groupId, groupId), eq(groupMembers.userId, userId)),
await ctx.db.insert(groupMembers).values({
groupId,
userId,
});
if (!existingMembership) {
await ctx.db.insert(groupMembers).values({
groupId,
userId,
});
}
await nextOnboardingStepAsync(ctx.db, undefined);
}),
register: publicProcedure
-9
View File
@@ -1,9 +0,0 @@
export {
homarrBundleSchema,
homarrConfigBundleSchema,
type HomarrBundle,
type HomarrConfigBundle,
} from "./src/schema";
export { parseServicesYaml } from "./src/homepage/parse-services-yaml";
export { extractHomepageEnvVariables, replaceHomepageEnvVariables } from "./src/homepage/env-variables";
export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./src/homepage/types";
-25
View File
@@ -1,25 +0,0 @@
export {
CONFIG_BUNDLE_FORMAT_VERSION,
assessBundleCompatibility,
parseAndValidateBundle,
parseConfigBundleJson,
} from "./src/config-bundle-compat";
export { USER_DIRECT_FIELDS, USER_REF_FIELDS, GROUP_REF_FIELDS } from "./src/entity-fields";
export { resolveEntities, groupByKey, type EntityResolverDef, type ResolveResult } from "./src/resolve-entities";
export { homarrBundleSchema, homarrConfigBundleSchema, type HomarrBundle, type HomarrConfigBundle, type ConfigBundleUser } from "./src/schema";
export type { ConfigEntityCounts, ConfigEntityKey, ConfigExportPreview, ConfigImportPreview } from "./src/types";
export { CONFIG_ENTITY_KEYS, emptyCounts } from "./src/types";
export { exportBoardBundleAsync, bundleFilenameForBoard } from "./src/export";
export { exportFullConfigAsync } from "./src/export/export-full-config";
export { previewExportFullConfigAsync } from "./src/export/preview-export-full-config";
export { parseBundleJson, importBoardBundleAsync, type BundleImportReport } from "./src/import";
export { importFullConfigAsync, type ConfigImportReport } from "./src/import/import-full-config";
export { previewImportFullConfigAsync } from "./src/import/plan-full-config-import";
export {
parseServicesYaml,
importHomepageServicesAsync,
homepageWidgetMap,
type HomepageService,
type HomepageImportReport,
type ParseServicesYamlResult,
} from "./src/homepage";
-40
View File
@@ -1,40 +0,0 @@
{
"name": "@homarr/board-portability",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./index.ts",
"./client": "./client.ts",
"./homepage": "./src/homepage/index.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"superjson": "catalog:",
"yaml": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
@@ -1,178 +0,0 @@
import { describe, expect, test } from "vitest";
import type { HomarrBundle } from "../schema";
import { homarrBundleSchema } from "../schema";
import { bundleToJson, bundleFilenameForBoard } from "../export/to-json";
import { parseBundleJson } from "../import/parse-bundle";
const createMinimalBundle = (overrides: Partial<HomarrBundle> = {}): HomarrBundle => ({
version: "1.0",
exportedAt: new Date().toISOString(),
homarrVersion: "1.0.0-test",
boards: [
{
name: "Test Board",
settings: {
primaryColor: "#FA5252",
secondaryColor: "#868E96",
opacity: 100,
itemRadius: "md",
},
layouts: [
{
ref: "layout-1",
name: "Default",
columnCount: 10,
breakpoint: 0,
},
],
sections: [
{
ref: "section-1",
kind: "category",
name: "Apps",
yOffset: 0,
xOffset: 0,
},
],
items: [
{
ref: "item-1",
kind: "app",
options: { appId: "app-ref-1", openInNewTab: true },
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
integrationRefs: [],
appRef: "app-ref-1",
layouts: [
{
layoutRef: "layout-1",
sectionRef: "section-1",
x: 0,
y: 0,
w: 2,
h: 1,
},
],
},
],
},
],
apps: [
{
ref: "app-ref-1",
name: "Jellyfin",
href: "https://jellyfin.local",
iconUrl: "https://cdn.example/jellyfin.png",
description: "Media server",
pingUrl: "https://jellyfin.local/health",
},
],
integrations: [
{
ref: "int-ref-1",
kind: "jellyfin",
name: "Jellyfin",
url: "https://jellyfin.local",
secretKinds: ["apiKey"],
secrets: "REDACTED",
},
],
...overrides,
});
describe("bundle round-trip", () => {
test("serialized bundle parses back to equal object", () => {
const original = createMinimalBundle();
const json = bundleToJson(original);
const parsed = parseBundleJson(json);
expect(parsed.version).toBe(original.version);
expect(parsed.homarrVersion).toBe(original.homarrVersion);
expect(parsed.boards).toHaveLength(1);
expect(parsed.boards[0]?.name).toBe("Test Board");
expect(parsed.apps).toHaveLength(1);
expect(parsed.apps[0]?.name).toBe("Jellyfin");
expect(parsed.integrations).toHaveLength(1);
expect(parsed.integrations[0]?.secrets).toBe("REDACTED");
});
test("schema validates valid bundle", () => {
const bundle = createMinimalBundle();
const result = homarrBundleSchema.safeParse(bundle);
expect(result.success).toBe(true);
});
test("schema rejects missing boards", () => {
const invalid = { ...createMinimalBundle(), boards: [] };
const result = homarrBundleSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
test("schema rejects invalid version", () => {
const invalid = { ...createMinimalBundle(), version: "2.0" };
const result = homarrBundleSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
test("bundle preserves multiple items with layouts", () => {
const bundle = createMinimalBundle();
bundle.boards[0]?.items.push({
ref: "item-2",
kind: "clock",
options: { is24HourFormat: true },
advancedOptions: { title: "My Clock", customCssClasses: ["custom"], borderColor: "#000" },
integrationRefs: [],
layouts: [{ layoutRef: "layout-1", sectionRef: "section-1", x: 2, y: 0, w: 2, h: 1 }],
});
const json = bundleToJson(bundle);
const parsed = parseBundleJson(json);
expect(parsed.boards[0]?.items).toHaveLength(2);
expect(parsed.boards[0]?.items[1]?.kind).toBe("clock");
expect(parsed.boards[0]?.items[1]?.advancedOptions.title).toBe("My Clock");
});
test("bundle preserves section layouts", () => {
const bundle = createMinimalBundle();
const firstSection = bundle.boards[0]?.sections[0];
if (!firstSection) throw new Error("missing section");
firstSection.layouts = [
{
layoutRef: "layout-1",
parentSectionRef: "section-1",
xOffset: 0,
yOffset: 0,
width: 10,
height: 5,
},
];
const json = bundleToJson(bundle);
const parsed = parseBundleJson(json);
expect(parsed.boards[0]?.sections[0]?.layouts).toHaveLength(1);
expect(parsed.boards[0]?.sections[0]?.layouts?.[0]?.width).toBe(10);
});
test("empty apps and integrations are valid", () => {
const bundle = createMinimalBundle({ apps: [], integrations: [] });
if (bundle.boards[0]) {
bundle.boards[0].items = [];
}
const json = bundleToJson(bundle);
const parsed = parseBundleJson(json);
expect(parsed.apps).toHaveLength(0);
expect(parsed.integrations).toHaveLength(0);
});
});
describe("bundleFilenameForBoard", () => {
test("sanitizes board name for filename", () => {
expect(bundleFilenameForBoard("My Board")).toBe("homarr-board-My-Board.json");
expect(bundleFilenameForBoard("test_board-1")).toBe("homarr-board-test_board-1.json");
expect(bundleFilenameForBoard("special!@#chars")).toBe("homarr-board-special---chars.json");
});
});
@@ -1,237 +0,0 @@
import { describe, expect, test } from "vitest";
import type { HomarrConfigBundle } from "../schema";
import { homarrConfigBundleSchema } from "../schema";
import { assessBundleCompatibility, parseConfigBundleJson } from "../config-bundle-compat";
const createMinimalConfigBundle = (overrides: Partial<HomarrConfigBundle> = {}): HomarrConfigBundle => ({
version: "2.0",
type: "full-config",
exportedAt: new Date().toISOString(),
homarrVersion: "1.5.0",
encryptionKey: "a".repeat(64),
boards: [
{
ref: "board-uuid-1",
name: "Main Dashboard",
settings: {
primaryColor: "#FA5252",
secondaryColor: "#868E96",
opacity: 100,
itemRadius: "md",
},
layouts: [
{ ref: "layout-1", name: "Default", columnCount: 10, breakpoint: 0 },
],
sections: [
{ ref: "section-1", kind: "category", name: "Media", yOffset: 0, xOffset: 0 },
],
items: [
{
ref: "item-1",
kind: "app",
options: { appId: "app-ref-1", openInNewTab: true },
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
integrationRefs: ["int-ref-1"],
appRef: "app-ref-1",
layouts: [{ layoutRef: "layout-1", sectionRef: "section-1", x: 0, y: 0, w: 2, h: 1 }],
},
],
},
],
apps: [
{
ref: "app-ref-1",
name: "Jellyfin",
href: "https://jellyfin.local",
iconUrl: "https://cdn.example/jellyfin.png",
description: "Media server",
pingUrl: null,
},
],
integrations: [
{
ref: "int-ref-1",
kind: "jellyfin",
name: "Jellyfin",
url: "https://jellyfin.local",
secrets: [{ kind: "apiKey", value: "encrypted.value" }],
},
],
serverSettings: {
board: { enableStatusByDefault: true, forceDisableStatus: false },
docker: { targetBoardName: null, readHomepageLabels: true },
},
searchEngines: [
{
ref: "se-1",
iconUrl: "https://google.com/favicon.ico",
name: "Google",
short: "g",
description: "Google search",
urlTemplate: "https://google.com/search?q={query}",
type: "generic",
integrationRef: null,
},
],
groups: [
{
ref: "group-1",
name: "Admins",
position: 0,
ownerRef: "user-1",
homeBoardRef: null,
mobileHomeBoardRef: null,
permissions: ["admin"],
boardPermissions: [{ boardRef: "board-uuid-1", permission: "board-full-access" }],
integrationPermissions: [{ integrationRef: "int-ref-1", permission: "integration-use-credentials" }],
memberRefs: ["user-1"],
},
],
users: [
{
ref: "user-1",
name: "admin",
email: "admin@example.com",
password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
provider: "credentials",
homeBoardRef: "board-uuid-1",
mobileHomeBoardRef: null,
defaultSearchEngineRef: "se-1",
colorScheme: "dark",
firstDayOfWeek: 1,
openSearchInNewTab: true,
ddgBangs: true,
pingIconsEnabled: false,
},
],
...overrides,
});
describe("v2.0 config bundle schema", () => {
test("schema validates a valid config bundle", () => {
const bundle = createMinimalConfigBundle();
const result = homarrConfigBundleSchema.safeParse(bundle);
expect(result.success).toBe(true);
});
test("schema rejects v1.0 version for config bundle", () => {
const invalid = { ...createMinimalConfigBundle(), version: "1.0" };
const result = homarrConfigBundleSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
test("schema rejects missing type field", () => {
const { type: _type, ...invalid } = createMinimalConfigBundle();
const result = homarrConfigBundleSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
test("schema accepts bundle with no boards", () => {
const bundle = createMinimalConfigBundle({ boards: [] });
const result = homarrConfigBundleSchema.safeParse(bundle);
expect(result.success).toBe(true);
});
test("schema accepts board with optional ref field", () => {
const bundle = createMinimalConfigBundle();
delete (bundle.boards[0] as Record<string, unknown>).ref;
const result = homarrConfigBundleSchema.safeParse(bundle);
expect(result.success).toBe(true);
});
});
describe("v2.0 config bundle round-trip", () => {
test("serialized config bundle parses back correctly", () => {
const original = createMinimalConfigBundle();
const json = JSON.stringify(original);
const parsed = parseConfigBundleJson(json);
expect(parsed.version).toBe("2.0");
expect(parsed.type).toBe("full-config");
expect(parsed.homarrVersion).toBe("1.5.0");
expect(parsed.boards).toHaveLength(1);
expect(parsed.boards[0]?.ref).toBe("board-uuid-1");
expect(parsed.boards[0]?.name).toBe("Main Dashboard");
expect(parsed.apps).toHaveLength(1);
expect(parsed.integrations).toHaveLength(1);
expect(parsed.integrations[0]?.secrets).toHaveLength(1);
expect(parsed.searchEngines).toHaveLength(1);
expect(parsed.groups).toHaveLength(1);
expect(parsed.groups[0]?.boardPermissions).toHaveLength(1);
expect(parsed.groups[0]?.boardPermissions[0]?.boardRef).toBe("board-uuid-1");
});
test("preserves encryption key", () => {
const bundle = createMinimalConfigBundle();
const json = JSON.stringify(bundle);
const parsed = parseConfigBundleJson(json);
expect(parsed.encryptionKey).toBe("a".repeat(64));
});
test("preserves server settings", () => {
const bundle = createMinimalConfigBundle();
const json = JSON.stringify(bundle);
const parsed = parseConfigBundleJson(json);
expect(parsed.serverSettings).toEqual({
board: { enableStatusByDefault: true, forceDisableStatus: false },
docker: { targetBoardName: null, readHomepageLabels: true },
});
});
test("preserves users and group memberships", () => {
const bundle = createMinimalConfigBundle();
const json = JSON.stringify(bundle);
const parsed = parseConfigBundleJson(json);
expect(parsed.users).toHaveLength(1);
expect(parsed.users![0]?.email).toBe("admin@example.com");
expect(parsed.users![0]?.provider).toBe("credentials");
expect(parsed.users![0]?.homeBoardRef).toBe("board-uuid-1");
expect(parsed.groups[0]?.memberRefs).toEqual(["user-1"]);
expect(parsed.groups[0]?.ownerRef).toBe("user-1");
});
test("accepts bundle without users (backward compat)", () => {
const { users: _users, ...bundleWithoutUsers } = createMinimalConfigBundle();
const json = JSON.stringify(bundleWithoutUsers);
const parsed = parseConfigBundleJson(json);
expect(parsed.users).toBeUndefined();
});
});
describe("assessBundleCompatibility", () => {
test("marks valid bundle as compatible", () => {
const bundle = createMinimalConfigBundle();
const result = assessBundleCompatibility(bundle, "1.5.0");
expect(result.compatibility.status).toBe("compatible");
expect(result.bundle).not.toBeNull();
expect(result.compatibility.issues).toHaveLength(0);
});
test("marks compatible even with version mismatch (different Homarr version)", () => {
const bundle = createMinimalConfigBundle({ homarrVersion: "1.4.0" });
const result = assessBundleCompatibility(bundle, "1.5.0");
expect(result.compatibility.status).toBe("compatible");
expect(result.compatibility.issues).toHaveLength(1);
expect(result.compatibility.issues[0]).toContain("1.4.0");
});
test("rejects unsupported format version", () => {
const bundle = { ...createMinimalConfigBundle(), version: "3.0" };
const result = assessBundleCompatibility(bundle, "1.5.0");
expect(result.compatibility.status).toBe("unsupportedVersion");
expect(result.bundle).toBeNull();
});
test("rejects non-object input", () => {
const result = assessBundleCompatibility(null, "1.5.0");
expect(result.compatibility.status).toBe("invalidStructure");
expect(result.bundle).toBeNull();
});
test("rejects malformed structure", () => {
const result = assessBundleCompatibility({ version: "2.0", type: "full-config" }, "1.5.0");
expect(result.compatibility.status).toBe("invalidStructure");
});
});
@@ -1,118 +0,0 @@
import type { HomarrConfigBundle } from "./schema";
import { homarrConfigBundleSchema } from "./schema";
export const CONFIG_BUNDLE_FORMAT_VERSION = "2.0";
export const SUPPORTED_CONFIG_BUNDLE_FORMAT_VERSIONS = [CONFIG_BUNDLE_FORMAT_VERSION] as const;
export type BundleCompatibilityStatus = "compatible" | "unsupportedVersion" | "invalidStructure";
export type BundleCompatibility = {
status: BundleCompatibilityStatus;
bundleVersion: string | null;
bundleHomarrVersion: string | null;
currentHomarrVersion: string;
issues: string[];
};
const versionSupported = (version: string) =>
(SUPPORTED_CONFIG_BUNDLE_FORMAT_VERSIONS as readonly string[]).includes(version);
export const parseConfigBundleJson = (content: string): HomarrConfigBundle => {
const parsed: unknown = JSON.parse(content);
return homarrConfigBundleSchema.parse(parsed);
};
export const assessBundleCompatibility = (
raw: unknown,
currentHomarrVersion: string,
): { bundle: HomarrConfigBundle | null; compatibility: BundleCompatibility } => {
const issues: string[] = [];
if (raw === null || typeof raw !== "object") {
return {
bundle: null,
compatibility: {
status: "invalidStructure",
bundleVersion: null,
bundleHomarrVersion: null,
currentHomarrVersion,
issues: ["File is not a valid JSON object"],
},
};
}
const record = raw as Record<string, unknown>;
const bundleVersion = typeof record.version === "string" ? record.version : null;
const bundleHomarrVersion = typeof record.homarrVersion === "string" ? record.homarrVersion : null;
if (bundleVersion && !versionSupported(bundleVersion)) {
issues.push(
`Bundle format version "${bundleVersion}" is not supported. This instance expects version ${CONFIG_BUNDLE_FORMAT_VERSION}.`,
);
return {
bundle: null,
compatibility: {
status: "unsupportedVersion",
bundleVersion,
bundleHomarrVersion,
currentHomarrVersion,
issues,
},
};
}
const parseResult = homarrConfigBundleSchema.safeParse(raw);
if (!parseResult.success) {
issues.push("Bundle structure does not match the current Homarr config schema.");
issues.push("Ensure the exporting instance was upgraded and migrations have run before export.");
return {
bundle: null,
compatibility: {
status: "invalidStructure",
bundleVersion,
bundleHomarrVersion,
currentHomarrVersion,
issues,
},
};
}
const bundle = parseResult.data;
if (bundle.homarrVersion !== currentHomarrVersion) {
issues.push(
`Exported from Homarr ${bundle.homarrVersion}; this instance is ${currentHomarrVersion}. Import is allowed when the bundle format matches.`,
);
}
return {
bundle,
compatibility: {
status: "compatible",
bundleVersion: bundle.version,
bundleHomarrVersion: bundle.homarrVersion,
currentHomarrVersion,
issues,
},
};
};
export const parseAndValidateBundle = (
content: string,
currentHomarrVersion: string,
): HomarrConfigBundle => {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new Error("Invalid JSON");
}
const { bundle, compatibility } = assessBundleCompatibility(parsed, currentHomarrVersion);
if (!bundle || compatibility.status !== "compatible") {
throw new Error(compatibility.issues.join(" "));
}
return bundle;
};
@@ -1,59 +0,0 @@
/**
* Declarative field definitions for entities in the config bundle.
*
* To add a new user preference:
* 1. Add the column to the DB schema (migration)
* 2. Add the field name to USER_DIRECT_FIELDS below
* That's it — export, import, and schema all derive from this.
*
* To add a new FK reference on users:
* 1. Add the column to the DB schema (migration)
* 2. Add an entry to USER_REF_FIELDS below (bundleKey → dbColumn → refMap name)
*/
/**
* User fields copied directly between bundle and DB (no transformation).
* Adding a new preference here automatically includes it in export AND import.
*/
export const USER_DIRECT_FIELDS = [
"name",
"email",
"password",
"provider",
"colorScheme",
"firstDayOfWeek",
"openSearchInNewTab",
"ddgBangs",
"pingIconsEnabled",
] as const;
export type UserDirectField = (typeof USER_DIRECT_FIELDS)[number];
/**
* User FK reference fields: bundle uses `xxxRef`, DB uses `xxxId`.
* Key = bundle field name (with "Ref" suffix)
* Value = which ref map to use during import (matches the entity group name)
*/
export const USER_REF_FIELDS = {
homeBoardRef: "boards",
mobileHomeBoardRef: "boards",
defaultSearchEngineRef: "searchEngines",
} as const;
export type UserRefField = keyof typeof USER_REF_FIELDS;
/**
* Derives the DB column name from a bundle ref field name.
* "homeBoardRef" → "homeBoardId"
*/
export const refFieldToDbColumn = (refField: string): string =>
refField.replace(/Ref$/, "Id");
/**
* Group FK reference fields.
*/
export const GROUP_REF_FIELDS = {
ownerRef: "users",
homeBoardRef: "boards",
mobileHomeBoardRef: "boards",
} as const;
@@ -1,215 +0,0 @@
import SuperJSON from "superjson";
import { env } from "@homarr/common/env";
import type { Database } from "@homarr/db";
import {
boardGroupPermissions,
groupMembers,
groupPermissions,
groups,
integrationGroupPermissions,
integrations,
integrationSecrets,
searchEngines,
serverSettings,
users,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import { CONFIG_BUNDLE_FORMAT_VERSION } from "../config-bundle-compat";
import { USER_DIRECT_FIELDS, USER_REF_FIELDS } from "../entity-fields";
import { groupByKey } from "../resolve-entities";
import type { ConfigBundleUser, HomarrBundleApp, HomarrConfigBundle } from "../schema";
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
import { loadBoardGraphAsync } from "./load-board-graph";
import { buildBoardSettings } from "./serialize-board";
/**
* Exports a user row using USER_DIRECT_FIELDS and USER_REF_FIELDS.
* Adding a new preference to USER_DIRECT_FIELDS automatically includes it here.
*/
const exportUserRow = (user: Record<string, unknown>): ConfigBundleUser => {
const bundle: Record<string, unknown> = { ref: user.id };
for (const field of USER_DIRECT_FIELDS) {
bundle[field] = user[field] ?? null;
}
for (const refField of Object.keys(USER_REF_FIELDS)) {
const dbCol = refField.replace(/Ref$/, "Id");
bundle[refField] = user[dbCol] ?? null;
}
return bundle as unknown as ConfigBundleUser;
};
export const exportFullConfigAsync = async (
db: Database,
homarrVersion: string,
): Promise<{ content: string; filename: string }> => {
const allBoards = await db.query.boards.findMany();
const allApps = await db.query.apps.findMany();
const allIntegrations = await db.select().from(integrations);
const allSecrets = await db.select().from(integrationSecrets);
const allServerSettings = await db.select().from(serverSettings);
const allSearchEngines = await db.select().from(searchEngines);
const allGroups = await db.select().from(groups);
const allGroupPermissions = await db.select().from(groupPermissions);
const allBoardGroupPermissions = await db.select().from(boardGroupPermissions);
const allIntegrationGroupPermissions = await db.select().from(integrationGroupPermissions);
const allUsers = await db.select().from(users);
const allGroupMembers = await db.select().from(groupMembers);
const secretsByIntegration = groupByKey(allSecrets, (s) => s.integrationId);
const bundleIntegrations = allIntegrations.map((integration) => ({
ref: integration.id,
kind: integration.kind,
name: integration.name,
url: integration.url,
secrets: (secretsByIntegration.get(integration.id) ?? []).map((s) => ({
kind: s.kind as IntegrationSecretKind,
value: s.value,
})),
}));
const bundleApps: HomarrBundleApp[] = allApps.map((app) => ({
ref: app.id,
name: app.name,
href: app.href,
iconUrl: app.iconUrl,
description: app.description,
pingUrl: app.pingUrl,
}));
const appIdSet = new Set(allApps.map((a) => a.id));
const appIdToRef = new Map(allApps.map((app) => [app.id, app.id] as const));
const bundleBoards = [];
for (const boardRow of allBoards) {
const boardGraph = await loadBoardGraphAsync(db, boardRow.id);
if (!boardGraph) continue;
const bundleItems = boardGraph.items.map((item) => {
const options = parseStoredValue<Record<string, unknown>>(item.options, {});
const advancedOptions = itemAdvancedOptionsSchema.parse(
parseStoredValue(item.advancedOptions, itemAdvancedOptionsSchema.parse({})),
);
const portableOptions = replaceAppIdsInValue(options, appIdToRef) as Record<string, unknown>;
const appRef = typeof options.appId === "string" && appIdSet.has(options.appId) ? options.appId : undefined;
return {
ref: item.id,
kind: item.kind,
options: portableOptions,
advancedOptions,
integrationRefs: item.integrations.map(({ integrationId }) => integrationId),
appRef,
layouts: item.layouts.map((layout) => ({
layoutRef: layout.layoutId,
sectionRef: layout.sectionId,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
})),
};
});
bundleBoards.push({
ref: boardRow.id,
name: boardGraph.name,
settings: buildBoardSettings(boardGraph),
layouts: boardGraph.layouts.map((layout) => ({
ref: layout.id,
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
})),
sections: boardGraph.sections.map((section) => ({
ref: section.id,
kind: section.kind,
name: section.name,
yOffset: section.yOffset,
xOffset: section.xOffset,
options: section.options ? (parseStoredValue(section.options, {}) as Record<string, unknown>) : undefined,
layouts:
section.layouts.length > 0
? section.layouts.map((layout) => ({
layoutRef: layout.layoutId,
parentSectionRef: layout.parentSectionId ?? section.id,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
}))
: undefined,
})),
items: bundleItems,
});
}
const settingsRecord: Record<string, unknown> = {};
for (const setting of allServerSettings) {
settingsRecord[setting.settingKey] = SuperJSON.parse(setting.value);
}
const bundleSearchEngines = allSearchEngines.map((se) => ({
ref: se.id,
iconUrl: se.iconUrl,
name: se.name,
short: se.short,
description: se.description,
urlTemplate: se.urlTemplate,
type: se.type,
integrationRef: se.integrationId,
}));
const permissionsByGroup = groupByKey(allGroupPermissions, (gp) => gp.groupId);
const boardPermsByGroup = groupByKey(allBoardGroupPermissions, (bp) => bp.groupId);
const integrationPermsByGroup = groupByKey(allIntegrationGroupPermissions, (ip) => ip.groupId);
const membersByGroup = groupByKey(allGroupMembers, (gm) => gm.groupId);
const bundleGroups = allGroups.map((group) => ({
ref: group.id,
name: group.name,
position: group.position,
ownerRef: group.ownerId ?? null,
homeBoardRef: group.homeBoardId ?? null,
mobileHomeBoardRef: group.mobileHomeBoardId ?? null,
permissions: (permissionsByGroup.get(group.id) ?? []).map((gp) => gp.permission),
boardPermissions: (boardPermsByGroup.get(group.id) ?? []).map((bp) => ({
boardRef: bp.boardId,
permission: bp.permission,
})),
integrationPermissions: (integrationPermsByGroup.get(group.id) ?? []).map((ip) => ({
integrationRef: ip.integrationId,
permission: ip.permission,
})),
memberRefs: (membersByGroup.get(group.id) ?? []).map((gm) => gm.userId),
}));
const bundleUsers = allUsers.map((user) => exportUserRow(user as unknown as Record<string, unknown>));
const bundle: HomarrConfigBundle = {
version: CONFIG_BUNDLE_FORMAT_VERSION,
type: "full-config",
exportedAt: new Date().toISOString(),
homarrVersion,
encryptionKey: env.SECRET_ENCRYPTION_KEY ?? "0".repeat(64),
boards: bundleBoards,
apps: bundleApps,
integrations: bundleIntegrations,
serverSettings: settingsRecord,
searchEngines: bundleSearchEngines,
groups: bundleGroups,
users: bundleUsers,
};
const now = new Date().toISOString().slice(0, 10);
return {
content: JSON.stringify(bundle, null, 2),
filename: `homarr-config-${now}.json`,
};
};
@@ -1,23 +0,0 @@
import type { Database } from "@homarr/db";
import { loadBoardGraphAsync } from "./load-board-graph";
import { serializeBoardToBundleAsync } from "./serialize-board";
import { bundleFilenameForBoard, bundleToJson } from "./to-json";
export const exportBoardBundleAsync = async (db: Database, boardId: string, homarrVersion: string) => {
const board = await loadBoardGraphAsync(db, boardId);
if (!board) {
return null;
}
const bundle = await serializeBoardToBundleAsync(db, board, homarrVersion);
return {
bundle,
filename: bundleFilenameForBoard(board.name),
content: bundleToJson(bundle),
};
};
export { loadBoardGraphAsync } from "./load-board-graph";
export { serializeBoardToBundleAsync } from "./serialize-board";
export { bundleToJson, bundleFilenameForBoard } from "./to-json";
@@ -1,35 +0,0 @@
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { boards } from "@homarr/db/schema";
export const loadBoardGraphAsync = async (db: Database, boardId: string) => {
const board = await db.query.boards.findFirst({
where: eq(boards.id, boardId),
with: {
layouts: true,
sections: {
with: {
layouts: true,
},
},
items: {
with: {
layouts: true,
integrations: {
with: {
integration: {
with: {
secrets: true,
},
},
},
},
},
},
},
});
return board;
};
export type LoadedBoardGraph = NonNullable<Awaited<ReturnType<typeof loadBoardGraphAsync>>>;
@@ -1,42 +0,0 @@
import type { Database } from "@homarr/db";
import {
groups,
integrationSecrets,
integrations,
items,
layouts,
searchEngines,
sections,
serverSettings,
users,
} from "@homarr/db/schema";
import type { ConfigExportPreview } from "../types";
export const previewExportFullConfigAsync = async (db: Database): Promise<ConfigExportPreview> => {
const allBoards = await db.query.boards.findMany();
const allApps = await db.query.apps.findMany();
const allIntegrations = await db.select().from(integrations);
const allSecrets = await db.select().from(integrationSecrets);
const allItems = await db.select().from(items);
const allSections = await db.select().from(sections);
const allLayouts = await db.select().from(layouts);
const allSearchEngines = await db.select().from(searchEngines);
const allGroups = await db.select().from(groups);
const allUsers = await db.select().from(users);
const allServerSettings = await db.select().from(serverSettings);
return {
boards: allBoards.length,
apps: allApps.length,
integrations: allIntegrations.length,
secrets: allSecrets.length,
widgets: allItems.length,
sections: allSections.length,
layouts: allLayouts.length,
searchEngines: allSearchEngines.length,
groups: allGroups.length,
users: allUsers.length,
serverSettings: allServerSettings.length,
};
};
@@ -1,168 +0,0 @@
import { inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import type { HomarrBundle, HomarrBundleApp, HomarrBundleIntegration } from "../schema";
import { parseStoredValue, replaceAppIdsInValue } from "../utils";
import type { LoadedBoardGraph } from "./load-board-graph";
export const buildBoardSettings = (board: LoadedBoardGraph) => ({
pageTitle: board.pageTitle,
metaTitle: board.metaTitle,
logoImageUrl: board.logoImageUrl,
faviconImageUrl: board.faviconImageUrl,
backgroundImageUrl: board.backgroundImageUrl,
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
customCss: board.customCss,
iconColor: board.iconColor,
itemRadius: board.itemRadius,
disableStatus: board.disableStatus,
isPublic: board.isPublic,
});
const walkValues = (value: unknown, onString: (candidate: string) => void) => {
if (typeof value === "string") {
onString(value);
return;
}
if (Array.isArray(value)) {
value.forEach((entry) => walkValues(entry, onString));
return;
}
if (value !== null && typeof value === "object") {
Object.values(value).forEach((entry) => walkValues(entry, onString));
}
};
export const serializeBoardToBundleAsync = async (
db: Database,
board: LoadedBoardGraph,
homarrVersion: string,
): Promise<HomarrBundle> => {
const appIdCandidates = new Set<string>();
const integrationRecords = new Map<
string,
{
id: string;
kind: LoadedBoardGraph["items"][number]["integrations"][number]["integration"]["kind"];
name: string;
url: string;
secretKinds: IntegrationSecretKind[];
}
>();
board.items.forEach((item) => {
const options = parseStoredValue<Record<string, unknown>>(item.options, {});
walkValues(options, (candidate) => appIdCandidates.add(candidate));
item.integrations.forEach(({ integration }) => {
integrationRecords.set(integration.id, {
id: integration.id,
kind: integration.kind,
name: integration.name,
url: integration.url,
secretKinds: integration.secrets.map((secret) => secret.kind),
});
});
});
const dbApps =
appIdCandidates.size > 0
? await db.query.apps.findMany({
where: inArray(apps.id, [...appIdCandidates]),
})
: [];
const appIdToRef = new Map(dbApps.map((app) => [app.id, app.id] as const));
const bundleApps: HomarrBundleApp[] = dbApps.map((app) => ({
ref: app.id,
name: app.name,
href: app.href,
iconUrl: app.iconUrl,
description: app.description,
pingUrl: app.pingUrl,
}));
const bundleIntegrations: HomarrBundleIntegration[] = [...integrationRecords.values()].map((integration) => ({
ref: integration.id,
kind: integration.kind,
name: integration.name,
url: integration.url,
secretKinds: integration.secretKinds,
secrets: "REDACTED" as const,
}));
const bundleItems = board.items.map((item) => {
const options = parseStoredValue<Record<string, unknown>>(item.options, {});
const advancedOptions = itemAdvancedOptionsSchema.parse(
parseStoredValue(item.advancedOptions, itemAdvancedOptionsSchema.parse({})),
);
const portableOptions = replaceAppIdsInValue(options, appIdToRef) as Record<string, unknown>;
const appRef = typeof options.appId === "string" ? appIdToRef.get(options.appId) : undefined;
return {
ref: item.id,
kind: item.kind,
options: portableOptions,
advancedOptions,
integrationRefs: item.integrations.map(({ integrationId }) => integrationId),
appRef,
layouts: item.layouts.map((layout) => ({
layoutRef: layout.layoutId,
sectionRef: layout.sectionId,
x: layout.xOffset,
y: layout.yOffset,
w: layout.width,
h: layout.height,
})),
};
});
return {
version: "1.0",
exportedAt: new Date().toISOString(),
homarrVersion,
boards: [
{
name: board.name,
settings: buildBoardSettings(board),
layouts: board.layouts.map((layout) => ({
ref: layout.id,
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
})),
sections: board.sections.map((section) => ({
ref: section.id,
kind: section.kind,
name: section.name,
yOffset: section.yOffset,
xOffset: section.xOffset,
options: section.options ? (parseStoredValue(section.options, {}) as Record<string, unknown>) : undefined,
layouts:
section.layouts.length > 0
? section.layouts.map((layout) => ({
layoutRef: layout.layoutId,
parentSectionRef: layout.parentSectionId ?? section.id,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
}))
: undefined,
})),
items: bundleItems,
},
],
apps: bundleApps,
integrations: bundleIntegrations,
};
};
@@ -1,10 +0,0 @@
import type { HomarrBundle } from "../schema";
export const bundleToJson = (bundle: HomarrBundle) => {
return JSON.stringify(bundle, null, 2);
};
export const bundleFilenameForBoard = (boardName: string) => {
const sanitized = boardName.replace(/[^A-Za-z0-9-_]/g, "-");
return `homarr-board-${sanitized}.json`;
};
@@ -1,15 +0,0 @@
const ENV_VAR_PATTERN = /\{\{(HOMEPAGE_VAR_[A-Z0-9_]+)\}\}/g;
export const extractHomepageEnvVariables = (content: string): string[] => {
const matches = new Set<string>();
for (const match of content.matchAll(ENV_VAR_PATTERN)) {
matches.add(match[1] ?? "");
}
return [...matches].sort();
};
export const replaceHomepageEnvVariables = (
content: string,
values: Record<string, string>,
): string =>
content.replaceAll(ENV_VAR_PATTERN, (full, varName: string) => values[varName] ?? full);
@@ -1,76 +0,0 @@
import { handleTransactionsAsync } from "@homarr/db";
import type { Database } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import { parseServicesYaml } from "./parse-services-yaml";
import { prepareHomepageImport } from "./prepare-homepage-import";
export type HomepageImportReport = {
boardId: string;
created: {
apps: number;
integrations: number;
items: number;
sections: number;
};
warnings: string[];
unmappedWidgetTypes: string[];
};
export const importHomepageServicesAsync = async (
db: Database,
content: string,
boardName: string,
creatorId: string,
createIntegrations: boolean,
): Promise<HomepageImportReport> => {
const parsed = parseServicesYaml(content);
if (!parsed.success) {
throw new Error(parsed.error);
}
const prepared = prepareHomepageImport(parsed.services, boardName, creatorId, createIntegrations);
const insertCollection = createDbInsertCollectionForTransaction([
"apps",
"integrations",
"integrationSecrets",
"boards",
"layouts",
"sections",
"items",
"itemLayouts",
"integrationItems",
]);
insertCollection.apps.push(...prepared.apps);
insertCollection.integrations.push(...prepared.integrations);
insertCollection.integrationSecrets.push(...prepared.integrationSecrets);
insertCollection.boards.push(prepared.board);
insertCollection.layouts.push(...prepared.layouts);
insertCollection.sections.push(...prepared.sections);
insertCollection.items.push(...prepared.items);
insertCollection.itemLayouts.push(...prepared.itemLayouts);
insertCollection.integrationItems.push(...prepared.integrationItems);
await handleTransactionsAsync(db, {
async handleAsync(innerDb) {
await insertCollection.insertAllAsync(innerDb);
},
handleSync(innerDb) {
insertCollection.insertAll(innerDb);
},
});
return {
boardId: prepared.board.id,
created: {
apps: prepared.apps.length,
integrations: prepared.integrations.length,
items: prepared.items.length,
sections: prepared.sections.length,
},
warnings: prepared.warnings,
unmappedWidgetTypes: prepared.unmappedWidgetTypes,
};
};
@@ -1,5 +0,0 @@
export { parseServicesYaml } from "./parse-services-yaml";
export { homepageWidgetMap, type HomepageWidgetMapping } from "@homarr/definitions";
export { prepareHomepageImport, resolveHomepageIconUrl, type PreparedHomepageImport } from "./prepare-homepage-import";
export { importHomepageServicesAsync, type HomepageImportReport } from "./import";
export type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./types";
@@ -1,111 +0,0 @@
import { describe, expect, test } from "vitest";
import { parseServicesYaml } from "./parse-services-yaml";
const sampleYaml = `
- Media:
- Sonarr:
icon: sonarr.png
href: http://sonarr.host/
description: Series management
ping: sonarr.host
widget:
type: sonarr
url: http://sonarr.host
key: apikeyhere
- Radarr:
icon: radarr.png
href: http://radarr.host/
widgets:
- type: radarr
url: http://radarr.host
key: radarrkey
- Downloads:
- qBittorrent:
icon: qbittorrent.png
href: http://qbit.host/
widget:
type: qbittorrent
url: http://qbit.host
username: admin
password: pass123
`;
describe("parseServicesYaml", () => {
test("parses groups, services, singular widget, and plural widgets", () => {
const result = parseServicesYaml(sampleYaml);
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.services).toHaveLength(3);
const sonarr = result.services.find((service) => service.name === "Sonarr");
expect(sonarr).toMatchObject({
group: "Media",
href: "http://sonarr.host/",
ping: "sonarr.host",
});
expect(sonarr?.widgets).toHaveLength(1);
expect(sonarr?.widgets[0]).toMatchObject({ type: "sonarr", key: "apikeyhere" });
const radarr = result.services.find((service) => service.name === "Radarr");
expect(radarr?.widgets).toHaveLength(1);
expect(radarr?.widgets[0]?.type).toBe("radarr");
const qbit = result.services.find((service) => service.name === "qBittorrent");
expect(qbit?.group).toBe("Downloads");
expect(qbit?.widgets[0]).toMatchObject({ type: "qbittorrent", fields: { username: "admin" } });
});
test("flattens nested groups with separator", () => {
const yaml = `
- Parent:
- Child:
- Service:
href: http://service.local
`;
const result = parseServicesYaml(yaml);
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.services[0]?.group).toBe("Parent / Child");
expect(result.services[0]?.name).toBe("Service");
});
test("parses services without widgets", () => {
const yaml = `
- Tools:
- Homepage:
href: http://homepage.local
`;
const result = parseServicesYaml(yaml);
expect(result.success).toBe(true);
if (!result.success) {
return;
}
expect(result.services[0]?.widgets).toEqual([]);
});
test("returns empty services for empty content", () => {
expect(parseServicesYaml(" ")).toEqual({ success: true, services: [] });
});
test("handles malformed yaml gracefully", () => {
const result = parseServicesYaml("invalid: yaml: [");
expect(result.success).toBe(false);
if (result.success) {
return;
}
expect(result.error).toBe("Invalid YAML");
});
test("rejects non-array root documents", () => {
const result = parseServicesYaml("name: not-an-array");
expect(result).toEqual({ success: false, error: "Homepage services.yaml must be a YAML array" });
});
});
@@ -1,111 +0,0 @@
import { parse as parseYaml } from "yaml";
import type { HomepageService, HomepageWidget, ParseServicesYamlResult } from "./types";
const extractWidgets = (config: Record<string, unknown>): HomepageWidget[] => {
const singular = config.widget;
const plural = config.widgets;
if (Array.isArray(plural)) {
return plural.filter(isWidgetConfig).map(normalizeWidget);
}
if (isWidgetConfig(singular)) {
return [normalizeWidget(singular)];
}
return [];
};
const isWidgetConfig = (value: unknown): value is Record<string, unknown> & { type: string } => {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
"type" in value &&
typeof value.type === "string"
);
};
const normalizeWidget = (widget: Record<string, unknown>): HomepageWidget => {
const { type, url, key, ...rest } = widget;
return {
type: String(type),
...(typeof url === "string" ? { url } : {}),
...(typeof key === "string" ? { key } : {}),
fields: rest,
};
};
const isServiceConfig = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
const parseService = (group: string, name: string, config: Record<string, unknown>): HomepageService => {
const icon = typeof config.icon === "string" ? config.icon : undefined;
const href = typeof config.href === "string" ? config.href : undefined;
const description = typeof config.description === "string" ? config.description : undefined;
const ping = typeof config.ping === "string" ? config.ping : undefined;
const siteMonitor = typeof config.siteMonitor === "string" ? config.siteMonitor : undefined;
return {
group,
name,
icon,
href,
description,
ping,
siteMonitor,
widgets: extractWidgets(config),
};
};
const collectServices = (entries: unknown[], groupPath: string): HomepageService[] => {
const services: HomepageService[] = [];
for (const entry of entries) {
if (!isServiceConfig(entry)) {
continue;
}
const keys = Object.keys(entry);
if (keys.length !== 1) {
continue;
}
const name = keys[0] ?? "";
const value = entry[name];
if (Array.isArray(value)) {
const nestedGroup = groupPath ? `${groupPath} / ${name}` : name;
services.push(...collectServices(value, nestedGroup));
continue;
}
if (isServiceConfig(value)) {
services.push(parseService(groupPath.length > 0 ? groupPath : "Services", name, value));
}
}
return services;
};
export const parseServicesYaml = (content: string): ParseServicesYamlResult => {
const trimmed = content.trim();
if (trimmed.length === 0) {
return { success: true, services: [] };
}
let parsed: unknown;
try {
parsed = parseYaml(trimmed);
} catch {
return { success: false, error: "Invalid YAML" };
}
if (!Array.isArray(parsed)) {
return { success: false, error: "Homepage services.yaml must be a YAML array" };
}
return { success: true, services: collectServices(parsed, "") };
};
@@ -1,346 +0,0 @@
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
import type { InferInsertModel } from "@homarr/db";
import type {
apps,
boards,
integrationItems,
integrationSecrets,
integrations,
itemLayouts,
items,
layouts,
sections,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { emptySuperJSON, homepageWidgetMap, integrationDefs } from "@homarr/definitions";
import { stringifyForDb } from "../utils";
import type { HomepageService, HomepageWidget } from "./types";
const DASHBOARD_ICON_CDN = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg";
const ENV_VAR_PATTERN = /^\$\{[^}]+\}$/;
const COLUMN_COUNT = 10;
const WIDGET_WIDTH = 2;
export type PreparedHomepageImport = {
apps: InferInsertModel<typeof apps>[];
integrations: InferInsertModel<typeof integrations>[];
integrationSecrets: InferInsertModel<typeof integrationSecrets>[];
board: InferInsertModel<typeof boards>;
layouts: InferInsertModel<typeof layouts>[];
sections: InferInsertModel<typeof sections>[];
items: InferInsertModel<typeof items>[];
itemLayouts: InferInsertModel<typeof itemLayouts>[];
integrationItems: InferInsertModel<typeof integrationItems>[];
warnings: string[];
unmappedWidgetTypes: string[];
};
type GridPosition = { x: number; y: number };
const advanceGrid = (grid: GridPosition, width: number): GridPosition => {
const nextX = grid.x + width;
if (nextX >= COLUMN_COUNT) {
return { x: 0, y: grid.y + 1 };
}
return { x: nextX, y: grid.y };
};
const isEnvVarReference = (value: string) => ENV_VAR_PATTERN.test(value.trim());
export const resolveHomepageIconUrl = (icon: string | undefined, serviceName: string): string => {
if (!icon || icon.trim().length === 0) {
const fallback = serviceName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "");
return `${DASHBOARD_ICON_CDN}/${fallback || "generic"}.svg`;
}
if (icon.startsWith("http://") || icon.startsWith("https://")) {
return icon;
}
const baseName = icon.replace(/\.(png|webp|svg|jpg|jpeg)$/i, "");
return `${DASHBOARD_ICON_CDN}/${baseName}.svg`;
};
const resolvePingUrl = (service: HomepageService): string | null => {
const raw = service.ping ?? service.siteMonitor;
if (!raw || raw.trim().length === 0) {
return null;
}
if (raw.startsWith("http://") || raw.startsWith("https://")) {
return raw;
}
return `http://${raw}`;
};
const extractSecretValue = (widget: HomepageWidget, homepageField: string): string | undefined => {
const value = widget.fields[homepageField] ?? (homepageField === "key" ? widget.key : undefined);
return typeof value === "string" && value.length > 0 ? value : undefined;
};
const descriptionDisplayModes: Record<string, string> = {
present: "tooltip",
absent: "hidden",
};
type WidgetProcessingContext = {
service: HomepageService;
appId: string;
sectionId: string;
boardId: string;
layoutId: string;
gridState: Map<string, GridPosition>;
integrationRows: InferInsertModel<typeof integrations>[];
integrationSecretRows: InferInsertModel<typeof integrationSecrets>[];
itemRows: InferInsertModel<typeof items>[];
itemLayoutRows: InferInsertModel<typeof itemLayouts>[];
integrationItemRows: InferInsertModel<typeof integrationItems>[];
warnings: string[];
unmappedWidgetTypesSet: Set<string>;
};
const processServiceWidgets = (ctx: WidgetProcessingContext) => {
for (const widget of ctx.service.widgets) {
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
if (!mapping) {
ctx.unmappedWidgetTypesSet.add(widget.type);
continue;
}
const integrationId = createId();
const integrationUrl =
(typeof widget.url === "string" && widget.url.length > 0 ? widget.url : ctx.service.href) ?? "";
if (integrationUrl.length === 0) {
ctx.warnings.push(`Skipped integration for "${ctx.service.name}" (${widget.type}): missing URL`);
continue;
}
ctx.integrationRows.push({
id: integrationId,
kind: mapping.integrationKind,
name: ctx.service.name,
url: integrationUrl,
appId: ctx.appId,
});
processSecrets(ctx, widget, mapping, integrationId);
validateSecrets(ctx, mapping, integrationId);
placeWidgetItem(ctx, mapping.widgetKind, integrationId);
}
};
const processSecrets = (
ctx: WidgetProcessingContext,
widget: HomepageWidget,
mapping: { secretFieldMap: Record<string, IntegrationSecretKind>; integrationKind: string },
integrationId: string,
) => {
for (const [homepageField, secretKind] of Object.entries(mapping.secretFieldMap)) {
const rawValue = extractSecretValue(widget, homepageField);
if (!rawValue) continue;
if (isEnvVarReference(rawValue)) {
ctx.warnings.push(
`Unresolved secret for "${ctx.service.name}" (${widget.type}): ${homepageField} references an environment variable`,
);
continue;
}
ctx.integrationSecretRows.push({
integrationId,
kind: secretKind as IntegrationSecretKind,
value: encryptSecret(rawValue),
});
}
};
const validateSecrets = (
ctx: WidgetProcessingContext,
mapping: { integrationKind: string },
integrationId: string,
) => {
const requiredSecretSets = integrationDefs[mapping.integrationKind as keyof typeof integrationDefs].secretKinds;
const providedKinds = new Set(
ctx.integrationSecretRows.filter((s) => s.integrationId === integrationId).map((s) => s.kind),
);
const hasRequiredSecrets = requiredSecretSets.some(
(secretSet) => secretSet.length === 0 || secretSet.every((kind) => providedKinds.has(kind)),
);
if (!hasRequiredSecrets && requiredSecretSets.every((set) => set.length > 0)) {
ctx.warnings.push(`Integration "${ctx.service.name}" (${mapping.integrationKind}) may be missing required credentials`);
}
};
const placeWidgetItem = (ctx: WidgetProcessingContext, widgetKind: string, integrationId: string) => {
const widgetItemId = createId();
const grid = ctx.gridState.get(ctx.sectionId) ?? { x: 0, y: 0 };
ctx.itemRows.push({
id: widgetItemId,
boardId: ctx.boardId,
kind: widgetKind as never,
options: emptySuperJSON,
advancedOptions: emptySuperJSON,
});
ctx.itemLayoutRows.push({
itemId: widgetItemId,
sectionId: ctx.sectionId,
layoutId: ctx.layoutId,
width: WIDGET_WIDTH,
height: 1,
xOffset: grid.x,
yOffset: grid.y,
});
ctx.gridState.set(ctx.sectionId, advanceGrid(grid, WIDGET_WIDTH));
ctx.integrationItemRows.push({
itemId: widgetItemId,
integrationId,
});
};
export const prepareHomepageImport = (
services: HomepageService[],
boardName: string,
creatorId: string,
createIntegrations: boolean,
): PreparedHomepageImport => {
const warnings: string[] = [];
const unmappedWidgetTypesSet = new Set<string>();
const boardId = createId();
const layoutId = createId();
const board: InferInsertModel<typeof boards> = {
id: boardId,
name: boardName,
creatorId,
isPublic: false,
};
const layoutRows: InferInsertModel<typeof layouts>[] = [
{ id: layoutId, boardId, name: "Base", columnCount: COLUMN_COUNT, breakpoint: 0 },
];
const groupNames = [...new Set(services.map((service) => service.group || "Services"))];
const sectionByGroup = new Map<string, string>();
const sectionRows: InferInsertModel<typeof sections>[] = groupNames.map((groupName, index) => {
const sectionId = createId();
sectionByGroup.set(groupName, sectionId);
return {
id: sectionId,
boardId,
kind: "category" as const,
name: groupName || "Services",
xOffset: 0,
yOffset: index,
};
});
const appRows: InferInsertModel<typeof apps>[] = [];
const integrationRows: InferInsertModel<typeof integrations>[] = [];
const integrationSecretRows: InferInsertModel<typeof integrationSecrets>[] = [];
const itemRows: InferInsertModel<typeof items>[] = [];
const itemLayoutRows: InferInsertModel<typeof itemLayouts>[] = [];
const integrationItemRows: InferInsertModel<typeof integrationItems>[] = [];
const gridState = new Map<string, GridPosition>();
for (const service of services) {
const groupKey = service.group || "Services";
const sectionId = sectionByGroup.get(groupKey);
if (!sectionId) {
warnings.push(`Skipped "${service.name}" because section "${groupKey}" was not found`);
continue;
}
const appId = createId();
const iconUrl = resolveHomepageIconUrl(service.icon, service.name);
const pingUrl = resolvePingUrl(service);
appRows.push({
id: appId,
name: service.name,
href: service.href ?? null,
iconUrl,
description: service.description ?? null,
pingUrl,
});
const grid = gridState.get(sectionId) ?? { x: 0, y: 0 };
const itemId = createId();
itemRows.push({
id: itemId,
boardId,
kind: "app",
options: stringifyForDb({
appId,
openInNewTab: true,
showTitle: true,
layout: "column",
descriptionDisplayMode: descriptionDisplayModes[service.description ? "present" : "absent"],
pingEnabled: pingUrl !== null,
}),
advancedOptions: emptySuperJSON,
});
itemLayoutRows.push({
itemId,
sectionId,
layoutId,
width: 1,
height: 1,
xOffset: grid.x,
yOffset: grid.y,
});
gridState.set(sectionId, advanceGrid(grid, 1));
if (!createIntegrations) {
for (const widget of service.widgets) {
const mapping = homepageWidgetMap[widget.type.toLowerCase()];
if (!mapping) {
unmappedWidgetTypesSet.add(widget.type);
}
}
continue;
}
processServiceWidgets({
service,
appId,
sectionId,
boardId,
layoutId,
gridState,
integrationRows,
integrationSecretRows,
itemRows,
itemLayoutRows,
integrationItemRows,
warnings,
unmappedWidgetTypesSet,
});
}
return {
apps: appRows,
integrations: integrationRows,
integrationSecrets: integrationSecretRows,
board,
layouts: layoutRows,
sections: sectionRows,
items: itemRows,
itemLayouts: itemLayoutRows,
integrationItems: integrationItemRows,
warnings,
unmappedWidgetTypes: [...unmappedWidgetTypesSet].sort(),
};
};
@@ -1,21 +0,0 @@
export interface HomepageService {
group: string;
name: string;
icon?: string;
href?: string;
description?: string;
ping?: string;
siteMonitor?: string;
widgets: HomepageWidget[];
}
export interface HomepageWidget {
type: string;
url?: string;
key?: string;
fields: Record<string, unknown>;
}
export type ParseServicesYamlResult =
| { success: true; services: HomepageService[] }
| { success: false; error: string };
@@ -1,38 +0,0 @@
import { describe, expect, test } from "vitest";
import { homepageWidgetMap, integrationDefs, integrationKinds } from "@homarr/definitions";
describe("homepageWidgetMap", () => {
test("returns mapping for known homepage widget types", () => {
const sonarr = homepageWidgetMap.sonarr;
expect(sonarr).toMatchObject({
integrationKind: "sonarr",
widgetKind: "downloads",
secretFieldMap: { key: "apiKey" },
});
});
test("returns null for unknown widget types", () => {
expect(homepageWidgetMap.unknownservice).toBeUndefined();
expect(homepageWidgetMap.bazarr).toBeNull();
expect(homepageWidgetMap.tautulli).toBeNull();
});
test("only maps integration kinds that exist in integrationDefs", () => {
const invalidKinds: string[] = [];
for (const mapping of Object.values(homepageWidgetMap)) {
if (!mapping) {
continue;
}
if (!integrationKinds.includes(mapping.integrationKind)) {
invalidKinds.push(mapping.integrationKind);
}
expect(integrationDefs[mapping.integrationKind]).toBeDefined();
}
expect(invalidKinds).toEqual([]);
});
});
@@ -1,506 +0,0 @@
import SuperJSON from "superjson";
import { createId } from "@homarr/common";
import { decryptSecretWithKey, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { eq, handleTransactionsAsync } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import {
boardGroupPermissions,
groupMembers,
groupPermissions,
groups,
integrationGroupPermissions,
integrations,
integrationSecrets,
searchEngines,
serverSettings,
users,
} from "@homarr/db/schema";
import type { apps } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { GROUP_REF_FIELDS, USER_DIRECT_FIELDS, USER_REF_FIELDS, refFieldToDbColumn } from "../entity-fields";
import { resolveEntities } from "../resolve-entities";
import type { HomarrBundleBoard, HomarrConfigBundle } from "../schema";
import { buildBoardInsertRow, mustGet, replaceAppRefsInValue, stringifyForDb } from "../utils";
import { loadExistingEntitiesAsync } from "./load-existing-entities";
export type ConfigImportReport = {
boards: number;
apps: number;
integrations: number;
groups: number;
users: number;
searchEngines: number;
warnings: string[];
};
const reEncryptSecret = (encryptedValue: string, sourceKeyHex: string): `${string}.${string}` => {
const sourceKey = Buffer.from(sourceKeyHex, "hex");
const decrypted = decryptSecretWithKey(encryptedValue as `${string}.${string}`, sourceKey);
return encryptSecret(decrypted);
};
const resolveRef = (map: Map<string, string>, ref: string | null | undefined): string | null => {
if (!ref) return null;
return map.get(ref) ?? null;
};
const resolveApps = (
bundle: HomarrConfigBundle,
existingApps: { id: string; name: string; iconUrl: string; href: string | null }[],
) =>
resolveEntities(bundle.apps, existingApps, {
getRef: (app) => app.ref,
match: (bundle, existing) =>
existing.name === bundle.name && existing.iconUrl === bundle.iconUrl && existing.href === bundle.href,
toInsertRow: (app, id): (typeof apps.$inferInsert) => ({
id,
name: app.name,
iconUrl: app.iconUrl,
href: app.href,
description: app.description ?? null,
pingUrl: app.pingUrl ?? null,
}),
});
const resolveIntegrations = (
bundle: HomarrConfigBundle,
existingIntegrations: { id: string; kind: string; name: string; url: string }[],
) => {
const result = resolveEntities(bundle.integrations, existingIntegrations, {
getRef: (i) => i.ref,
match: (bundle, existing) =>
existing.kind === bundle.kind && existing.name === bundle.name && existing.url === bundle.url,
toInsertRow: (integration, id): (typeof integrations.$inferInsert) => ({
id,
kind: integration.kind,
name: integration.name,
url: integration.url,
}),
});
const secretRows: (typeof integrationSecrets.$inferInsert)[] = [];
const secretWarnings: string[] = [];
const sourceKey = bundle.encryptionKey;
for (const bundleIntegration of bundle.integrations) {
const newId = result.refToId.get(bundleIntegration.ref);
if (!newId || existingIntegrations.some((e) => e.id === newId)) continue;
for (const secret of bundleIntegration.secrets) {
try {
const reEncrypted = reEncryptSecret(secret.value, sourceKey);
secretRows.push({
integrationId: newId,
kind: secret.kind as IntegrationSecretKind,
value: reEncrypted,
updatedAt: new Date(),
});
} catch {
secretWarnings.push(`Failed to re-encrypt secret "${secret.kind}" for integration "${bundleIntegration.name}"`);
}
}
}
return {
...result,
secretRows,
warnings: [...result.warnings, ...secretWarnings],
};
};
const resolveBoards = (
bundle: HomarrConfigBundle,
existingBoards: { id: string; name: string }[],
) => {
const boardRefToId = new Map<string, string>();
const boardsToCreate = new Set<string>();
const warnings: string[] = [];
const existingBoardNames = new Set(existingBoards.map((b) => b.name));
for (const bundleBoard of bundle.boards) {
if (existingBoardNames.has(bundleBoard.name)) {
const existing = existingBoards.find((b) => b.name === bundleBoard.name);
if (existing) {
boardRefToId.set(bundleBoard.name, existing.id);
if (bundleBoard.ref) boardRefToId.set(bundleBoard.ref, existing.id);
}
warnings.push(`Board "${bundleBoard.name}" already exists and was skipped`);
continue;
}
const boardId = createId();
boardRefToId.set(bundleBoard.name, boardId);
if (bundleBoard.ref) boardRefToId.set(bundleBoard.ref, boardId);
boardsToCreate.add(bundleBoard.name);
}
return { boardRefToId, boardsToCreate, warnings };
};
const resolveSearchEngines = (
bundle: HomarrConfigBundle,
existingSearchEngines: { id: string; short: string }[],
integrationRefToId: Map<string, string>,
) => {
const result = resolveEntities(bundle.searchEngines, existingSearchEngines, {
getRef: (se) => se.ref,
match: (bundle, existing) => existing.short === bundle.short,
toInsertRow: (se, id): (typeof searchEngines.$inferInsert) => ({
id,
iconUrl: se.iconUrl,
name: se.name,
short: se.short,
description: se.description ?? null,
urlTemplate: se.urlTemplate ?? null,
type: se.type as never,
integrationId: resolveRef(integrationRefToId, se.integrationRef),
}),
skipMessage: (se) => `Search engine "${se.name}" (short: ${se.short}) already exists and was skipped`,
});
for (const existing of existingSearchEngines) {
result.refToId.set(existing.id, existing.id);
}
return result;
};
/**
* Uses USER_DIRECT_FIELDS and USER_REF_FIELDS to build user insert rows.
* Adding a new user preference to USER_DIRECT_FIELDS automatically
* includes it here — no other changes needed.
*/
const resolveUsers = (
bundle: HomarrConfigBundle,
existingUsers: { id: string; email: string | null }[],
refMaps: Record<string, Map<string, string>>,
) =>
resolveEntities(bundle.users ?? [], existingUsers, {
getRef: (u) => u.ref,
match: (bundle, existing) => Boolean(bundle.email) && existing.email === bundle.email,
toInsertRow: (bundleUser, id): (typeof users.$inferInsert) => {
const row: Record<string, unknown> = { id };
for (const field of USER_DIRECT_FIELDS) {
row[field] = (bundleUser as Record<string, unknown>)[field] ?? null;
}
for (const [refField, mapName] of Object.entries(USER_REF_FIELDS)) {
const dbCol = refFieldToDbColumn(refField);
row[dbCol] = resolveRef(refMaps[mapName]!, (bundleUser as Record<string, unknown>)[refField] as string);
}
return row as typeof users.$inferInsert;
},
});
const resolveGroups = (
bundle: HomarrConfigBundle,
existingGroups: { id: string; name: string }[],
existingMembershipKeys: Set<string>,
refMaps: Record<string, Map<string, string>>,
) => {
const result = resolveEntities(bundle.groups, existingGroups, {
getRef: (g) => g.ref,
match: (bundle, existing) => existing.name === bundle.name,
toInsertRow: (bundleGroup, id): (typeof groups.$inferInsert) => {
const row: Record<string, unknown> = { id, name: bundleGroup.name, position: bundleGroup.position };
for (const [refField, mapName] of Object.entries(GROUP_REF_FIELDS)) {
const dbCol = refFieldToDbColumn(refField);
row[dbCol] = resolveRef(refMaps[mapName]!, (bundleGroup as Record<string, unknown>)[refField] as string);
}
return row as typeof groups.$inferInsert;
},
skipMessage: (g) => `Group "${g.name}" already exists and was skipped`,
});
const groupPermRows: (typeof groupPermissions.$inferInsert)[] = [];
const boardGroupPermRows: (typeof boardGroupPermissions.$inferInsert)[] = [];
const integrationGroupPermRows: (typeof integrationGroupPermissions.$inferInsert)[] = [];
const memberRows: (typeof groupMembers.$inferInsert)[] = [];
const extraWarnings: string[] = [];
for (const bundleGroup of bundle.groups) {
const groupId = result.refToId.get(bundleGroup.ref);
if (!groupId || existingGroups.some((g) => g.id === groupId)) continue;
for (const perm of bundleGroup.permissions) {
groupPermRows.push({ groupId, permission: perm as never });
}
}
for (const bundleGroup of bundle.groups) {
const groupId = result.refToId.get(bundleGroup.ref);
if (!groupId) continue;
for (const bp of bundleGroup.boardPermissions) {
const resolvedBoardId = resolveRef(refMaps.boards!, bp.boardRef);
if (!resolvedBoardId) {
extraWarnings.push(`Board permission for group "${bundleGroup.name}" references unknown board, skipped`);
continue;
}
boardGroupPermRows.push({ groupId, boardId: resolvedBoardId, permission: bp.permission as never });
}
for (const ip of bundleGroup.integrationPermissions) {
const resolvedIntegrationId = resolveRef(refMaps.integrations!, ip.integrationRef);
if (resolvedIntegrationId) {
integrationGroupPermRows.push({ groupId, integrationId: resolvedIntegrationId, permission: ip.permission as never });
}
}
for (const memberRef of bundleGroup.memberRefs ?? []) {
const userId = refMaps.users!.get(memberRef);
if (!userId) continue;
const key = `${groupId}:${userId}`;
if (existingMembershipKeys.has(key)) continue;
memberRows.push({ groupId, userId });
}
}
return {
...result,
groupPermRows,
boardGroupPermRows,
integrationGroupPermRows,
memberRows,
warnings: [...result.warnings, ...extraWarnings],
};
};
const resolveServerSettings = (
bundle: HomarrConfigBundle,
existingSettings: { settingKey: string; value: string }[],
) => {
const existingKeys = new Set(existingSettings.map((s) => s.settingKey));
const toInsert: (typeof serverSettings.$inferInsert)[] = [];
const toUpdate: { key: string; value: string }[] = [];
for (const [key, value] of Object.entries(bundle.serverSettings)) {
const serialized = SuperJSON.stringify(value);
if (existingKeys.has(key)) {
toUpdate.push({ key, value: serialized });
} else {
toInsert.push({ settingKey: key, value: serialized });
}
}
return { toInsert, toUpdate };
};
const buildBoardInserts = (
bundle: HomarrConfigBundle,
boardsToCreate: Set<string>,
boardRefToId: Map<string, string>,
appRefToId: Map<string, string>,
integrationRefToId: Map<string, string>,
creatorId: string | null,
insertCollection: Pick<ReturnType<typeof createDbInsertCollectionForTransaction>, "boards" | "layouts" | "sections" | "sectionLayouts" | "items" | "itemLayouts" | "integrationItems">,
) => {
let boardsCreated = 0;
for (const bundleBoard of bundle.boards) {
if (!boardsToCreate.has(bundleBoard.name)) continue;
const boardId = boardRefToId.get(bundleBoard.name);
if (!boardId) continue;
boardsCreated++;
const sourceBoard = bundleBoard as HomarrBundleBoard;
const layoutRefToId = new Map(sourceBoard.layouts.map((l) => [l.ref, createId()]));
const sectionRefToId = new Map(sourceBoard.sections.map((s) => [s.ref, createId()]));
const itemRefToId = new Map(sourceBoard.items.map((i) => [i.ref, createId()]));
insertCollection.boards.push(buildBoardInsertRow(boardId, sourceBoard.name, creatorId, sourceBoard.settings));
for (const layout of sourceBoard.layouts) {
insertCollection.layouts.push({
id: mustGet(layoutRefToId, layout.ref),
boardId,
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
});
}
for (const section of sourceBoard.sections) {
insertCollection.sections.push({
id: mustGet(sectionRefToId, section.ref),
boardId,
kind: section.kind,
name: section.name ?? null,
xOffset: section.xOffset ?? null,
yOffset: section.yOffset ?? null,
options: section.options ? stringifyForDb(section.options) : undefined,
});
for (const layout of section.layouts ?? []) {
insertCollection.sectionLayouts.push({
sectionId: mustGet(sectionRefToId, section.ref),
layoutId: mustGet(layoutRefToId, layout.layoutRef),
parentSectionId: mustGet(sectionRefToId, layout.parentSectionRef),
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
});
}
}
for (const item of sourceBoard.items) {
const optionsWithRefs = replaceAppRefsInValue(item.options, appRefToId) as Record<string, unknown>;
insertCollection.items.push({
id: mustGet(itemRefToId, item.ref),
boardId,
kind: item.kind,
options: stringifyForDb(optionsWithRefs),
advancedOptions: stringifyForDb(item.advancedOptions),
});
for (const layout of item.layouts) {
insertCollection.itemLayouts.push({
itemId: mustGet(itemRefToId, item.ref),
sectionId: mustGet(sectionRefToId, layout.sectionRef),
layoutId: mustGet(layoutRefToId, layout.layoutRef),
xOffset: layout.x,
yOffset: layout.y,
width: layout.w,
height: layout.h,
});
}
for (const integrationRef of item.integrationRefs) {
const integrationId = integrationRefToId.get(integrationRef);
if (integrationId) {
insertCollection.integrationItems.push({
itemId: mustGet(itemRefToId, item.ref),
integrationId,
});
}
}
}
}
return boardsCreated;
};
export const importFullConfigAsync = async (
db: Database,
bundle: HomarrConfigBundle,
creatorId: string | null,
): Promise<ConfigImportReport> => {
const existing = await loadExistingEntitiesAsync(db);
const existingMembershipKeys = new Set(existing.groupMembers.map((gm) => `${gm.groupId}:${gm.userId}`));
const resolvedApps = resolveApps(bundle, existing.apps);
const resolvedIntegrations = resolveIntegrations(bundle, existing.integrations);
const resolvedBoardsResult = resolveBoards(bundle, existing.boards);
const resolvedSearchEngines = resolveSearchEngines(bundle, existing.searchEngines, resolvedIntegrations.refToId);
const refMaps: Record<string, Map<string, string>> = {
boards: resolvedBoardsResult.boardRefToId,
searchEngines: resolvedSearchEngines.refToId,
integrations: resolvedIntegrations.refToId,
users: new Map<string, string>(),
};
const resolvedUsers = resolveUsers(bundle, existing.users, refMaps);
refMaps.users = resolvedUsers.refToId;
const resolvedGroups = resolveGroups(bundle, existing.groups, existingMembershipKeys, refMaps);
const resolvedSettings = resolveServerSettings(bundle, existing.serverSettings);
const insertCollection = createDbInsertCollectionForTransaction([
"apps",
"integrations",
"integrationSecrets",
"boards",
"layouts",
"sections",
"sectionLayouts",
"items",
"itemLayouts",
"integrationItems",
"searchEngines",
"users",
"groups",
"groupPermissions",
"groupMembers",
"boardGroupPermissions",
"integrationGroupPermissions",
]);
insertCollection.apps.push(...resolvedApps.rows);
insertCollection.integrations.push(...resolvedIntegrations.rows);
insertCollection.integrationSecrets.push(...resolvedIntegrations.secretRows);
insertCollection.searchEngines.push(...resolvedSearchEngines.rows);
insertCollection.users.push(...resolvedUsers.rows);
insertCollection.groups.push(...resolvedGroups.rows);
insertCollection.groupPermissions.push(...resolvedGroups.groupPermRows);
insertCollection.groupMembers.push(...resolvedGroups.memberRows);
insertCollection.boardGroupPermissions.push(...resolvedGroups.boardGroupPermRows);
insertCollection.integrationGroupPermissions.push(...resolvedGroups.integrationGroupPermRows);
const boardsCreated = buildBoardInserts(
bundle,
resolvedBoardsResult.boardsToCreate,
resolvedBoardsResult.boardRefToId,
resolvedApps.refToId,
resolvedIntegrations.refToId,
creatorId,
insertCollection,
);
await handleTransactionsAsync(db, {
async handleAsync(innerDb, dbSchema) {
await innerDb.transaction(async (trx) => {
await insertCollection.insertAllAsync(trx);
if (resolvedSettings.toInsert.length > 0) {
await trx.insert(dbSchema.serverSettings).values(resolvedSettings.toInsert as never);
}
for (const setting of resolvedSettings.toUpdate) {
await trx
.update(dbSchema.serverSettings)
.set({ value: setting.value })
.where(eq(dbSchema.serverSettings.settingKey, setting.key));
}
});
},
handleSync(innerDb) {
innerDb.transaction((trx) => {
insertCollection.insertAll(trx);
if (resolvedSettings.toInsert.length > 0) {
trx.insert(serverSettings).values(resolvedSettings.toInsert as never).run();
}
for (const setting of resolvedSettings.toUpdate) {
trx
.update(serverSettings)
.set({ value: setting.value })
.where(eq(serverSettings.settingKey, setting.key))
.run();
}
});
},
});
const warnings = [
...resolvedIntegrations.warnings,
...resolvedBoardsResult.warnings,
...resolvedSearchEngines.warnings,
...resolvedGroups.warnings,
];
return {
boards: boardsCreated,
apps: resolvedApps.rows.length,
integrations: resolvedIntegrations.rows.length,
groups: resolvedGroups.rows.length,
users: resolvedUsers.rows.length,
searchEngines: resolvedSearchEngines.rows.length,
warnings,
};
};
@@ -1,4 +0,0 @@
export { parseBundleJson } from "./parse-bundle";
export { prepareBoardImport, type PreparedBundleImport } from "./prepare-board";
export { importBoardBundleAsync, type BundleImportReport } from "./insert";
export { doAppsMatch } from "./match-apps";
@@ -1,79 +0,0 @@
import { handleTransactionsAsync } from "@homarr/db";
import type { Database } from "@homarr/db";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
import type { HomarrBundle } from "../schema";
import type { PreparedBundleImport } from "./prepare-board";
import { prepareBoardImport } from "./prepare-board";
export type BundleImportReport = {
created: {
apps: number;
items: number;
};
warnings: string[];
boardId: string;
};
export const importBoardBundleAsync = async (
db: Database,
bundle: HomarrBundle,
boardName: string,
creatorId: string,
existingApps: Awaited<ReturnType<typeof db.query.apps.findMany>>,
existingIntegrations: Awaited<ReturnType<typeof db.query.integrations.findMany>>,
integrationIdsWithAccess: string[],
hasAccessForAll: boolean,
): Promise<BundleImportReport> => {
const prepared = prepareBoardImport(
bundle,
boardName,
creatorId,
existingApps,
existingIntegrations,
integrationIdsWithAccess,
hasAccessForAll,
);
await insertPreparedBundleAsync(db, prepared);
return {
created: {
apps: prepared.createdAppsCount,
items: prepared.createdItemsCount,
},
warnings: prepared.warnings,
boardId: prepared.board.id,
};
};
const insertPreparedBundleAsync = async (db: Database, prepared: PreparedBundleImport) => {
const insertCollection = createDbInsertCollectionForTransaction([
"apps",
"boards",
"layouts",
"sections",
"sectionLayouts",
"items",
"itemLayouts",
"integrationItems",
]);
insertCollection.apps.push(...prepared.apps);
insertCollection.boards.push(prepared.board);
insertCollection.layouts.push(...prepared.layouts);
insertCollection.sections.push(...prepared.sections);
insertCollection.sectionLayouts.push(...prepared.sectionLayouts);
insertCollection.items.push(...prepared.items);
insertCollection.itemLayouts.push(...prepared.itemLayouts);
insertCollection.integrationItems.push(...prepared.integrationItems);
await handleTransactionsAsync(db, {
async handleAsync(innerDb) {
await insertCollection.insertAllAsync(innerDb);
},
handleSync(innerDb) {
insertCollection.insertAll(innerDb);
},
});
};
@@ -1,39 +0,0 @@
import type { Database, InferSelectModel } from "@homarr/db";
import type { groupMembers, groups, searchEngines, serverSettings, users } from "@homarr/db/schema";
import { groupMembers as groupMembersTable, groups as groupsTable, searchEngines as searchEnginesTable, serverSettings as serverSettingsTable, users as usersTable } from "@homarr/db/schema";
export type ExistingEntities = {
apps: Awaited<ReturnType<Database["query"]["apps"]["findMany"]>>;
integrations: Awaited<ReturnType<Database["query"]["integrations"]["findMany"]>>;
boards: Awaited<ReturnType<Database["query"]["boards"]["findMany"]>>;
groups: InferSelectModel<typeof groups>[];
searchEngines: InferSelectModel<typeof searchEngines>[];
users: InferSelectModel<typeof users>[];
groupMembers: InferSelectModel<typeof groupMembers>[];
serverSettings: InferSelectModel<typeof serverSettings>[];
};
export const loadExistingEntitiesAsync = async (db: Database): Promise<ExistingEntities> => {
const [appsResult, integrationsResult, boardsResult, groupsResult, searchEnginesResult, usersResult, groupMembersResult, serverSettingsResult] =
await Promise.all([
db.query.apps.findMany(),
db.query.integrations.findMany(),
db.query.boards.findMany(),
db.select().from(groupsTable),
db.select().from(searchEnginesTable),
db.select().from(usersTable),
db.select().from(groupMembersTable),
db.select().from(serverSettingsTable),
]);
return {
apps: appsResult,
integrations: integrationsResult,
boards: boardsResult,
groups: groupsResult,
searchEngines: searchEnginesResult,
users: usersResult,
groupMembers: groupMembersResult,
serverSettings: serverSettingsResult,
};
};
@@ -1,14 +0,0 @@
import type { InferSelectModel } from "@homarr/db";
import type { apps } from "@homarr/db/schema";
export const doAppsMatch = (
app1: Omit<InferSelectModel<typeof apps>, "id">,
app2: Omit<InferSelectModel<typeof apps>, "id">,
) => {
return (
app1.name === app2.name &&
app1.iconUrl === app2.iconUrl &&
app1.description === app2.description &&
app1.href === app2.href
);
};
@@ -1,7 +0,0 @@
import type { HomarrBundle } from "../schema";
import { homarrBundleSchema } from "../schema";
export const parseBundleJson = (content: string): HomarrBundle => {
const parsed: unknown = JSON.parse(content);
return homarrBundleSchema.parse(parsed);
};
@@ -1,175 +0,0 @@
import SuperJSON from "superjson";
import type { Database } from "@homarr/db";
import type { IntegrationKind } from "@homarr/definitions";
import { assessBundleCompatibility } from "../config-bundle-compat";
import type { HomarrConfigBundle } from "../schema";
import type { ConfigEntityCounts, ConfigImportPreview } from "../types";
import { emptyCounts } from "../types";
import { loadExistingEntitiesAsync } from "./load-existing-entities";
import { doAppsMatch } from "./match-apps";
const emptyPreview = (
compatibility: ConfigImportPreview["compatibility"],
meta: ConfigImportPreview["meta"],
warnings: string[] = [],
): ConfigImportPreview => ({
compatibility,
meta,
toCreate: emptyCounts(),
toReuse: { apps: 0, integrations: 0, users: 0 },
toSkip: { boards: 0, groups: 0, searchEngines: 0 },
toUpdate: { serverSettings: 0 },
warnings,
});
const extractMetaFromUnknown = (parsed: unknown): ConfigImportPreview["meta"] => {
const obj = parsed as Record<string, unknown>;
return {
exportedAt: typeof obj.exportedAt === "string" ? obj.exportedAt : "",
homarrVersion: typeof obj.homarrVersion === "string" ? obj.homarrVersion : "",
bundleVersion: typeof obj.version === "string" ? obj.version : "",
};
};
const integrationMatches = (
bundleIntegration: HomarrConfigBundle["integrations"][number],
existing: { kind: IntegrationKind; name: string; url: string },
) =>
bundleIntegration.kind === existing.kind &&
bundleIntegration.name === existing.name &&
bundleIntegration.url === existing.url;
export const previewImportFullConfigAsync = async (
db: Database,
content: string,
currentHomarrVersion: string,
): Promise<ConfigImportPreview> => {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return emptyPreview(
{ status: "invalidStructure", bundleVersion: null, bundleHomarrVersion: null, currentHomarrVersion, issues: ["File is not valid JSON"] },
{ exportedAt: "", homarrVersion: "", bundleVersion: "" },
);
}
const { bundle, compatibility } = assessBundleCompatibility(parsed, currentHomarrVersion);
if (!bundle || compatibility.status !== "compatible") {
return emptyPreview(compatibility, extractMetaFromUnknown(parsed), compatibility.issues);
}
const existing = await loadExistingEntitiesAsync(db);
const existingSettingsByKey = new Map(
existing.serverSettings.map((setting) => [setting.settingKey, setting.value] as const),
);
const existingBoardNames = new Set(existing.boards.map((b) => b.name));
const existingGroupNames = new Set(existing.groups.map((g) => g.name));
const existingSearchEngineShorts = new Set(existing.searchEngines.map((se) => se.short));
const existingUserEmails = new Set(existing.users.filter((u) => u.email).map((u) => u.email!));
const toCreate = emptyCounts();
const warnings: string[] = [];
let reuseApps = 0;
let reuseIntegrations = 0;
let reuseUsers = 0;
let skipBoards = 0;
let skipGroups = 0;
let skipSearchEngines = 0;
let updateServerSettings = 0;
for (const bundleApp of bundle.apps) {
const matched = existing.apps.find((app) =>
doAppsMatch(app, {
name: bundleApp.name,
iconUrl: bundleApp.iconUrl,
description: bundleApp.description ?? null,
href: bundleApp.href,
pingUrl: bundleApp.pingUrl ?? null,
}),
);
if (matched) {
reuseApps += 1;
continue;
}
toCreate.apps += 1;
}
for (const bundleIntegration of bundle.integrations) {
const matched = existing.integrations.find((i) => integrationMatches(bundleIntegration, i));
if (matched) {
reuseIntegrations += 1;
continue;
}
toCreate.integrations += 1;
toCreate.secrets += bundleIntegration.secrets.length;
}
for (const bundleBoard of bundle.boards) {
if (existingBoardNames.has(bundleBoard.name)) {
skipBoards += 1;
warnings.push(`Board "${bundleBoard.name}" already exists and will be skipped`);
continue;
}
toCreate.boards += 1;
toCreate.widgets += bundleBoard.items.length;
toCreate.sections += bundleBoard.sections.length;
toCreate.layouts += bundleBoard.layouts.length;
}
for (const bundleGroup of bundle.groups) {
if (existingGroupNames.has(bundleGroup.name)) {
skipGroups += 1;
warnings.push(`Group "${bundleGroup.name}" already exists and will be skipped`);
continue;
}
toCreate.groups += 1;
}
for (const bundleSearchEngine of bundle.searchEngines) {
if (existingSearchEngineShorts.has(bundleSearchEngine.short)) {
skipSearchEngines += 1;
warnings.push(`Search engine "${bundleSearchEngine.name}" already exists and will be skipped`);
continue;
}
toCreate.searchEngines += 1;
}
for (const bundleUser of bundle.users ?? []) {
if (bundleUser.email && existingUserEmails.has(bundleUser.email)) {
reuseUsers += 1;
continue;
}
toCreate.users += 1;
}
for (const [settingKey, value] of Object.entries(bundle.serverSettings)) {
const serialized = SuperJSON.stringify(value);
const existingValue = existingSettingsByKey.get(settingKey);
if (existingValue === undefined) {
toCreate.serverSettings += 1;
continue;
}
if (existingValue !== serialized) {
updateServerSettings += 1;
}
}
return {
compatibility,
meta: {
exportedAt: bundle.exportedAt,
homarrVersion: bundle.homarrVersion,
bundleVersion: bundle.version,
},
toCreate,
toReuse: { apps: reuseApps, integrations: reuseIntegrations, users: reuseUsers },
toSkip: { boards: skipBoards, groups: skipGroups, searchEngines: skipSearchEngines },
toUpdate: { serverSettings: updateServerSettings },
warnings,
};
};
@@ -1,203 +0,0 @@
import { createId } from "@homarr/common";
import type { InferInsertModel, InferSelectModel } from "@homarr/db";
import type {
apps,
boards,
integrationItems,
integrations,
itemLayouts,
items,
layouts,
sectionLayouts,
sections,
} from "@homarr/db/schema";
import type { HomarrBundle, HomarrBundleBoard } from "../schema";
import { buildBoardInsertRow, mustGet, replaceAppRefsInValue, stringifyForDb } from "../utils";
import { doAppsMatch } from "./match-apps";
export type PreparedBundleImport = {
apps: InferInsertModel<typeof apps>[];
board: InferInsertModel<typeof boards>;
layouts: InferInsertModel<typeof layouts>[];
sections: InferInsertModel<typeof sections>[];
sectionLayouts: InferInsertModel<typeof sectionLayouts>[];
items: InferInsertModel<typeof items>[];
itemLayouts: InferInsertModel<typeof itemLayouts>[];
integrationItems: InferInsertModel<typeof integrationItems>[];
warnings: string[];
createdAppsCount: number;
createdItemsCount: number;
};
const integrationMatches = (
bundleIntegration: HomarrBundle["integrations"][number],
existing: InferSelectModel<typeof integrations>,
) => {
return (
bundleIntegration.kind === existing.kind &&
bundleIntegration.name === existing.name &&
bundleIntegration.url === existing.url
);
};
export const prepareBoardImport = (
bundle: HomarrBundle,
boardName: string,
creatorId: string,
existingApps: InferSelectModel<typeof apps>[],
existingIntegrations: InferSelectModel<typeof integrations>[],
integrationIdsWithAccess: string[],
hasAccessForAll: boolean,
): PreparedBundleImport => {
const warnings: string[] = [];
if (bundle.boards.length > 1) {
warnings.push(`Bundle contains ${bundle.boards.length} boards; only the first board was imported`);
}
const sourceBoard = bundle.boards[0] as HomarrBundleBoard | undefined;
if (!sourceBoard) {
throw new Error("Bundle does not contain a board");
}
const boardId = createId();
const layoutRefToId = new Map(sourceBoard.layouts.map((layout) => [layout.ref, createId()]));
const sectionRefToId = new Map(sourceBoard.sections.map((section) => [section.ref, createId()]));
const itemRefToId = new Map(sourceBoard.items.map((item) => [item.ref, createId()]));
const appRefToId = new Map<string, string>();
const appsToInsert: InferInsertModel<typeof apps>[] = [];
let createdAppsCount = 0;
for (const bundleApp of bundle.apps) {
const existingApp = existingApps.find((existing) =>
doAppsMatch(existing, {
name: bundleApp.name,
iconUrl: bundleApp.iconUrl,
description: bundleApp.description ?? null,
href: bundleApp.href,
pingUrl: bundleApp.pingUrl ?? null,
}),
);
if (existingApp) {
appRefToId.set(bundleApp.ref, existingApp.id);
continue;
}
const newAppId = createId();
appRefToId.set(bundleApp.ref, newAppId);
appsToInsert.push({
id: newAppId,
name: bundleApp.name,
iconUrl: bundleApp.iconUrl,
href: bundleApp.href,
description: bundleApp.description ?? null,
pingUrl: bundleApp.pingUrl ?? null,
});
createdAppsCount += 1;
}
const integrationRefToId = new Map<string, string>();
for (const bundleIntegration of bundle.integrations) {
const existingIntegration = existingIntegrations.find((existing) =>
integrationMatches(bundleIntegration, existing),
);
if (!existingIntegration) {
warnings.push(
`Integration "${bundleIntegration.name}" (${bundleIntegration.kind}) was not found and linked widgets were skipped`,
);
continue;
}
const hasAccess = hasAccessForAll || integrationIdsWithAccess.includes(existingIntegration.id);
if (!hasAccess) {
warnings.push(`Integration "${bundleIntegration.name}" is not accessible and was skipped`);
continue;
}
integrationRefToId.set(bundleIntegration.ref, existingIntegration.id);
}
const board: PreparedBundleImport["board"] = buildBoardInsertRow(boardId, boardName, creatorId, sourceBoard.settings);
const layouts = sourceBoard.layouts.map((layout) => ({
id: mustGet(layoutRefToId, layout.ref),
boardId,
name: layout.name,
columnCount: layout.columnCount,
breakpoint: layout.breakpoint,
}));
const sections = sourceBoard.sections.map((section) => ({
id: mustGet(sectionRefToId, section.ref),
boardId,
kind: section.kind,
name: section.name ?? null,
xOffset: section.xOffset ?? null,
yOffset: section.yOffset ?? null,
options: section.options ? stringifyForDb(section.options) : undefined,
}));
const sectionLayouts = sourceBoard.sections.flatMap((section) =>
(section.layouts ?? []).map((layout) => ({
sectionId: mustGet(sectionRefToId, section.ref),
layoutId: mustGet(layoutRefToId, layout.layoutRef),
parentSectionId: mustGet(sectionRefToId, layout.parentSectionRef),
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
})),
);
const items = sourceBoard.items.map((item) => {
const optionsWithRefs = replaceAppRefsInValue(item.options, appRefToId) as Record<string, unknown>;
return {
id: mustGet(itemRefToId, item.ref),
boardId,
kind: item.kind,
options: stringifyForDb(optionsWithRefs),
advancedOptions: stringifyForDb(item.advancedOptions),
};
});
const itemLayouts = sourceBoard.items.flatMap((item) =>
item.layouts.map((layout) => ({
itemId: mustGet(itemRefToId, item.ref),
sectionId: mustGet(sectionRefToId, layout.sectionRef),
layoutId: mustGet(layoutRefToId, layout.layoutRef),
xOffset: layout.x,
yOffset: layout.y,
width: layout.w,
height: layout.h,
})),
);
const integrationItems = sourceBoard.items.flatMap((item) =>
item.integrationRefs
.map((integrationRef) => integrationRefToId.get(integrationRef))
.filter((integrationId): integrationId is string => integrationId !== undefined)
.map((integrationId) => ({
itemId: mustGet(itemRefToId, item.ref),
integrationId,
})),
);
return {
apps: appsToInsert,
board,
layouts,
sections,
sectionLayouts,
items,
itemLayouts,
integrationItems,
warnings,
createdAppsCount,
createdItemsCount: items.length,
};
};
@@ -1,54 +0,0 @@
import { createId } from "@homarr/common";
export type EntityResolverDef<TBundle, TExisting extends { id: string }, TInsert> = {
getRef: (bundle: TBundle) => string;
match: (bundle: TBundle, existing: TExisting) => boolean;
toInsertRow: (bundle: TBundle, newId: string) => TInsert;
skipMessage?: (bundle: TBundle) => string;
};
export type ResolveResult<TInsert> = {
refToId: Map<string, string>;
rows: TInsert[];
warnings: string[];
};
export const resolveEntities = <TBundle, TExisting extends { id: string }, TInsert>(
bundleEntities: TBundle[],
existingEntities: TExisting[],
def: EntityResolverDef<TBundle, TExisting, TInsert>,
): ResolveResult<TInsert> => {
const refToId = new Map<string, string>();
const rows: TInsert[] = [];
const warnings: string[] = [];
for (const bundleEntity of bundleEntities) {
const existing = existingEntities.find((e) => def.match(bundleEntity, e));
if (existing) {
refToId.set(def.getRef(bundleEntity), existing.id);
if (def.skipMessage) warnings.push(def.skipMessage(bundleEntity));
continue;
}
const newId = createId();
refToId.set(def.getRef(bundleEntity), newId);
rows.push(def.toInsertRow(bundleEntity, newId));
}
return { refToId, rows, warnings };
};
export const groupByKey = <T, K extends string | number>(
items: T[],
getKey: (item: T) => K,
): Map<K, T[]> => {
const map = new Map<K, T[]>();
for (const item of items) {
const key = getKey(item);
const list = map.get(key) ?? [];
list.push(item);
map.set(key, list);
}
return map;
};
-199
View File
@@ -1,199 +0,0 @@
import { z } from "zod/v4";
import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
integrationKinds,
sectionKinds,
widgetKinds,
} from "@homarr/definitions";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import { zodEnumFromArray } from "@homarr/validation/enums";
const bundleVersionSchema = z.literal("1.0");
const bundleBoardSettingsSchema = z.object({
pageTitle: z.string().nullable().optional(),
metaTitle: z.string().nullable().optional(),
logoImageUrl: z.string().nullable().optional(),
faviconImageUrl: z.string().nullable().optional(),
backgroundImageUrl: z.string().nullable().optional(),
backgroundImageAttachment: z.enum(backgroundImageAttachments.values).optional(),
backgroundImageRepeat: z.enum(backgroundImageRepeats.values).optional(),
backgroundImageSize: z.enum(backgroundImageSizes.values).optional(),
primaryColor: z.string(),
secondaryColor: z.string(),
opacity: z.number(),
customCss: z.string().nullable().optional(),
iconColor: z.string().nullable().optional(),
itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]),
disableStatus: z.boolean().optional(),
isPublic: z.boolean().optional(),
});
const bundleLayoutSchema = z.object({
ref: z.string(),
name: z.string(),
columnCount: z.number(),
breakpoint: z.number(),
});
const bundleSectionLayoutSchema = z.object({
layoutRef: z.string(),
parentSectionRef: z.string(),
xOffset: z.number(),
yOffset: z.number(),
width: z.number(),
height: z.number(),
});
const bundleSectionSchema = z.object({
ref: z.string(),
kind: zodEnumFromArray(sectionKinds),
name: z.string().nullable().optional(),
yOffset: z.number().nullable().optional(),
xOffset: z.number().nullable().optional(),
options: z.record(z.string(), z.unknown()).optional(),
layouts: z.array(bundleSectionLayoutSchema).optional(),
});
const bundleItemLayoutSchema = z.object({
layoutRef: z.string(),
sectionRef: z.string(),
x: z.number(),
y: z.number(),
w: z.number(),
h: z.number(),
});
const bundleItemSchema = z.object({
ref: z.string(),
kind: zodEnumFromArray(widgetKinds),
options: z.record(z.string(), z.unknown()),
advancedOptions: itemAdvancedOptionsSchema,
integrationRefs: z.array(z.string()),
appRef: z.string().optional(),
layouts: z.array(bundleItemLayoutSchema),
});
const bundleBoardSchema = z.object({
ref: z.string().optional(),
name: z.string(),
settings: bundleBoardSettingsSchema,
layouts: z.array(bundleLayoutSchema),
sections: z.array(bundleSectionSchema),
items: z.array(bundleItemSchema),
});
const bundleAppSchema = z.object({
ref: z.string(),
name: z.string(),
href: z.string().nullable(),
iconUrl: z.string(),
description: z.string().nullable().optional(),
pingUrl: z.string().nullable().optional(),
});
const bundleIntegrationSchema = z.object({
ref: z.string(),
kind: zodEnumFromArray(integrationKinds),
name: z.string(),
url: z.string(),
secretKinds: z.array(z.string()),
secrets: z.literal("REDACTED"),
});
export const homarrBundleSchema = z.object({
version: bundleVersionSchema,
exportedAt: z.string(),
homarrVersion: z.string(),
boards: z.array(bundleBoardSchema).min(1),
apps: z.array(bundleAppSchema),
integrations: z.array(bundleIntegrationSchema),
});
export type HomarrBundle = z.infer<typeof homarrBundleSchema>;
export type HomarrBundleBoard = z.infer<typeof bundleBoardSchema>;
export type HomarrBundleApp = z.infer<typeof bundleAppSchema>;
export type HomarrBundleIntegration = z.infer<typeof bundleIntegrationSchema>;
const configBundleSecretSchema = z.object({
kind: z.string(),
value: z.string(),
});
const configBundleIntegrationSchema = z.object({
ref: z.string(),
kind: zodEnumFromArray(integrationKinds),
name: z.string(),
url: z.string(),
secrets: z.array(configBundleSecretSchema),
});
const configBundleSearchEngineSchema = z.object({
ref: z.string(),
iconUrl: z.string(),
name: z.string(),
short: z.string(),
description: z.string().nullable().optional(),
urlTemplate: z.string().nullable().optional(),
type: z.string(),
integrationRef: z.string().nullable().optional(),
});
const configBundleUserSchema = z
.object({
ref: z.string(),
name: z.string().nullable(),
email: z.string().nullable(),
password: z.string().nullable(),
provider: z.string(),
homeBoardRef: z.string().nullable(),
mobileHomeBoardRef: z.string().nullable(),
defaultSearchEngineRef: z.string().nullable(),
colorScheme: z.string(),
firstDayOfWeek: z.number(),
openSearchInNewTab: z.boolean(),
ddgBangs: z.boolean(),
pingIconsEnabled: z.boolean(),
})
.catchall(z.unknown());
const configBundleGroupSchema = z.object({
ref: z.string(),
name: z.string(),
position: z.number(),
ownerRef: z.string().nullable().optional(),
homeBoardRef: z.string().nullable().optional(),
mobileHomeBoardRef: z.string().nullable().optional(),
permissions: z.array(z.string()),
boardPermissions: z.array(
z.object({ boardRef: z.string(), permission: z.string() }),
),
integrationPermissions: z.array(
z.object({ integrationRef: z.string(), permission: z.string() }),
),
memberRefs: z.array(z.string()).optional(),
});
export const homarrConfigBundleSchema = z.object({
version: z.literal("2.0"),
type: z.literal("full-config"),
exportedAt: z.string(),
homarrVersion: z.string(),
encryptionKey: z.string(),
boards: z.array(bundleBoardSchema),
apps: z.array(bundleAppSchema),
integrations: z.array(configBundleIntegrationSchema),
serverSettings: z.record(z.string(), z.unknown()),
searchEngines: z.array(configBundleSearchEngineSchema),
groups: z.array(configBundleGroupSchema),
users: z.array(configBundleUserSchema).optional(),
});
export type HomarrConfigBundle = z.infer<typeof homarrConfigBundleSchema>;
export type ConfigBundleIntegration = z.infer<typeof configBundleIntegrationSchema>;
export type ConfigBundleGroup = z.infer<typeof configBundleGroupSchema>;
export type ConfigBundleSearchEngine = z.infer<typeof configBundleSearchEngineSchema>;
export type ConfigBundleUser = z.infer<typeof configBundleUserSchema>;
-48
View File
@@ -1,48 +0,0 @@
import type { BundleCompatibility } from "./config-bundle-compat";
export const CONFIG_ENTITY_KEYS = [
"boards",
"apps",
"integrations",
"secrets",
"widgets",
"sections",
"layouts",
"searchEngines",
"groups",
"users",
"serverSettings",
] as const;
export type ConfigEntityKey = (typeof CONFIG_ENTITY_KEYS)[number];
export type ConfigEntityCounts = Record<ConfigEntityKey, number>;
export const emptyCounts = (): ConfigEntityCounts =>
Object.fromEntries(CONFIG_ENTITY_KEYS.map((key) => [key, 0])) as ConfigEntityCounts;
export type ConfigExportPreview = ConfigEntityCounts;
export type ConfigImportPreview = {
compatibility: BundleCompatibility;
meta: {
exportedAt: string;
homarrVersion: string;
bundleVersion: string;
};
toCreate: ConfigEntityCounts;
toReuse: {
apps: number;
integrations: number;
users: number;
};
toSkip: {
boards: number;
groups: number;
searchEngines: number;
};
toUpdate: {
serverSettings: number;
};
warnings: string[];
};
-74
View File
@@ -1,74 +0,0 @@
import SuperJSON from "superjson";
import type { InferInsertModel } from "@homarr/db";
import type { boards } from "@homarr/db/schema";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes, emptySuperJSON } from "@homarr/definitions";
import type { HomarrBundleBoard } from "./schema";
export const parseStoredValue = <T>(value: string | null | undefined, fallback: T): T => {
if (!value || value === emptySuperJSON) {
return fallback;
}
return SuperJSON.parse(value) as T;
};
export const stringifyForDb = (value: unknown): string => {
return SuperJSON.stringify(value);
};
export const mustGet = <T>(map: Map<string, T>, key: string): T => {
const value = map.get(key);
if (value === undefined) {
throw new Error(`Missing map entry for key "${key}"`);
}
return value;
};
const replaceMapValues = (value: unknown, map: Map<string, string>): unknown => {
if (typeof value === "string") {
return map.get(value) ?? value;
}
if (Array.isArray(value)) {
return value.map((entry) => replaceMapValues(entry, map));
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, replaceMapValues(entry, map)]),
);
}
return value;
};
export const replaceAppIdsInValue = replaceMapValues;
export const replaceAppRefsInValue = replaceMapValues;
export const buildBoardInsertRow = (
boardId: string,
name: string,
creatorId: string | null,
settings: HomarrBundleBoard["settings"],
): InferInsertModel<typeof boards> => ({
id: boardId,
name,
creatorId,
isPublic: settings.isPublic ?? false,
pageTitle: settings.pageTitle ?? null,
metaTitle: settings.metaTitle ?? null,
logoImageUrl: settings.logoImageUrl ?? null,
faviconImageUrl: settings.faviconImageUrl ?? null,
backgroundImageUrl: settings.backgroundImageUrl ?? null,
backgroundImageAttachment: settings.backgroundImageAttachment ?? backgroundImageAttachments.defaultValue,
backgroundImageRepeat: settings.backgroundImageRepeat ?? backgroundImageRepeats.defaultValue,
backgroundImageSize: settings.backgroundImageSize ?? backgroundImageSizes.defaultValue,
primaryColor: settings.primaryColor,
secondaryColor: settings.secondaryColor,
opacity: settings.opacity,
customCss: settings.customCss ?? null,
iconColor: settings.iconColor ?? null,
itemRadius: settings.itemRadius,
disableStatus: settings.disableStatus ?? false,
});
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
-1
View File
@@ -26,7 +26,6 @@
"dependencies": {
"@drizzle-team/brocli": "catalog:",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/board-portability": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
@@ -1,40 +0,0 @@
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { command, string } from "@drizzle-team/brocli";
import { exportBoardBundleAsync, bundleFilenameForBoard } from "@homarr/board-portability";
import { db } from "@homarr/db";
import { boards } from "@homarr/db/schema";
import { eq } from "@homarr/db";
export const boardsExport = command({
name: "export",
desc: "Export a board as a portable JSON bundle",
options: {
name: string("name").desc("Name of the board to export").required(),
output: string("output").alias("o").desc("Output file path (defaults to homarr-board-<name>.json)"),
},
// eslint-disable-next-line no-restricted-syntax
handler: async (opts) => {
const board = await db.query.boards.findFirst({
where: eq(boards.name, opts.name),
});
if (!board) {
console.error(`Board "${opts.name}" not found`);
process.exit(1);
}
const result = await exportBoardBundleAsync(db, board.id, "cli");
if (!result) {
console.error(`Failed to export board "${opts.name}"`);
process.exit(1);
}
const outputPath = resolve(opts.output ?? bundleFilenameForBoard(board.name));
writeFileSync(outputPath, result.content, "utf-8");
console.log(`Board exported to ${outputPath}`);
},
});
-21
View File
@@ -1,21 +0,0 @@
import { command } from "@drizzle-team/brocli";
import { db } from "@homarr/db";
export const boardsList = command({
name: "list",
desc: "List all boards (id, name, public)",
// eslint-disable-next-line no-restricted-syntax
handler: async () => {
const all = await db.query.boards.findMany();
if (all.length === 0) {
console.log("No boards found");
return;
}
console.log("ID\t\t\t\tName\t\tPublic");
for (const board of all) {
console.log(`${board.id}\t${board.name}\t\t${board.isPublic ? "yes" : "no"}`);
}
},
});
-10
View File
@@ -1,10 +0,0 @@
import { command } from "@drizzle-team/brocli";
import { boardsExport } from "./boards-export";
import { boardsList } from "./boards-list";
export const boardsRoot = command({
name: "boards",
desc: "Group of commands to manage boards",
subcommands: [boardsList, boardsExport],
});
+1 -2
View File
@@ -1,13 +1,12 @@
import { run } from "@drizzle-team/brocli";
import { boardsRoot } from "./commands/boards";
import { fixUsernames } from "./commands/fix-usernames";
import { integrationsRoot } from "./commands/integrations";
import { recreateAdmin } from "./commands/recreate-admin";
import { resetPassword } from "./commands/reset-password";
import { usersRoot } from "./commands/users";
const commands = [usersRoot, integrationsRoot, boardsRoot, resetPassword, fixUsernames, recreateAdmin];
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
void run(commands, {
name: "homarr-cli",
-1
View File
@@ -30,7 +30,6 @@
"@homarr/cron-jobs-core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/ping": "workspace:^0.1.0",
@@ -1,17 +0,0 @@
CREATE TABLE `docker_app_source` (
`host` varchar(512) NOT NULL,
`container_id` varchar(128) NOT NULL,
`external_id` varchar(512) NOT NULL,
`app_id` varchar(64) NOT NULL,
`board_id` varchar(64) NOT NULL,
`item_id` varchar(64),
`integration_id` varchar(64),
`updated_at` timestamp NOT NULL,
CONSTRAINT `docker_app_source_host_container_id_pk` PRIMARY KEY(`host`,`container_id`)
);
--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_app_id_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `docker_app_source` ADD CONSTRAINT `docker_app_source_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `docker_app_source__external_id_idx` ON `docker_app_source` (`host`,`external_id`);
File diff suppressed because it is too large. Load diff
@@ -281,13 +281,6 @@
"when": 1779199104477,
"tag": "0039_add_user_onboarding_tour_status",
"breakpoints": true
},
{
"idx": 40,
"version": "5",
"when": 1780490691902,
"tag": "0040_add_docker_app_source",
"breakpoints": true
}
]
}
}
@@ -1,17 +0,0 @@
CREATE TABLE "docker_app_source" (
"host" varchar(512) NOT NULL,
"container_id" varchar(128) NOT NULL,
"external_id" varchar(512) NOT NULL,
"app_id" varchar(64) NOT NULL,
"board_id" varchar(64) NOT NULL,
"item_id" varchar(64),
"integration_id" varchar(64),
"updated_at" timestamp NOT NULL,
CONSTRAINT "docker_app_source_host_container_id_pk" PRIMARY KEY("host","container_id")
);
--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_app_id_app_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."app"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_board_id_board_id_fk" FOREIGN KEY ("board_id") REFERENCES "public"."board"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_item_id_item_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."item"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "docker_app_source" ADD CONSTRAINT "docker_app_source_integration_id_integration_id_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integration"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "docker_app_source__external_id_idx" ON "docker_app_source" USING btree ("host","external_id");
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more