diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index bcce78519..612972f43 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -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, diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 6f37d0ef9..1ee201f75 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -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:", diff --git a/apps/nextjs/public/sql-wasm.wasm b/apps/nextjs/public/sql-wasm.wasm new file mode 100755 index 000000000..b32b66473 Binary files /dev/null and b/apps/nextjs/public/sql-wasm.wasm differ diff --git a/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx b/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx index d63c95331..04a7fc159 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/start/init-start.tsx @@ -2,13 +2,16 @@ import { Stack, Text } from "@mantine/core"; 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 ( @@ -27,6 +30,7 @@ export const InitStart = async () => { > {tStart("action.importOldmarr")} + {isSqlite && } ); diff --git a/apps/nextjs/src/app/[locale]/init/_steps/start/sqlite-restore.tsx b/apps/nextjs/src/app/[locale]/init/_steps/start/sqlite-restore.tsx new file mode 100644 index 000000000..75bc39f5f --- /dev/null +++ b/apps/nextjs/src/app/[locale]/init/_steps/start/sqlite-restore.tsx @@ -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 ( + + + + {t("restoreSqliteDescription")} + + + + ); + } + + return ( + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 368d0b2f7..3253670ff 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -10,6 +10,7 @@ import { IconBrandTablerFilled, IconCertificate, IconClipboardListFilled, + IconDatabaseExport, IconDirectionsFilled, IconGitFork, IconHelpSquareRoundedFilled, @@ -28,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"; @@ -154,6 +156,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) { href: "/manage/tools/tasks", hidden: !session?.user.permissions.includes("admin"), }, + { + label: t("items.tools.items.backup"), + icon: IconDatabaseExport, + href: "/manage/tools/backup", + hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3", + }, ], }, { diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx new file mode 100644 index 000000000..cdbe2055e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-export-card.tsx @@ -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(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 ( + + + + + {t("title")} + + + {t("description")} + + {error && ( + }> + {error} + + )} + + {showConfirm ? ( + } + cancelLabel={t("confirm.cancel")} + onConfirm={handleExport} + onCancel={() => setShowConfirm(false)} + disabled={loading} + color="orange" + /> + ) : ( + + + + )} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-import-card.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-import-card.tsx new file mode 100644 index 000000000..794801976 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/_components/backup-import-card.tsx @@ -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 ( + + + + + {t("title")} + + + {t("description")} + + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx new file mode 100644 index 000000000..80a794b57 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx @@ -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 ( + + {t("title")} + + + + ); +} diff --git a/apps/nextjs/src/app/api/backup/export/route.ts b/apps/nextjs/src/app/api/backup/export/route.ts new file mode 100644 index 000000000..cf612c337 --- /dev/null +++ b/apps/nextjs/src/app/api/backup/export/route.ts @@ -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 | 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); + } + } +} diff --git a/apps/nextjs/src/app/api/backup/import/route.ts b/apps/nextjs/src/app/api/backup/import/route.ts new file mode 100644 index 000000000..32002014a --- /dev/null +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -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, 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 => { + 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 | 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 + } + } + } +} diff --git a/apps/nextjs/src/app/api/backup/migrations/route.ts b/apps/nextjs/src/app/api/backup/migrations/route.ts new file mode 100644 index 000000000..13724560d --- /dev/null +++ b/apps/nextjs/src/app/api/backup/migrations/route.ts @@ -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 }); + } +} diff --git a/apps/nextjs/src/app/api/backup/shared.ts b/apps/nextjs/src/app/api/backup/shared.ts new file mode 100644 index 000000000..58978a596 --- /dev/null +++ b/apps/nextjs/src/app/api/backup/shared.ts @@ -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)); diff --git a/apps/nextjs/src/app/api/backup/sql-wasm/route.ts b/apps/nextjs/src/app/api/backup/sql-wasm/route.ts new file mode 100644 index 000000000..de5917ecb --- /dev/null +++ b/apps/nextjs/src/app/api/backup/sql-wasm/route.ts @@ -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", + }, + }); +} diff --git a/apps/nextjs/src/app/api/backup/test/backup.spec.ts b/apps/nextjs/src/app/api/backup/test/backup.spec.ts new file mode 100644 index 000000000..8e4e6039d --- /dev/null +++ b/apps/nextjs/src/app/api/backup/test/backup.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/nextjs/src/components/backup/backup-preview-panel.tsx b/apps/nextjs/src/components/backup/backup-preview-panel.tsx new file mode 100644 index 000000000..98a5effc5 --- /dev/null +++ b/apps/nextjs/src/components/backup/backup-preview-panel.tsx @@ -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 ( + + + + + + + +
+ {t("title")} + + {t("exported", { date: exportDate })} + +
+ + Homarr {analysis.metadata.homarrVersion} + +
+ + + + + {t("entity")} + {t("count")} + + + + {PREVIEW_TABLE_KEYS.map((key) => ( + + {t(`entities.${key}` as never)} + + + {(analysis.counts[key] ?? 0) > 0 && ( + + )} + {analysis.counts[key] ?? 0} + + + + ))} + +
+ + {analysis.boardNames.length > 0 && ( + + {t("boards")}: + {analysis.boardNames.map((name) => ( + {name} + ))} + + )} +
+
+ + {hasPendingMigrations && ( + + + + + +
+ + {pendingCount === 1 + ? t("migrationsApplied", { count: String(pendingCount) }) + : t("migrationsAppliedPlural", { count: String(pendingCount) })} + + + {pendingCount === 1 + ? t("migrationsOlderVersion", { count: String(pendingCount) }) + : t("migrationsOlderVersionPlural", { count: String(pendingCount) })} + +
+ + {t("migrationsPending", { count: String(pendingCount) })} + +
+
+ )} + + {!hasPendingMigrations && ( + + + + + + + {t("migrationsUpToDate", { count: String(analysis.migrations.total) })} + + + + )} +
+ ); +}; diff --git a/apps/nextjs/src/components/backup/dangerous-action-confirmation.tsx b/apps/nextjs/src/components/backup/dangerous-action-confirmation.tsx new file mode 100644 index 000000000..3ebc71ce3 --- /dev/null +++ b/apps/nextjs/src/components/backup/dangerous-action-confirmation.tsx @@ -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 ( + + + + + + {title} + + + + }> + + {warningTitle} + {warningBody} + + + + + + {typePrompt} + + 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, + }, + }} + /> + + + + + + + + + ); +}; diff --git a/apps/nextjs/src/components/backup/database-restore-flow.tsx b/apps/nextjs/src/components/backup/database-restore-flow.tsx new file mode 100644 index 000000000..502b5d9f1 --- /dev/null +++ b/apps/nextjs/src/components/backup/database-restore-flow.tsx @@ -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(null); + const [step, setStep] = useState("upload"); + const [importError, setImportError] = useState(null); + const apiDoneRef = useRef(false); + const animDoneRef = useRef(false); + const apiErrorRef = useRef(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 ( + + + + + + + + + + + +
+ + {t("dropzone.title")} + + + {t("dropzone.description")} + +
+
+
+ ); + } + + if (step === "preview") { + return ( + + + + +
+ {file?.name} + {file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""} +
+
+ +
+ + {loading && migrationProgress && ( + + )} + + {loading && !migrationProgress && ( + + + {t("analyzing")} + + )} + + {analysisError && ( + } title={t("analysisError")}> + {analysisError} + + )} + + {analysis && ( + <> + + + + + + )} +
+ ); + } + + if (step === "confirm") { + return ( + + {analysis && } + setStep("preview")} + /> + + ); + } + + if (step === "restoring") { + return ( + + ); + } + + if (step === "error") { + return ( + + } title={t("failed.title")} radius="md"> + {importError} + + + + + + ); + } + + return null; +}; diff --git a/apps/nextjs/src/components/backup/index.ts b/apps/nextjs/src/components/backup/index.ts new file mode 100644 index 000000000..accd1a935 --- /dev/null +++ b/apps/nextjs/src/components/backup/index.ts @@ -0,0 +1,3 @@ +export { DatabaseRestoreFlow } from "./database-restore-flow"; +export { DangerousActionConfirmation } from "./dangerous-action-confirmation"; +export type { BackupAnalysis, RestoreStep } from "./types"; diff --git a/apps/nextjs/src/components/backup/migration-progress-panel.tsx b/apps/nextjs/src/components/backup/migration-progress-panel.tsx new file mode 100644 index 000000000..8b0cd7bfc --- /dev/null +++ b/apps/nextjs/src/components/backup/migration-progress-panel.tsx @@ -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 ( + + + + + + + + {t("title", { current: String(progress.current), total: String(progress.total) })} + + + + + + {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 ( + + ) : isActive ? ( + + ) : ( + + ) + } + title={ + + {i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })} + + } + /> + ); + })} + + + + + ); +}; diff --git a/apps/nextjs/src/components/backup/restore-confirmation.tsx b/apps/nextjs/src/components/backup/restore-confirmation.tsx new file mode 100644 index 000000000..a5feff6c8 --- /dev/null +++ b/apps/nextjs/src/components/backup/restore-confirmation.tsx @@ -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 ( + } + cancelLabel={t("cancel")} + onConfirm={onConfirm} + onCancel={onCancel} + disabled={disabled} + /> + ); +}; diff --git a/apps/nextjs/src/components/backup/restore-progress-panel.tsx b/apps/nextjs/src/components/backup/restore-progress-panel.tsx new file mode 100644 index 000000000..4faa397be --- /dev/null +++ b/apps/nextjs/src/components/backup/restore-progress-panel.tsx @@ -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 = { + 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 ( + + + + + + {t("title")} + + + + + + {steps.map((step, i) => { + const isDone = i < currentStepIdx; + const isActive = i === currentStepIdx; + const isMigration = step.key.startsWith("migration-"); + + return ( + + + + ) : isActive ? ( + + + + ) : ( + + + + ) + } + > + + + + {step.label} + + + + ); + })} + + + + + ); +}; diff --git a/apps/nextjs/src/components/backup/types.ts b/apps/nextjs/src/components/backup/types.ts new file mode 100644 index 000000000..c197c6544 --- /dev/null +++ b/apps/nextjs/src/components/backup/types.ts @@ -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; + 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; diff --git a/apps/nextjs/src/components/backup/use-backup-analysis.ts b/apps/nextjs/src/components/backup/use-backup-analysis.ts new file mode 100644 index 000000000..a563e24cd --- /dev/null +++ b/apps/nextjs/src/components/backup/use-backup-analysis.ts @@ -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 => { + 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 => { + const counts: Record = {}; + 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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [migrationProgress, setMigrationProgress] = useState(null); + const dbRef = useRef(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 }; +}; diff --git a/apps/nextjs/src/react-app-env.d.ts b/apps/nextjs/src/react-app-env.d.ts index 0357e4f19..ca9b2ac8c 100644 --- a/apps/nextjs/src/react-app-env.d.ts +++ b/apps/nextjs/src/react-app-env.d.ts @@ -2,3 +2,4 @@ declare module "*.png"; declare module "*.svg"; declare module "*.jpeg"; declare module "*.jpg"; +declare module "sql.js"; diff --git a/nginx.conf b/nginx.conf index 4b82c8ca5..c1c082777 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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; } } } diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 2a2de758b..88f95e6b5 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -7,7 +7,10 @@ "description": "To get started, please select how you want to set up your Homarr instance.", "action": { "scratch": "Start from scratch", - "importOldmarr": "Import from Homarr before 1.0" + "importOldmarr": "Import from Homarr before 1.0", + "restoreSqlite": "Restore from SQLite backup", + "restoreSqliteDescription": "Upload a full SQLite database backup from a previous Homarr instance. This will restore all boards, users, apps, integrations, and settings.", + "backToOptions": "← Back to setup options" } }, "import": { @@ -3498,7 +3501,8 @@ "logs": "Logs", "api": "API", "certificates": "Certificates", - "tasks": "Tasks" + "tasks": "Tasks", + "backup": "Backup" } }, "settings": "Settings", @@ -4038,6 +4042,125 @@ } } } + }, + "backup": { + "title": "Full Database Backup", + "metaTitle": "Backup & Restore", + "export": { + "title": "Export Database", + "description": "Download a full backup of your SQLite database including all settings, users, boards, and encrypted integration secrets. The backup is bundled as a ZIP file with the encryption key and metadata.", + "button": "Download Backup", + "error": "Export failed", + "confirm": { + "title": "Sensitive data export", + "warningTitle": "This export contains your entire database.", + "warningBody": "The backup includes all users, integration secrets (encrypted), API keys, board configurations, and app data. Store the exported file securely and do not share it.", + "typePrompt": "Type \"{phrase}\" to continue", + "submit": "Export Database", + "cancel": "Cancel" + } + }, + "import": { + "title": "Restore Database", + "description": "Upload a previously exported backup ZIP to restore your database. This will replace your entire database. If the backup was created with a different encryption key, secrets will be automatically re-encrypted.", + "warning": "Restoring a backup will completely replace your current database and restart the application. This action cannot be undone.", + "dropzone": { + "title": "Drop your backup ZIP here or click to browse", + "description": "Only .zip files from a Homarr SQLite backup are accepted" + }, + "changeFile": "Change file", + "button": "Restore Database", + "success": { + "title": "Restore Complete", + "message": "Database has been restored successfully. The application is restarting..." + }, + "error": "Import failed" + }, + "preview": { + "title": "Backup Contents", + "exported": "Exported {date}", + "loading": "Analyzing backup file...", + "errorTitle": "Preview Error", + "entity": "Entity", + "count": "Count", + "boards": "Boards", + "entities": { + "board": "Boards", + "user": "Users", + "app": "Apps", + "integration": "Integrations", + "item": "Widgets", + "media": "Media", + "search_engine": "Search Engines" + }, + "analyzeError": "Failed to analyze backup file", + "invalidMissingMetadata": "Invalid backup: missing metadata.json", + "invalidMissingDb": "Invalid backup: missing db.sqlite" + }, + "restore": { + "dropzone": { + "title": "Drop your backup ZIP here", + "description": "Accepts .zip files from a Homarr SQLite backup (max 256 MB)" + }, + "analyzing": "Analyzing backup...", + "changeFile": "Change file", + "continueToRestore": "Continue to restore", + "tryAgain": "Try again", + "analysisError": "Analysis Error", + "preview": { + "title": "Backup Contents", + "exported": "Exported {date}", + "entity": "Entity", + "count": "Count", + "boards": "Boards", + "entities": { + "board": "Boards", + "user": "Users", + "app": "Apps", + "integration": "Integrations", + "item": "Widgets", + "media": "Media", + "search_engine": "Search Engines" + }, + "migrationsApplied": "{count} migration applied", + "migrationsAppliedPlural": "{count} migrations applied", + "migrationsOlderVersion": "This backup was from an older Homarr version. {count} schema migration was applied to bring it up to date.", + "migrationsOlderVersionPlural": "This backup was from an older Homarr version. {count} schema migrations were applied to bring it up to date.", + "migrationsUpToDate": "Database schema is up to date ({count} migrations applied)", + "upToDate": "Up to date", + "migrationsPending": "{count} pending" + }, + "migration": { + "title": "Applying migrations ({current}/{total})", + "migrationLabel": "Migration {index}" + }, + "confirm": { + "title": "Destructive action", + "warningTitle": "This will permanently replace your entire database.", + "warningBody": "All current boards, users, apps, integrations, settings, and sessions will be lost. This action cannot be undone. The application will restart after the restore.", + "typePrompt": "Type \"{phrase}\" to continue", + "cancel": "Cancel", + "submit": "Restore Database" + }, + "progress": { + "title": "Restoring database...", + "extracting": "Extracting backup archive", + "migrating": "Applying database migrations", + "encrypting": "Re-encrypting integration secrets", + "swapping": "Replacing database file", + "restarting": "Restarting server" + }, + "complete": { + "title": "Restore Complete", + "message": "Database has been restored successfully. The server is restarting — please refresh the page in a few seconds." + }, + "failed": { + "title": "Restore Failed" + }, + "invalidMissingMetadata": "Invalid backup: missing metadata.json", + "invalidMissingDb": "Invalid backup: missing db.sqlite", + "analyzeError": "Failed to analyze backup" + } } }, "about": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f389f2d2d..64a13594d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -384,6 +384,9 @@ catalogs: json5: specifier: ^2.2.3 version: 2.2.3 + jszip: + specifier: ^3.10.1 + version: 3.10.1 ldapts: specifier: 8.1.8 version: 8.1.8 @@ -796,9 +799,15 @@ importers: '@xterm/xterm': specifier: 'catalog:' version: 6.0.0 + adm-zip: + specifier: 'catalog:' + version: 0.5.17 babel-plugin-react-compiler: specifier: 'catalog:' version: 1.0.0 + better-sqlite3: + specifier: 'catalog:' + version: 12.10.0 chroma-js: specifier: 'catalog:' version: 3.2.0 @@ -811,6 +820,9 @@ importers: dotenv: specifier: 'catalog:' version: 17.4.2 + drizzle-orm: + specifier: 'catalog:' + version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1) flag-icons: specifier: 'catalog:' version: 7.5.0 @@ -850,6 +862,9 @@ importers: sass: specifier: 'catalog:' version: 1.100.0 + sql.js: + specifier: 'catalog:' + version: 1.14.1 superjson: specifier: 'catalog:' version: 2.2.6 @@ -869,6 +884,12 @@ importers: '@homarr/tsconfig': specifier: workspace:^0.1.0 version: link:../../tooling/typescript + '@types/adm-zip': + specifier: 'catalog:' + version: 0.5.8 + '@types/better-sqlite3': + specifier: 'catalog:' + version: 7.6.13 '@types/chroma-js': specifier: 'catalog:' version: 3.1.2 @@ -1338,7 +1359,7 @@ importers: version: 0.2.9 drizzle-orm: specifier: 'catalog:' - version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0) + version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1) ioredis: specifier: 'catalog:' version: 5.11.0 @@ -1510,10 +1531,10 @@ importers: version: 0.31.10 drizzle-orm: specifier: 'catalog:' - version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0) + version: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1) drizzle-zod: specifier: 'catalog:' - version: 0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0))(zod@4.4.3) + version: 0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1))(zod@4.4.3) mysql2: specifier: 'catalog:' version: 3.22.4(@types/node@24.12.4) @@ -7039,6 +7060,9 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} @@ -7335,6 +7359,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} @@ -7358,6 +7385,9 @@ packages: resolution: {integrity: sha512-Wpdvo+/0DREIynYf2he2efHtjptr5zD7dpesSkZWJqfQdMEgSTytEIsSZVuoDSiiE1+SpXf3emZ7ewxGBTo7Pw==} engines: {node: '>=20'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -8049,6 +8079,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parchment@3.0.0: resolution: {integrity: sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==} @@ -8791,6 +8824,9 @@ packages: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -8927,6 +8963,9 @@ packages: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + sql.js@1.14.1: + resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} + ssh-remote-port-forward@1.0.4: resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==} @@ -13743,7 +13782,7 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0): + drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1): optionalDependencies: '@libsql/client-wasm': 0.14.0 '@types/better-sqlite3': 7.6.13 @@ -13752,10 +13791,11 @@ snapshots: gel: 2.0.0 mysql2: 3.22.4(@types/node@24.12.4) pg: 8.21.0 + sql.js: 1.14.1 - drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0))(zod@4.4.3): + drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1))(zod@4.4.3): dependencies: - drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0) + drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(gel@2.0.0)(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(sql.js@1.14.1) zod: 4.4.3 dunder-proto@1.0.1: @@ -14429,6 +14469,8 @@ snapshots: ieee754@1.2.1: {} + immediate@3.0.6: {} + immer@10.2.0: {} immer@11.1.4: {} @@ -14694,6 +14736,13 @@ snapshots: '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) jsep: 1.4.0 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keygrip@1.1.0: dependencies: tsscmp: 1.0.6 @@ -14714,6 +14763,10 @@ snapshots: dependencies: strict-event-emitter-types: 2.0.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -15467,6 +15520,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + parchment@3.0.0: {} parent-module@1.0.1: @@ -16339,6 +16394,8 @@ snapshots: gopd: 1.2.0 has-property-descriptors: 1.0.2 + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} sha.js@2.4.12: @@ -16507,6 +16564,8 @@ snapshots: sql-escaper@1.3.3: {} + sql.js@1.14.1: {} + ssh-remote-port-forward@1.0.4: dependencies: '@types/ssh2': 0.5.52 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 636c0c7d3..bc5e65b6f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -98,6 +98,8 @@ catalog: "@xterm/addon-fit": 0.11.0 "@xterm/xterm": ^6.0.0 adm-zip: 0.5.17 + jszip: ^3.10.1 + sql.js: ^1.12.0 babel-plugin-react-compiler: ^1.0.0 bcrypt: ^6.0.0 better-sqlite3: ^12.10.0 diff --git a/scripts/run.sh b/scripts/run.sh index a9c13a8e1..83eff588e 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -10,8 +10,11 @@ if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then echo "DB migrations are disabled, skipping" else echo "Running DB migrations" - # We disable redis logs during migration as the redis client is not yet started DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT + if [ $? -ne 0 ]; then + echo "ERROR: DB migrations failed, aborting startup" + exit 1 + fi fi # Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing @@ -36,26 +39,38 @@ else REDIS_PID=$! fi -node apps/nextjs/server.js & -NEXTJS_PID=$! +SHUTTING_DOWN=false -# Function to handle SIGTERM and shut down services terminate() { - echo "Received SIGTERM. Shutting down..." - kill -TERM $NGINX_PID $NEXTJS_PID 2>/dev/null - wait - # kill redis-server last because of logging of other services and only if $REDIS_PID is set + SHUTTING_DOWN=true + echo "Shutting down..." + kill -TERM $NEXTJS_PID 2>/dev/null + wait $NEXTJS_PID 2>/dev/null + kill -TERM $NGINX_PID 2>/dev/null + wait $NGINX_PID 2>/dev/null if [ -n "$REDIS_PID" ]; then kill -TERM $REDIS_PID 2>/dev/null - wait + wait $REDIS_PID 2>/dev/null fi echo "Shutdown complete." exit 0 } -# When SIGTERM (docker stop ) / SIGINT (ctrl+c) is received, run the terminate function trap terminate TERM INT -# Wait for all processes -wait $NEXTJS_PID -terminate +node apps/nextjs/server.js & +NEXTJS_PID=$! + +while true; do + wait $NEXTJS_PID + EXIT_CODE=$? + + if [ "$SHUTTING_DOWN" = true ]; then + break + fi + + echo "Next.js exited with code $EXIT_CODE, restarting in 1s..." + sleep 1 + node apps/nextjs/server.js & + NEXTJS_PID=$! +done