diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 1db8ff957..636df3f52 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -5,15 +5,30 @@ 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({ requestConfig: "../../packages/translation/src/request.ts", }); const nextConfig: NextConfig = { + env: { + HOMARR_VERSION: homarrVersion, + }, output: "standalone", reactStrictMode: true, // react compiler breaks mantine-react-table, so disabled for now @@ -24,7 +39,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 f7d61657c..be5ff450b 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:", "lucide-react": "catalog:", "mantine-datatable": "catalog:", @@ -87,6 +91,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:", @@ -95,6 +100,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/package.json b/package.json index 5e0b488be..cc5af67e8 100644 --- a/package.json +++ b/package.json @@ -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", @@ -89,7 +89,7 @@ "prismjs@<1.30.0": ">=1.30.0", "proxmox-api>undici": "7.24.6", "react-is": "^19.2.7", - "rollup@>=4.0.0 <4.22.4": ">=4.61.0", + "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", diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 9cb85329b..05a29f29e 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -364,7 +364,7 @@ export const integrationDefs = { }, truenas: { name: "TrueNAS", - secretKinds: [["username", "password"]], + secretKinds: [["username", "password"], ["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg", category: ["healthMonitoring"], documentationUrl: createDocumentationLink("/docs/integrations/truenas"), diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 88ea6892a..6e0c81039 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -47,12 +47,14 @@ "timezones-ical-library": "catalog:", "tsdav": "catalog:", "undici": "catalog:", + "ws": "catalog:", "xml2js": "catalog:", "zod": "catalog:" }, "devDependencies": { "@homarr/tsconfig": "workspace:^0.1.0", "@types/node-unifi": "catalog:", + "@types/ws": "catalog:", "@types/xml2js": "catalog:", "typescript": "catalog:" } diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts index 22ad8b1ea..e129ca283 100644 --- a/packages/integrations/src/base/integration.ts +++ b/packages/integrations/src/base/integration.ts @@ -85,6 +85,16 @@ export abstract class Integration { return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams); } + protected webSocketUrl( + path: `/${string}`, + queryParams?: Record, + ) { + const url = this.url(path, queryParams); + // http -> ws, https -> wss + url.protocol = url.protocol.replace("http", "ws"); + return url; + } + public async testConnectionAsync(): Promise { try { const url = new URL(this.integration.url); diff --git a/packages/integrations/src/truenas/test/truenas-api.spec.ts b/packages/integrations/src/truenas/test/truenas-api.spec.ts new file mode 100644 index 000000000..cc75bcc07 --- /dev/null +++ b/packages/integrations/src/truenas/test/truenas-api.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "vitest"; + +import { trueNasApis } from "../truenas-api"; + +describe("trueNasApis descriptors", () => { + test("expose the expected endpoints and handshake requirements", () => { + expect(trueNasApis.jsonRpc).toMatchObject({ + kind: "jsonRpc", + path: "/api/current", + requiresSessionHandshake: false, + }); + expect(trueNasApis.legacy).toMatchObject({ + kind: "legacy", + path: "/websocket", + requiresSessionHandshake: true, + }); + }); +}); + +describe("jsonRpc api", () => { + const { buildRequest, matchResponse } = trueNasApis.jsonRpc; + + test("builds a JSON-RPC 2.0 envelope", () => { + expect(buildRequest("id-1", "system.info", [])).toStrictEqual({ + jsonrpc: "2.0", + id: "id-1", + method: "system.info", + params: [], + }); + }); + + test("matches a result for the requested id", () => { + const result = matchResponse({ jsonrpc: "2.0", id: "id-1", result: true }, "id-1"); + expect(result).toStrictEqual({ result: true }); + }); + + test("returns the error when the response carries one", () => { + const error = { code: -32001, message: "Not authenticated" }; + expect(matchResponse({ jsonrpc: "2.0", id: "id-1", error }, "id-1")).toStrictEqual({ error }); + }); + + test("ignores responses for a different id", () => { + expect(matchResponse({ jsonrpc: "2.0", id: "other", result: true }, "id-1")).toBeUndefined(); + }); + + test("ignores non JSON-RPC messages", () => { + expect(matchResponse({ msg: "result", id: "id-1", result: true }, "id-1")).toBeUndefined(); + }); +}); + +describe("legacy api", () => { + const { buildRequest, matchResponse } = trueNasApis.legacy; + + test("builds a DDP method envelope", () => { + expect(buildRequest("id-1", "auth.login", ["user", "pass"])).toStrictEqual({ + id: "id-1", + msg: "method", + method: "auth.login", + params: ["user", "pass"], + }); + }); + + test("matches a result message for the requested id", () => { + expect(matchResponse({ msg: "result", id: "id-1", result: false }, "id-1")).toStrictEqual({ result: false }); + }); + + test("returns the error when the result message carries one", () => { + const error = { error: 13, reason: "boom" }; + expect(matchResponse({ msg: "result", id: "id-1", error }, "id-1")).toStrictEqual({ error }); + }); + + test("ignores responses for a different id", () => { + expect(matchResponse({ msg: "result", id: "other", result: true }, "id-1")).toBeUndefined(); + }); + + test("ignores non-result messages such as the session handshake", () => { + expect(matchResponse({ msg: "connected", session: "abc" }, "id-1")).toBeUndefined(); + }); +}); diff --git a/packages/integrations/src/truenas/test/truenas-integration.spec.ts b/packages/integrations/src/truenas/test/truenas-integration.spec.ts new file mode 100644 index 000000000..28ff3b8d1 --- /dev/null +++ b/packages/integrations/src/truenas/test/truenas-integration.spec.ts @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { IntegrationRequestError } from "../../base/errors/http/integration-request-error"; +import type { IntegrationSecret } from "../../base/types"; + +const ws = vi.hoisted(() => { + const sentPayloads: Record[] = []; + const constructedUrls: string[] = []; + // A failure with a `code` emits a coded TLS-style error; without one it mimics a missing endpoint (HTTP 404). + const failures: { path: string; code?: string }[] = []; + let responder: (method: string, params: unknown[]) => unknown = () => undefined; + + type Listener = (...args: unknown[]) => void; + + class FakeWebSocket { + static readonly OPEN = 1; + public readyState = FakeWebSocket.OPEN; + private readonly listeners: Record = {}; + + constructor(url: URL | string) { + constructedUrls.push(url.toString()); + setTimeout(() => { + const failure = failures.find((candidate) => url.toString().includes(candidate.path)); + if (failure) { + const error: Error & { code?: string } = new Error(failure.code ?? "Unexpected server response: 404"); + if (failure.code) error.code = failure.code; + this.dispatch("error", error); + } else { + this.dispatch("open"); + } + }, 0); + } + + on(event: string, listener: Listener) { + (this.listeners[event] ??= []).push(listener); + return this; + } + + once(event: string, listener: Listener) { + const wrapper: Listener = (...args) => { + this.off(event, wrapper); + listener(...args); + }; + return this.on(event, wrapper); + } + + off(event: string, listener: Listener) { + this.listeners[event] = (this.listeners[event] ?? []).filter((candidate) => candidate !== listener); + return this; + } + + send(raw: string) { + const payload = JSON.parse(raw) as Record; + sentPayloads.push(payload); + + if (payload.msg === "connect") { + this.respond(JSON.stringify({ msg: "connected", session: "test-session" })); + return; + } + + const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []); + const envelope = + payload.jsonrpc === "2.0" + ? { jsonrpc: "2.0", id: payload.id, result } + : { msg: "result", id: payload.id, result }; + this.respond(JSON.stringify(envelope)); + } + + close() { + this.readyState = 3; + } + + private respond(message: string) { + setTimeout(() => this.dispatch("message", message), 0); + } + + private dispatch(event: string, ...args: unknown[]) { + // `off` replaces the array rather than mutating it, so iterating it directly is safe. + for (const listener of this.listeners[event] ?? []) listener(...args); + } + } + + return { + FakeWebSocket, + sentPayloads, + constructedUrls, + failures, + setResponder: (fn: (method: string, params: unknown[]) => unknown) => { + responder = fn; + }, + }; +}); + +vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket })); +vi.mock("@homarr/core/infrastructure/certificates", () => ({ + getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]), + getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]), +})); +vi.mock("@homarr/core/infrastructure/http", () => ({ + createCustomCheckServerIdentity: () => () => undefined, +})); + +import { TrueNasIntegration } from "../truenas-integration"; + +const happyResponder = (method: string) => { + switch (method) { + case "auth.login": + case "auth.login_with_api_key": + return true; + case "system.info": + return { + version: "TrueNAS-SCALE-24.10", + hostname: "nas", + physmem: 16_000_000_000, + model: "Intel Xeon", + uptime_seconds: 3600, + }; + case "reporting.get_data": + return [ + { + name: "cpu", + identifier: "cpu", + aggregations: emptyAggregations(), + start: 0, + end: 1, + legend: [], + data: [[1000, 12]], + }, + { + name: "memory", + identifier: "memory", + aggregations: emptyAggregations(), + start: 0, + end: 1, + legend: [], + data: [[1000, 8_000_000_000]], + }, + { + name: "cputemp", + identifier: "cputemp", + aggregations: emptyAggregations(), + start: 0, + end: 1, + legend: [], + data: [[1000, 45]], + }, + ]; + case "pool.query": + return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }]; + case "interface.query": + return [{ id: "eth0", name: "eth0" }]; + case "reporting.netdata_get_data": + return [{ name: "interface", identifier: "eth0", data: [[1000, 100, 200]] }]; + default: + return null; + } +}; + +const emptyAggregations = () => ({ min: {}, mean: {}, max: {} }); + +let integrationCounter = 0; +const createIntegration = (decryptedSecrets: IntegrationSecret[]) => + new TrueNasIntegration({ + id: `truenas-${(integrationCounter += 1)}`, + name: "TrueNAS Test", + url: "https://truenas.local", + externalUrl: null, + decryptedSecrets, + }); + +const credentials: IntegrationSecret[] = [ + { kind: "username", value: "root" }, + { kind: "password", value: "secret" }, +]; + +describe("TrueNasIntegration", () => { + beforeEach(() => { + ws.sentPayloads.length = 0; + ws.constructedUrls.length = 0; + ws.failures.length = 0; + ws.setResponder(happyResponder); + }); + + test("fetches and maps system info over the JSON-RPC API", async () => { + const integration = createIntegration(credentials); + + const result = await integration.getSystemInfoAsync(); + + expect(ws.constructedUrls).toContain("wss://truenas.local/api/current"); + // JSON-RPC does not use the legacy "connect" session handshake. + expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false); + + expect(result).toMatchObject({ + version: "TrueNAS-SCALE-24.10", + cpuModelName: "Intel Xeon", + cpuTemp: 45, + memAvailableInBytes: 16_000_000_000, + memUsedInBytes: 8_000_000_000, + uptime: 3600, + rebootRequired: false, + availablePkgUpdates: 0, + loadAverage: null, + gpu: [], + network: { up: 20_000, down: 10_000 }, + fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }], + smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }], + }); + }); + + test("authenticates with an API key when one is configured", async () => { + const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]); + + await integration.getSystemInfoAsync(); + + const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth.")); + expect(authPayload).toMatchObject({ method: "auth.login_with_api_key", params: ["api-key-123"] }); + }); + + test("authenticates with username and password when no API key is configured", async () => { + const integration = createIntegration(credentials); + + await integration.getSystemInfoAsync(); + + const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth.")); + expect(authPayload).toMatchObject({ method: "auth.login", params: ["root", "secret"] }); + }); + + test("falls back to the legacy websocket API when the JSON-RPC endpoint is unavailable", async () => { + ws.failures.push({ path: "/api/current" }); + const integration = createIntegration(credentials); + + const result = await integration.getSystemInfoAsync(); + + expect(ws.constructedUrls).toContain("wss://truenas.local/api/current"); + expect(ws.constructedUrls).toContain("wss://truenas.local/websocket"); + // The legacy API requires the "connect" session handshake before authenticating. + expect(ws.sentPayloads[0]).toMatchObject({ msg: "connect" }); + expect(ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."))).toMatchObject({ + msg: "method", + method: "auth.login", + }); + expect(result.version).toBe("TrueNAS-SCALE-24.10"); + }); + + test("rejects when authentication is not successful", async () => { + ws.setResponder((method) => (method.startsWith("auth.") ? false : happyResponder(method))); + const integration = createIntegration(credentials); + + await expect(integration.getSystemInfoAsync()).rejects.toThrow(); + }); + + test("surfaces a self-signed certificate as a certificate request error without falling back", async () => { + ws.failures.push({ path: "/api/current", code: "DEPTH_ZERO_SELF_SIGNED_CERT" }); + ws.failures.push({ path: "/websocket", code: "DEPTH_ZERO_SELF_SIGNED_CERT" }); + const integration = createIntegration(credentials); + + const error = await integration.getSystemInfoAsync().catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(IntegrationRequestError); + expect((error as IntegrationRequestError).cause).toMatchObject({ type: "certificate", reason: "untrusted" }); + // A certificate failure is endpoint-independent, so the legacy API must not be tried. + expect(ws.constructedUrls).not.toContain("wss://truenas.local/websocket"); + }); +}); diff --git a/packages/integrations/src/truenas/truenas-api.ts b/packages/integrations/src/truenas/truenas-api.ts new file mode 100644 index 000000000..322c3e856 --- /dev/null +++ b/packages/integrations/src/truenas/truenas-api.ts @@ -0,0 +1,50 @@ +export type TrueNasApiKind = "jsonRpc" | "legacy"; + +export type TrueNasResponse = { result: unknown } | { error: unknown }; + +export interface TrueNasApi { + kind: TrueNasApiKind; + /** Websocket path appended to the integration URL. */ + path: `/${string}`; + /** The legacy DDP API requires a "connect" handshake before authentication, the JSON-RPC API does not. */ + requiresSessionHandshake: boolean; + /** Builds the request envelope for a method call. */ + buildRequest: (id: string, method: string, params: unknown[]) => Record; + /** + * Returns the response payload when `message` is the reply to `id`, + * otherwise `undefined` so the caller keeps waiting for a later message. + */ + matchResponse: (message: Record, id: string) => TrueNasResponse | undefined; +} + +/** + * JSON-RPC 2.0 API introduced in TrueNAS SCALE 24.10 (Electric Eel) and the + * legacy DDP-style websocket API used before it. Both expose the same + * middleware methods, so only the transport envelope differs. + * @see https://www.truenas.com/docs/api/scale_rest_api.html + * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol + */ +export const trueNasApis = { + jsonRpc: { + kind: "jsonRpc", + path: "/api/current", + requiresSessionHandshake: false, + buildRequest: (id: string, method: string, params: unknown[]) => ({ jsonrpc: "2.0", id, method, params }), + matchResponse: (message: Record, id: string) => { + if (message.id !== id || message.jsonrpc !== "2.0") return undefined; + if (message.error != null) return { error: message.error }; + return { result: message.result }; + }, + }, + legacy: { + kind: "legacy", + path: "/websocket", + requiresSessionHandshake: true, + buildRequest: (id: string, method: string, params: unknown[]) => ({ id, msg: "method", method, params }), + matchResponse: (message: Record, id: string) => { + if (message.id !== id || message.msg !== "result") return undefined; + if (message.error != null) return { error: message.error }; + return { result: message.result }; + }, + }, +} satisfies Record; diff --git a/packages/integrations/src/truenas/truenas-client.ts b/packages/integrations/src/truenas/truenas-client.ts new file mode 100644 index 000000000..e0f04f447 --- /dev/null +++ b/packages/integrations/src/truenas/truenas-client.ts @@ -0,0 +1,315 @@ +import type { RawData } from "ws"; +import { WebSocket } from "ws"; +import z from "zod"; + +import { createId } from "@homarr/common"; +import { matchErrorCode, RequestError, ResponseError } from "@homarr/common/server"; +import { + getAllTrustedCertificatesAsync, + getTrustedCertificateHostnamesAsync, +} from "@homarr/core/infrastructure/certificates"; +import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http"; +import { createLogger } from "@homarr/core/infrastructure/logs"; + +import type { IntegrationTestingInput } from "../base/integration"; +import type { TrueNasApi } from "./truenas-api"; +import { trueNasApis } from "./truenas-api"; + +const logger = createLogger({ module: "trueNasClient" }); + +const REQUEST_TIMEOUT_MS = 5000; + +type CertificateOptions = IntegrationTestingInput["options"]; + +export type TrueNasCredentials = { apiKey: string } | { username: string; password: string }; + +/** + * Handles talking to a TrueNAS server: opening a websocket with Homarr's + * trusted certificates, negotiating the API variant, authenticating, and + * sending method requests. Connections are reused across the per-request + * integration instances and reconnected transparently when the server drops + * an idle socket. + */ +export class TrueNasClient { + // Keyed by integration id. The value is the in-flight connection promise so concurrent + // callers reuse a single socket instead of racing to open duplicates. + private static readonly connectionMap = new Map>(); + private static readonly apiMap = new Map(); + + constructor( + private readonly id: string, + private readonly webSocketUrl: (path: `/${string}`) => URL, + private readonly credentials: TrueNasCredentials, + ) {} + + /** Sends an authenticated request, reusing the cached connection while it is open. */ + public async requestAsync(method: string, params: unknown[] = []): Promise { + const socket = await this.getSocketAsync(); + return socket.requestAsync(method, params); + } + + /** + * Opens a connection with the provided certificate options, authenticates, + * and closes it. Used by the connection test, which may run with updated + * credentials, so any cached connection is dropped and the API re-detected. + */ + public async testAsync(certificate: CertificateOptions): Promise { + this.evictConnection(); + TrueNasClient.apiMap.delete(this.id); + + const socket = await this.openSocketAsync(certificate); + try { + await this.authenticateAsync(socket); + } finally { + socket.close(); + } + } + + private getSocketAsync(): Promise { + let connection = TrueNasClient.connectionMap.get(this.id); + if (!connection) { + connection = this.connectAndAuthenticateAsync(); + TrueNasClient.connectionMap.set(this.id, connection); + + const evict = () => { + if (TrueNasClient.connectionMap.get(this.id) === connection) { + TrueNasClient.connectionMap.delete(this.id); + } + }; + // Reconnect on the next request once this attempt fails or the server drops the socket. + connection.then((socket) => socket.onClose(evict), evict); + } + + return connection; + } + + private async connectAndAuthenticateAsync(): Promise { + const socket = await this.openSocketAsync(await resolveCertificateOptionsAsync()); + try { + await this.authenticateAsync(socket); + } catch (error) { + // Never keep an unauthenticated socket, it would be reused without re-authenticating. + socket.close(); + throw error; + } + return socket; + } + + private evictConnection() { + const connection = TrueNasClient.connectionMap.get(this.id); + if (!connection) return; + + TrueNasClient.connectionMap.delete(this.id); + connection.then((socket) => socket.close()).catch(() => undefined); + } + + /** + * Connects using the JSON-RPC API (TrueNAS 24.10+) when available and falls + * back to the legacy websocket API otherwise. A missing JSON-RPC endpoint + * surfaces as a plain protocol error; certificate, connection, DNS and + * timeout errors are endpoint-independent and are never retried. + */ + private async openSocketAsync(certificate: CertificateOptions): Promise { + const cachedApi = TrueNasClient.apiMap.get(this.id); + if (cachedApi) { + return openTrueNasSocketAsync(this.webSocketUrl(cachedApi.path), cachedApi, certificate); + } + + try { + const socket = await openTrueNasSocketAsync( + this.webSocketUrl(trueNasApis.jsonRpc.path), + trueNasApis.jsonRpc, + certificate, + ); + TrueNasClient.apiMap.set(this.id, trueNasApis.jsonRpc); + return socket; + } catch (error) { + if (error instanceof RequestError) throw error; + + logger.debug("JSON-RPC API unavailable, falling back to the legacy websocket API", { + error: error instanceof Error ? error.message : undefined, + }); + const socket = await openTrueNasSocketAsync( + this.webSocketUrl(trueNasApis.legacy.path), + trueNasApis.legacy, + certificate, + ); + TrueNasClient.apiMap.set(this.id, trueNasApis.legacy); + return socket; + } + } + + /** + * Authenticates with an API key when one is configured, otherwise with the + * username and password. Both methods work on either API. + * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol + */ + private async authenticateAsync(socket: TrueNasSocket): Promise { + const response = + "apiKey" in this.credentials + ? await socket.requestAsync("auth.login_with_api_key", [this.credentials.apiKey]) + : await socket.requestAsync("auth.login", [this.credentials.username, this.credentials.password]); + + const success = await z.boolean().parseAsync(response); + if (!success) throw new ResponseError({ status: 401 }); + } +} + +/** + * Wraps a single websocket connection and speaks whichever TrueNAS API + * ({@link trueNasApis}) was negotiated when it was opened. + */ +class TrueNasSocket { + constructor( + private readonly webSocket: WebSocket, + private readonly api: TrueNasApi, + ) {} + + public close() { + this.webSocket.close(); + } + + public onClose(listener: () => void) { + this.webSocket.once("close", listener); + } + + /** + * The legacy DDP API requires a "connect" handshake to obtain a session + * before any method can be called. + * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol + */ + public registerSessionAsync(): Promise { + return new Promise((resolve, reject) => { + const handler = (raw: RawData) => { + const message = parseMessage(raw); + if (message?.msg === "connected") { + this.webSocket.off("message", handler); + resolve(); + } else if (message?.msg === "failed") { + this.webSocket.off("message", handler); + reject(new Error("Unable to establish TrueNAS session")); + } + }; + + this.webSocket.on("message", handler); + this.webSocket.send(JSON.stringify({ msg: "connect", version: "1", support: ["1"] })); + }); + } + + /** + * Sends a method request and resolves with its result. Rejects after + * {@link REQUEST_TIMEOUT_MS} when no matching response was received. + */ + public requestAsync(method: string, params: unknown[] = []): Promise { + return new Promise((resolve, reject) => { + const id = createId(); + + const cleanup = () => { + clearTimeout(timeoutId); + this.webSocket.off("message", handler); + this.webSocket.off("close", onClose); + this.webSocket.off("error", onError); + }; + + const handler = (raw: RawData) => { + const message = parseMessage(raw); + if (!message) return; + + const response = this.api.matchResponse(message, id); + if (!response) return; + + cleanup(); + if ("error" in response) { + reject(new Error(`TrueNAS method "${method}" failed: ${JSON.stringify(response.error)}`)); + return; + } + logger.debug("Received method response", { id, method, api: this.api.kind }); + resolve(response.result); + }; + + // A dropped connection would otherwise leave the request hanging until the timeout below. + const onClose = () => { + cleanup(); + reject( + new RequestError( + { type: "connection", reason: "reset", code: "ECONNRESET" }, + { cause: new Error("Connection closed before a response was received") }, + ), + ); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + const timeoutId = setTimeout(() => { + cleanup(); + reject( + new RequestError( + { + type: "timeout", + reason: "aborted", + code: "ECONNABORTED", + }, + { cause: new Error("Canceled request after 5 seconds") }, + ), + ); + }, REQUEST_TIMEOUT_MS); + + this.webSocket.on("message", handler); + this.webSocket.once("close", onClose); + this.webSocket.once("error", onError); + logger.debug("Sending method request", { id, method, api: this.api.kind }); + this.webSocket.send(JSON.stringify(this.api.buildRequest(id, method, params))); + }); + } +} + +/** + * Opens a websocket using Homarr's trusted certificates and performs the + * session handshake when the API requires it. TLS, connection, DNS and timeout + * failures are translated into {@link RequestError}s so the connection test can + * offer to trust a self-signed certificate, exactly like the HTTP integrations. + */ +const openTrueNasSocketAsync = async ( + url: URL, + api: TrueNasApi, + certificate: CertificateOptions, +): Promise => { + logger.debug("Connecting to TrueNAS websocket", { url: url.toString(), api: api.kind }); + + const webSocket = await new Promise((resolve, reject) => { + const socket = new WebSocket(url, { + ca: certificate.ca, + // The @types/ws contract types this as returning a boolean, but ws forwards it to + // tls.connect which uses Node's `Error | undefined` contract that our helper follows. + checkServerIdentity: certificate.checkServerIdentity as never, + }); + + socket.once("open", () => resolve(socket)); + socket.once("error", (error: Error & { code?: string }) => { + const matched = error.code ? matchErrorCode(error.code) : undefined; + reject(matched ? new RequestError(matched, { cause: error }) : error); + }); + }); + + const socket = new TrueNasSocket(webSocket, api); + if (api.requiresSessionHandshake) await socket.registerSessionAsync(); + return socket; +}; + +const resolveCertificateOptionsAsync = async (): Promise => ({ + ca: await getAllTrustedCertificatesAsync(), + checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()), +}); + +const parseMessage = (raw: RawData): Record | undefined => { + try { + const parsed: unknown = JSON.parse(raw.toString()); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +}; diff --git a/packages/integrations/src/truenas/truenas-integration.ts b/packages/integrations/src/truenas/truenas-integration.ts index 8d64f848c..adbe32b3b 100644 --- a/packages/integrations/src/truenas/truenas-integration.ts +++ b/packages/integrations/src/truenas/truenas-integration.ts @@ -1,8 +1,6 @@ import dayjs from "dayjs"; import z from "zod"; -import { createId } from "@homarr/common"; -import { RequestError, ResponseError } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import type { IntegrationTestingInput } from "../base/integration"; @@ -10,252 +8,21 @@ import { Integration } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration"; import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types"; +import type { TrueNasCredentials } from "./truenas-client"; +import { TrueNasClient } from "./truenas-client"; const logger = createLogger({ module: "trueNasIntegration" }); const NETWORK_MULTIPLIER = 100; export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration { - private static webSocketMap = new Map(); - - private wsUrl() { - const url = super.url("/websocket"); - url.protocol = url.protocol.replace("http", "ws"); - return url; - } - - private get webSocket() { - return TrueNasIntegration.webSocketMap.get(this.integration.id) ?? null; - } - - protected async testingAsync(_input: IntegrationTestingInput): Promise { - const webSocket = await this.connectWebSocketAsync(); - await this.registerSessionAsync(webSocket); - await this.authenticateWebSocketAsync(webSocket); - - // Remove current socket connection so we can authenticate with updated credentials - TrueNasIntegration.webSocketMap.delete(this.integration.id); + private client?: TrueNasClient; + protected async testingAsync(input: IntegrationTestingInput): Promise { + await this.getClient().testAsync(input.options); return { success: true }; } - /** - * TrueNAS API uses WebSocket. This function connects to the socket - * and resolves the promise if the connection was successful. - * @see https://www.truenas.com/docs/api/scale_websocket_api.html - */ - private async connectWebSocketAsync(): Promise { - logger.debug("Connecting to websocket server", { - url: this.wsUrl(), - }); - const webSocket = new WebSocket(this.wsUrl()); - - return new Promise((resolve, reject) => { - webSocket.onopen = () => { - logger.debug("Connected to websocket server", { - url: this.wsUrl(), - }); - resolve(webSocket); - }; - - webSocket.onerror = () => { - reject(new Error("Failed to connect")); - }; - }); - } - - /** - * Before authentication, a session must be obtained from the server using the "connect" event. - * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol - */ - private async registerSessionAsync(webSocket: WebSocket): Promise { - return new Promise((resolve, reject) => { - const subscribe = (event: MessageEvent) => { - const data = JSON.parse(event.data) as { msg: string }; - if (data.msg === "connected") { - webSocket.removeEventListener("message", subscribe); - resolve(); - } else if (data.msg === "failed") { - webSocket.removeEventListener("message", subscribe); - reject(new Error("Unable to establish connection")); - } - }; - - webSocket.addEventListener("message", subscribe); - webSocket.send( - JSON.stringify({ - msg: "connect", - version: "1", // this must be number, not string - support: ["1"], // this must be number, not string - }), - ); - }); - } - - /** - * After a session was obtained, the session can be authenticated. - * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol - */ - private async authenticateWebSocketAsync(webSocket?: WebSocket): Promise { - logger.debug("Authenticating with username and password", { - url: this.wsUrl(), - }); - const response = await this.requestAsync( - "auth.login", - [this.getSecretValue("username"), this.getSecretValue("password")], - webSocket, - ); - const result = await z.boolean().parseAsync(response); - if (!result) throw new ResponseError({ status: 401 }); - logger.debug("Authenticated successfully with username and password", { - url: this.wsUrl(), - }); - } - - private async getPoolsAsync() { - logger.debug("Retrieving pools", { - url: this.wsUrl(), - }); - - const response = await this.requestAsync("pool.query", [ - [], - { - extra: { - is_upgraded: true, - }, - }, - ]); - const result = await poolSchema.parseAsync(response); - // Offline / exported pools have null values for allocated, size and free, so we filter them out - // See https://github.com/homarr-labs/homarr/issues/5194 - const activePools = result - .map((pool) => { - if (pool.allocated !== null && pool.size !== null && pool.free !== null) { - return { - ...pool, - allocated: pool.allocated, - size: pool.size, - free: pool.free, - }; - } - - return null; - }) - .filter((pool) => pool !== null); - logger.debug("Retrieved pools", { - url: this.wsUrl(), - totalCount: result.length, - activeCount: activePools.length, - }); - return activePools; - } - - /** - * Retrieves data using the reporting method - * @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting - */ - private async getReportingAsync(): Promise { - logger.debug("Retrieving reporting data", { - url: this.wsUrl(), - }); - - const response = await this.requestAsync("reporting.get_data", [ - [ - { - name: "cpu", - }, - { - name: "memory", - }, - { - name: "cputemp", - }, - ], - { - aggregate: true, - start: dayjs().add(-5, "minutes").unix(), - end: dayjs().unix(), - }, - ]); - const result = await z.array(reportingItemSchema).parseAsync(response); - - logger.debug("Retrieved reporting data", { - url: this.wsUrl(), - count: result.length, - }); - return result; - } - - /** - * Retrieves a list of all available network interfaces - * @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface - */ - private async getNetworkInterfacesAsync(): Promise> { - logger.debug("Retrieving available network-interfaces", { - url: this.wsUrl(), - }); - - const response = await this.requestAsync("interface.query", [ - [], // no filters - {}, - ]); - const result = await networkInterfaceSchema.parseAsync(response); - - logger.debug("Retrieved available network-interfaces", { - url: this.wsUrl(), - count: result.length, - }); - return result; - } - - /** - * Retrieves reporting network data of the last 5 minutes - * @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting - */ - private async getReportingNetdataAsync(): Promise> { - const networkInterfaces = await this.getNetworkInterfacesAsync(); - - logger.debug("Retrieving reporting network data", { - url: this.wsUrl(), - }); - - const response = await this.requestAsync("reporting.netdata_get_data", [ - networkInterfaces.map((networkInterface) => ({ - name: "interface", - identifier: networkInterface.id, - })), - { - start: dayjs().add(-5, "minutes").unix(), - end: dayjs().unix(), - }, - ]); - const result = await reportingNetDataSchema.parseAsync(response); - - logger.debug("Retrieved reporting-network-data", { - url: this.wsUrl(), - count: result.length, - }); - return result; - } - - /** - * Retrieves information about the system - * @see https://www.truenas.com/docs/api/scale_websocket_api.html#system - */ - private async getSystemInformationAsync(): Promise> { - logger.debug("Retrieving system-information", { - url: this.wsUrl(), - }); - - const response = await this.requestAsync("system.info"); - const result = await systemInfoSchema.parseAsync(response); - - logger.debug("Retrieved system-information", { - url: this.wsUrl(), - }); - return result; - } - public async getSystemInfoAsync(): Promise { const systemInformation = await this.getSystemInformationAsync(); const reporting = await this.getReportingAsync(); @@ -272,7 +39,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni return { cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length, - cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)), + cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)), memAvailableInBytes: systemInformation.physmem, memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes fileSystem: datasets.map((dataset) => ({ @@ -301,87 +68,164 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni }; } - /** - * Send a request through websocket and return response - * Times out after 5 seconds when no response was received. - * @param method json-rpc method to call - * @param params array of parameters - * @param webSocketOverride override of webSocket, helpful for not storing the connection - * @returns result of json-rpc call - */ - private async requestAsync(method: string, params: unknown[] = [], webSocketOverride?: WebSocket) { - let webSocket = webSocketOverride ?? this.webSocket; - if (webSocket?.readyState !== WebSocket.OPEN) { - logger.debug("Connecting to websocket", { - url: this.wsUrl(), - }); - // We can only land here with static webSocket - webSocket = await this.connectWebSocketAsync(); - await this.registerSessionAsync(webSocket); - - TrueNasIntegration.webSocketMap.set(this.integration.id, webSocket); - await this.authenticateWebSocketAsync(); - } - - return await new Promise((resolve, reject) => { - const id = createId(); - const handler = (event: MessageEvent) => { - const data = JSON.parse(event.data) as Record; - if (data.msg !== "result") return; - if (data.id !== id) return; - - clearTimeout(timeoutId); - webSocket.removeEventListener("message", handler); - logger.debug("Received method response", { - id, - method, - url: this.wsUrl(), - }); - resolve(data.result); - }; - const timeoutId = setTimeout(() => { - webSocket.removeEventListener("message", handler); - reject( - new RequestError( - { - type: "timeout", - reason: "aborted", - code: "ECONNABORTED", - }, - { cause: new Error("Canceled request after 5 seconds") }, - ), - ); - }, 5000); - - webSocket.addEventListener("message", handler); - - logger.debug("Sending method request", { - id, - method, - url: this.wsUrl(), - }); - - webSocket.send( - JSON.stringify({ - id, - msg: "method", - method, - params, - }), - ); - }); + private getClient(): TrueNasClient { + return (this.client ??= this.createClient()); } - private extractNetworkTrafficData = (data: z.infer, index = 1 | 2) => { + private createClient(): TrueNasClient { + const credentials: TrueNasCredentials = this.hasSecretValue("apiKey") + ? { apiKey: this.getSecretValue("apiKey") } + : { username: this.getSecretValue("username"), password: this.getSecretValue("password") }; + + return new TrueNasClient(this.integration.id, (path) => this.webSocketUrl(path), credentials); + } + + private async requestAsync(method: string, params: unknown[] = []): Promise { + return this.getClient().requestAsync(method, params); + } + + private async getPoolsAsync() { + logger.debug("Retrieving pools", { url: this.integration.url }); + + const response = await this.requestAsync("pool.query", [ + [], + { + extra: { + is_upgraded: true, + }, + }, + ]); + const result = await poolSchema.parseAsync(response); + // Offline / exported pools have null values for allocated, size and free, so we filter them out + // See https://github.com/homarr-labs/homarr/issues/5194 + const activePools = result + .map((pool) => { + if (pool.allocated !== null && pool.size !== null && pool.free !== null) { + return { + ...pool, + allocated: pool.allocated, + size: pool.size, + free: pool.free, + }; + } + + return null; + }) + .filter((pool) => pool !== null); + logger.debug("Retrieved pools", { + url: this.integration.url, + totalCount: result.length, + activeCount: activePools.length, + }); + return activePools; + } + + /** + * Retrieves data using the reporting method + * @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting + */ + private async getReportingAsync(): Promise { + logger.debug("Retrieving reporting data", { url: this.integration.url }); + + const response = await this.requestAsync("reporting.get_data", [ + [ + { + name: "cpu", + }, + { + name: "memory", + }, + { + name: "cputemp", + }, + ], + { + aggregate: true, + start: dayjs().add(-5, "minutes").unix(), + end: dayjs().unix(), + }, + ]); + const result = await z.array(reportingItemSchema).parseAsync(response); + + logger.debug("Retrieved reporting data", { + url: this.integration.url, + count: result.length, + }); + return result; + } + + /** + * Retrieves a list of all available network interfaces + * @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface + */ + private async getNetworkInterfacesAsync(): Promise> { + logger.debug("Retrieving available network-interfaces", { url: this.integration.url }); + + const response = await this.requestAsync("interface.query", [ + [], // no filters + {}, + ]); + const result = await networkInterfaceSchema.parseAsync(response); + + logger.debug("Retrieved available network-interfaces", { + url: this.integration.url, + count: result.length, + }); + return result; + } + + /** + * Retrieves reporting network data of the last 5 minutes + * @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting + */ + private async getReportingNetdataAsync(): Promise> { + const networkInterfaces = await this.getNetworkInterfacesAsync(); + + logger.debug("Retrieving reporting network data", { url: this.integration.url }); + + const response = await this.requestAsync("reporting.netdata_get_data", [ + networkInterfaces.map((networkInterface) => ({ + name: "interface", + identifier: networkInterface.id, + })), + { + start: dayjs().add(-5, "minutes").unix(), + end: dayjs().unix(), + }, + ]); + const result = await reportingNetDataSchema.parseAsync(response); + + logger.debug("Retrieved reporting-network-data", { + url: this.integration.url, + count: result.length, + }); + return result; + } + + /** + * Retrieves information about the system + * @see https://www.truenas.com/docs/api/scale_websocket_api.html#system + */ + private async getSystemInformationAsync(): Promise> { + logger.debug("Retrieving system-information", { url: this.integration.url }); + + const response = await this.requestAsync("system.info"); + const result = await systemInfoSchema.parseAsync(response); + + logger.debug("Retrieved system-information", { url: this.integration.url }); + return result; + } + + private extractNetworkTrafficData = (data: z.infer, index: 1 | 2) => { return data.reduce((acc, current) => acc + (current.data.at(-1)?.at(index) ?? 0), 0); }; - private extractLatestReportingData(data: ReportingItem[], key: ReportingItem["identifier"]) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const dataObject = data.find((item) => item.identifier === key)!; - // TODO: check why the below sorting is done, because right now it compares number[] with number[]? - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return dataObject.data.sort((item1, item2) => (item1 > item2 ? -1 : 1))[0]!; + private extractLatestReportingData(data: ReportingItem[], identifier: ReportingItem["identifier"]) { + const item = data.find((reportingItem) => reportingItem.identifier === identifier); + // Reporting rows are [timestamp, ...values] in ascending time order, so the last row is the most recent reading. + const latest = item?.data.at(-1); + if (!latest) throw new Error(`TrueNAS returned no "${identifier}" reporting data`); + return latest; } } diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index c7e8da42f..e02d6945b 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": { @@ -3711,7 +3714,8 @@ "logs": "Logs", "api": "API", "certificates": "Certificates", - "tasks": "Tasks" + "tasks": "Tasks", + "backup": "Backup" } }, "settings": "Settings", @@ -4251,6 +4255,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 93776dee4..cc40a2757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,38 +61,38 @@ catalogs: specifier: ^1.4.0 version: 1.4.0 '@mantine/charts': - specifier: ^9.2.2 - version: 9.2.2 + specifier: ^9.3.0 + version: 9.3.0 '@mantine/colors-generator': - specifier: ^9.2.2 - version: 9.2.2 + specifier: ^9.3.0 + version: 9.3.0 '@mantine/core': - specifier: ^9.2.2 - version: 9.2.2 + specifier: ^9.3.0 + version: 9.3.0 '@mantine/dates': - specifier: ^9.2.2 - version: 9.2.2 + specifier: ^9.3.0 + version: 9.3.0 '@mantine/dropzone': - specifier: ^9.2.2 - version: 9.2.2 + specifier: ^9.3.0 + version: 9.3.0 '@mantine/form': - specifier: ^9.2.2 - version: 9.2.2 + specifier: ^9.3.0 + version: 9.3.0 '@mantine/hooks': - specifier: ^9.2.2 - version: 9.2.2 + specifier: ^9.3.0 + version: 9.3.0 '@mantine/modals': - specifier: ^9.2.2 - version: 9.2.2 + specifier: ^9.3.0 + version: 9.3.0 '@mantine/notifications': - specifier: ^9.2.2 - version: 9.2.2 + specifier: ^9.3.0 + version: 9.3.0 '@mantine/spotlight': - specifier: ^9.2.2 - version: 9.2.2 + specifier: ^9.3.0 + version: 9.3.0 '@mantine/tiptap': - specifier: ^9.2.2 - version: 9.2.2 + specifier: ^9.3.0 + version: 9.3.0 '@octokit/auth-app': specifier: ^8.2.0 version: 8.2.0 @@ -127,14 +127,14 @@ catalogs: specifier: ^3.44.0 version: 3.44.0 '@tanstack/react-query': - specifier: ^5.100.14 - version: 5.100.14 + specifier: ^5.101.0 + version: 5.101.0 '@tanstack/react-query-devtools': - specifier: ^5.100.14 - version: 5.100.14 + specifier: ^5.101.0 + version: 5.101.0 '@tanstack/react-query-next-experimental': - specifier: ^5.100.14 - version: 5.100.14 + specifier: ^5.101.0 + version: 5.101.0 '@testcontainers/mysql': specifier: ^12.0.1 version: 12.0.1 @@ -145,53 +145,53 @@ catalogs: specifier: ^12.0.1 version: 12.0.1 '@tiptap/extension-color': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/extension-highlight': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/extension-image': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/extension-link': - specifier: ^3.24.0 - version: 3.24.0 + specifier: ^3.25.0 + version: 3.26.0 '@tiptap/extension-placeholder': - specifier: ^3.24.0 - version: 3.24.0 + specifier: ^3.25.0 + version: 3.26.0 '@tiptap/extension-table': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/extension-table-cell': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/extension-table-header': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/extension-table-row': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/extension-task-item': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/extension-task-list': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/extension-text-align': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/extension-text-style': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/extension-underline': - specifier: 3.24.0 - version: 3.24.0 + specifier: 3.25.0 + version: 3.25.0 '@tiptap/react': - specifier: ^3.24.0 - version: 3.24.0 + specifier: ^3.25.0 + version: 3.26.0 '@tiptap/starter-kit': - specifier: ^3.24.0 - version: 3.24.0 + specifier: ^3.25.0 + version: 3.26.0 '@trpc/client': specifier: ^11.17.0 version: 11.17.0 @@ -373,8 +373,8 @@ catalogs: specifier: 5.11.0 version: 5.11.0 isomorphic-dompurify: - specifier: ^3.15.0 - version: 3.15.0 + specifier: ^3.16.0 + version: 3.16.0 jotai: specifier: ^2.20.0 version: 2.20.0 @@ -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 @@ -466,8 +469,8 @@ catalogs: specifier: ^25.0.3 version: 25.0.3 semver-parser: - specifier: ^4.2.0 - version: 4.2.0 + specifier: ^4.2.1 + version: 4.2.1 superjson: specifier: 2.2.6 version: 2.2.6 @@ -538,7 +541,7 @@ catalogs: 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' @@ -549,7 +552,7 @@ overrides: prismjs@<1.30.0: '>=1.30.0' proxmox-api>undici: 7.24.6 react-is: ^19.2.7 - rollup@>=4.0.0 <4.22.4: '>=4.61.0' + 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' @@ -657,7 +660,7 @@ importers: version: 3.2.2(react@19.2.7) '@gfazioli/mantine-onboarding-tour': specifier: 'catalog:' - version: 4.0.4(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 4.0.4(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@homarr/analytics': specifier: workspace:^0.1.0 version: link:../../packages/analytics @@ -753,43 +756,43 @@ importers: version: link:../../packages/widgets '@mantine/colors-generator': specifier: 'catalog:' - version: 9.2.2(chroma-js@3.2.0) + version: 9.3.0(chroma-js@3.2.0) '@mantine/core': specifier: 'catalog:' - version: 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@mantine/dropzone': specifier: 'catalog:' - version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@mantine/hooks': specifier: 'catalog:' - version: 9.2.2(react@19.2.7) + version: 9.3.0(react@19.2.7) '@mantine/modals': specifier: 'catalog:' - version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@mantine/tiptap': specifier: 'catalog:' - version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(@tiptap/extension-link@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@tiptap/react@3.24.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tiptap/extension-link@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))(@tiptap/react@3.26.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) '@tanstack/react-query': specifier: 'catalog:' - version: 5.100.14(react@19.2.7) + version: 5.101.0(react@19.2.7) '@tanstack/react-query-devtools': specifier: 'catalog:' - version: 5.100.14(@tanstack/react-query@5.100.14(react@19.2.7))(react@19.2.7) + version: 5.101.0(@tanstack/react-query@5.101.0(react@19.2.7))(react@19.2.7) '@tanstack/react-query-next-experimental': specifier: 'catalog:' - version: 5.100.14(@tanstack/react-query@5.100.14(react@19.2.7))(next@16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7) + version: 5.101.0(@tanstack/react-query@5.101.0(react@19.2.7))(next@16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7) '@trpc/client': specifier: 'catalog:' version: 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3) '@trpc/next': specifier: 'catalog:' - version: 11.17.0(@tanstack/react-query@5.100.14(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.17.0(@tanstack/react-query@5.100.14(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(next@16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3) + version: 11.17.0(@tanstack/react-query@5.101.0(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.17.0(@tanstack/react-query@5.101.0(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(next@16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3) '@trpc/react-query': specifier: 'catalog:' - version: 11.17.0(@tanstack/react-query@5.100.14(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3) + version: 11.17.0(@tanstack/react-query@5.101.0(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3) '@trpc/server': specifier: 'catalog:' version: 11.17.0(typescript@5.9.3) @@ -802,9 +805,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 @@ -817,6 +826,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 @@ -825,7 +837,7 @@ importers: version: 13.0.6 isomorphic-dompurify: specifier: 'catalog:' - version: 3.15.0(@noble/hashes@2.0.1) + version: 3.16.0(@noble/hashes@2.0.1) jotai: specifier: 'catalog:' version: 2.20.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.16)(react@19.2.7) @@ -837,7 +849,7 @@ importers: version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) mantine-react-table: specifier: 'catalog:' - version: mantine-react-table-open@9.0.3(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: mantine-react-table-open@9.0.3(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next: specifier: 'catalog:' version: 16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0) @@ -862,6 +874,9 @@ importers: sass: specifier: 'catalog:' version: 1.100.0 + sql.js: + specifier: 'catalog:' + version: 1.14.1 superjson: specifier: 'catalog:' version: 2.2.6 @@ -881,6 +896,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 @@ -1141,19 +1162,19 @@ importers: version: 1.4.0 '@tanstack/react-query': specifier: 'catalog:' - version: 5.100.14(react@19.2.7) + version: 5.101.0(react@19.2.7) '@trpc/client': specifier: 'catalog:' version: 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3) '@trpc/react-query': specifier: 'catalog:' - version: 11.17.0(@tanstack/react-query@5.100.14(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3) + version: 11.17.0(@tanstack/react-query@5.101.0(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3) '@trpc/server': specifier: 'catalog:' version: 11.17.0(typescript@5.9.3) '@trpc/tanstack-react-query': specifier: 'catalog:' - version: 11.17.0(@tanstack/react-query@5.100.14(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3) + version: 11.17.0(@tanstack/react-query@5.101.0(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3) lodash.clonedeep: specifier: 'catalog:' version: 4.5.0 @@ -1350,7 +1371,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 @@ -1448,7 +1469,7 @@ importers: version: link:../validation semver-parser: specifier: 'catalog:' - version: 4.2.0 + version: 4.2.1 zod: specifier: 'catalog:' version: 4.4.3 @@ -1501,7 +1522,7 @@ importers: version: link:../server-settings '@mantine/core': specifier: 'catalog:' - version: 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@paralleldrive/cuid2': specifier: 'catalog:' version: 3.3.0 @@ -1522,10 +1543,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) @@ -1615,7 +1636,7 @@ importers: version: link:../validation '@mantine/form': specifier: 'catalog:' - version: 9.2.2(react@19.2.7) + version: 9.3.0(react@19.2.7) zod: specifier: 'catalog:' version: 4.4.3 @@ -1655,7 +1676,7 @@ importers: version: link:../validation '@mantine/core': specifier: 'catalog:' - version: 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1785,6 +1806,9 @@ importers: undici: specifier: 'catalog:' version: 7.24.6 + ws: + specifier: 'catalog:' + version: 8.21.0 xml2js: specifier: 'catalog:' version: 0.6.2 @@ -1798,6 +1822,9 @@ importers: '@types/node-unifi': specifier: 'catalog:' version: 2.5.1(patch_hash=5e6ae51e2a17a7f9729bfa30b0eb3d0842a5810ac6db47603ab4a6efa1ed84c5) + '@types/ws': + specifier: 'catalog:' + version: 8.18.1 '@types/xml2js': specifier: 'catalog:' version: 0.4.14 @@ -1815,10 +1842,10 @@ importers: version: link:../ui '@mantine/core': specifier: 'catalog:' - version: 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@mantine/hooks': specifier: 'catalog:' - version: 9.2.2(react@19.2.7) + version: 9.3.0(react@19.2.7) react: specifier: 'catalog:' version: 19.2.7 @@ -1867,7 +1894,7 @@ importers: version: link:../validation '@mantine/core': specifier: 'catalog:' - version: 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) @@ -1901,7 +1928,7 @@ importers: version: link:../ui '@mantine/notifications': specifier: 'catalog:' - version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) @@ -1950,10 +1977,10 @@ importers: version: link:../validation '@mantine/core': specifier: 'catalog:' - version: 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@mantine/hooks': specifier: 'catalog:' - version: 9.2.2(react@19.2.7) + version: 9.3.0(react@19.2.7) adm-zip: specifier: 'catalog:' version: 0.5.17 @@ -2121,7 +2148,7 @@ importers: version: link:../server-settings '@mantine/dates': specifier: 'catalog:' - version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next: specifier: 'catalog:' version: 16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0) @@ -2176,13 +2203,13 @@ importers: version: link:../ui '@mantine/core': specifier: 'catalog:' - version: 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@mantine/hooks': specifier: 'catalog:' - version: 9.2.2(react@19.2.7) + version: 9.3.0(react@19.2.7) '@mantine/spotlight': specifier: 'catalog:' - version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) @@ -2225,7 +2252,7 @@ importers: version: 4.3.1 mantine-react-table: specifier: 'catalog:' - version: mantine-react-table-open@9.0.3(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: mantine-react-table-open@9.0.3(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next: specifier: 'catalog:' version: 16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0) @@ -2265,13 +2292,13 @@ importers: version: link:../validation '@mantine/core': specifier: 'catalog:' - version: 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@mantine/dates': specifier: 'catalog:' - version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@mantine/hooks': specifier: 'catalog:' - version: 9.2.2(react@19.2.7) + version: 9.3.0(react@19.2.7) '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) @@ -2280,7 +2307,7 @@ importers: version: 4.2.0 mantine-react-table: specifier: 'catalog:' - version: mantine-react-table-open@9.0.3(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: mantine-react-table-open@9.0.3(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next: specifier: 'catalog:' version: 16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0) @@ -2402,67 +2429,67 @@ importers: version: link:../validation '@mantine/charts': specifier: 'catalog:' - version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(recharts@3.8.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)) + version: 9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(recharts@3.8.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)) '@mantine/core': specifier: 'catalog:' - version: 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@mantine/form': specifier: 'catalog:' - version: 9.2.2(react@19.2.7) + version: 9.3.0(react@19.2.7) '@mantine/hooks': specifier: 'catalog:' - version: 9.2.2(react@19.2.7) + version: 9.3.0(react@19.2.7) '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) '@tiptap/extension-color': specifier: 'catalog:' - version: 3.24.0(@tiptap/extension-text-style@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))) + version: 3.25.0(@tiptap/extension-text-style@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))) '@tiptap/extension-highlight': specifier: 'catalog:' - version: 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + version: 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) '@tiptap/extension-image': specifier: 'catalog:' - version: 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + version: 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) '@tiptap/extension-link': specifier: 'catalog:' - version: 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) '@tiptap/extension-placeholder': specifier: 'catalog:' - version: 3.24.0(@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + version: 3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) '@tiptap/extension-table': specifier: 'catalog:' - version: 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + version: 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) '@tiptap/extension-table-cell': specifier: 'catalog:' - version: 3.24.0(@tiptap/extension-table@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + version: 3.25.0(@tiptap/extension-table@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) '@tiptap/extension-table-header': specifier: 'catalog:' - version: 3.24.0(@tiptap/extension-table@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + version: 3.25.0(@tiptap/extension-table@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) '@tiptap/extension-table-row': specifier: 'catalog:' - version: 3.24.0(@tiptap/extension-table@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + version: 3.25.0(@tiptap/extension-table@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) '@tiptap/extension-task-item': specifier: 'catalog:' - version: 3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + version: 3.25.0(@tiptap/extension-list@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) '@tiptap/extension-task-list': specifier: 'catalog:' - version: 3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) + version: 3.25.0(@tiptap/extension-list@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) '@tiptap/extension-text-align': specifier: 'catalog:' - version: 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + version: 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) '@tiptap/extension-text-style': specifier: 'catalog:' - version: 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + version: 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) '@tiptap/extension-underline': specifier: 'catalog:' - version: 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + version: 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) '@tiptap/react': specifier: 'catalog:' - version: 3.24.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 3.26.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tiptap/starter-kit': specifier: 'catalog:' - version: 3.24.0 + version: 3.26.0 clsx: specifier: 'catalog:' version: 2.1.1 @@ -2477,7 +2504,7 @@ importers: version: 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) mantine-react-table: specifier: 'catalog:' - version: mantine-react-table-open@9.0.3(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: mantine-react-table-open@9.0.3(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next: specifier: 'catalog:' version: 16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0) @@ -3763,88 +3790,88 @@ packages: '@libsql/core@0.14.0': resolution: {integrity: sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q==} - '@mantine/charts@9.2.2': - resolution: {integrity: sha512-F152Lf3f6H62mwzihBMobjizj0ShdL6VasqI0DjPaCmw39NmI1xHdaA2ydp8YI6TwZFCWVQHR5psPpaMdTHBCA==} + '@mantine/charts@9.3.0': + resolution: {integrity: sha512-ofqpHVEPbzebnLziFluWc0DF5QEPan3WdeAQxRyY0eZ5YZoulAwEq5nMhClQ+haEladVvfoovOuqS4jozWMFNw==} peerDependencies: - '@mantine/core': 9.2.2 - '@mantine/hooks': 9.2.2 + '@mantine/core': 9.3.0 + '@mantine/hooks': 9.3.0 react: ^19.2.0 react-dom: ^19.2.0 recharts: '>=3.2.1' - '@mantine/colors-generator@9.2.2': - resolution: {integrity: sha512-13Tv1TTDJ1IaneYY2S5uqZ4ICq75k8D/lTyVEFQSB5yDU7npBIetrVG2mT8wJjpYJz4CHZmINxxv0abP5N3p1w==} + '@mantine/colors-generator@9.3.0': + resolution: {integrity: sha512-pm0jIqW4r0nq91pAZPf3ANNtPRY+V0Op0XZSqEwXGIv5FbNqQAb6Xqc6hJShXynanORlHTdtdwzveYZKDhFF9Q==} peerDependencies: chroma-js: '>=2.4.2' - '@mantine/core@9.2.2': - resolution: {integrity: sha512-j4d7dwKkrYPP9Lb7ut1u0OodwmosIaKK4azK4GMoTKq1+7TKRrJPxB1vkDjkbOcd+jbUt0qn6EkJdsSMKVcIIw==} + '@mantine/core@9.3.0': + resolution: {integrity: sha512-mHVCm61YVW9ipy9eHiKMqsRUm3TkOErbdw7zHs0HRw5g403nf7tSTqNGvaYE+aX1Py874qMkrUzeQfj4bjiiBA==} peerDependencies: - '@mantine/hooks': 9.2.2 + '@mantine/hooks': 9.3.0 react: ^19.2.0 react-dom: ^19.2.0 - '@mantine/dates@9.2.2': - resolution: {integrity: sha512-6/O5f/u2NjJLKoLvZmSJCo6qQqPg627XjfpeUYqOx/h9I2W36eVaKghM4YObPpfkPZMIojaa3iz82Nw3lmZO3w==} + '@mantine/dates@9.3.0': + resolution: {integrity: sha512-THGHMi32EqhyU46i6d5wRAWrWKJ21MnOO/eZ9u/SwkgZBDbkzduouLYSrBprAPhD68dflz15nGCfVZ0rYSgT0w==} peerDependencies: - '@mantine/core': 9.2.2 - '@mantine/hooks': 9.2.2 + '@mantine/core': 9.3.0 + '@mantine/hooks': 9.3.0 dayjs: '>=1.0.0' react: ^19.2.0 react-dom: ^19.2.0 - '@mantine/dropzone@9.2.2': - resolution: {integrity: sha512-mYpaOfCrrOO1CytVbBaAxVRntHmB+YhjFlu1SGIIu9eoPr1pKOopvhVg58iiDG+xPIAzAH6zySEGRsTvPN61bw==} + '@mantine/dropzone@9.3.0': + resolution: {integrity: sha512-YnAO0+HGnHTSsQ44DJC5bWF4kcnuAiVUVottTjZJ1nsWnoagE+W5/XFWiFMtK0aep4btRFS8EHdoFai7YIFkvA==} peerDependencies: - '@mantine/core': 9.2.2 - '@mantine/hooks': 9.2.2 + '@mantine/core': 9.3.0 + '@mantine/hooks': 9.3.0 react: ^19.2.0 react-dom: ^19.2.0 - '@mantine/form@9.2.2': - resolution: {integrity: sha512-Mfw4XgNZdd67AwLbRU540RiXybZ2vj1UDFh+ky2ebZwieTwP+kd2ePSQQjEp7hUnvby06GXPByVrg8eNGSq67w==} + '@mantine/form@9.3.0': + resolution: {integrity: sha512-1jFQgz1XURfNw0v+/h+T4d05+4gxrRk9C+EgrNNz+7/2Hg7y7WQRXrQxcYvxmp8/k0bv659i2ZyJYj3neA8qjg==} peerDependencies: react: ^19.2.0 - '@mantine/hooks@9.2.2': - resolution: {integrity: sha512-E0eKEMv47Xo+QVKYj2buzLJURcO/Qfb12EvM4mjKlXPuTqYhl+Fvg7Sg+xHmta8iTtBvo/5G+iNeqc4pfs8eaA==} + '@mantine/hooks@9.3.0': + resolution: {integrity: sha512-QoSr9WI4WsKWrM3qFYYizHUn3+n+CVcFMYe4sdlnmFPStvs6BacPODKJSbFlYl73Z20t82JIy0eKqt4noHQI2g==} peerDependencies: react: ^19.2.0 - '@mantine/modals@9.2.2': - resolution: {integrity: sha512-OiLHaDrHw7ArCBXzxap+lsMdGwLiyOWD+HQ9pqwwyczmvwcgVgqcFi4gq4eOs8GGZc/e6WaQ0uKbYvlVBgK5wA==} + '@mantine/modals@9.3.0': + resolution: {integrity: sha512-Hnj7Mm89VW4kTnHrzaB3UARZg3qgaRr0jvSqSvwJMdgpY6WykyqLKeMyFE+7YQNq44gQ46yV3Q3VOyZsHchaew==} peerDependencies: - '@mantine/core': 9.2.2 - '@mantine/hooks': 9.2.2 + '@mantine/core': 9.3.0 + '@mantine/hooks': 9.3.0 react: ^19.2.0 react-dom: ^19.2.0 - '@mantine/notifications@9.2.2': - resolution: {integrity: sha512-7pNVA1wy+QJmzQiXXP65o9pzg/NLpLNU9wE0nLU2chjpvuqvfu1KP1lOB3097rebLxYmmrE25Dn4hxfSrHwCmw==} + '@mantine/notifications@9.3.0': + resolution: {integrity: sha512-ikkgAVGccbyU/WLru6b2cZsVqky8iEvduCPAPBnSPd5UQBEjcZr11F0LFUGAHwBM94InDzdf4HQiSBMG2lXRRw==} peerDependencies: - '@mantine/core': 9.2.2 - '@mantine/hooks': 9.2.2 + '@mantine/core': 9.3.0 + '@mantine/hooks': 9.3.0 react: ^19.2.0 react-dom: ^19.2.0 - '@mantine/spotlight@9.2.2': - resolution: {integrity: sha512-kgXAu5ODsduuieraRWUtfjGLnxDssvk5WhrWUxlVcDTaBjP+PdeXvkSCzBR73mShEvRidBxKml7Ru9Sx7OBK9g==} + '@mantine/spotlight@9.3.0': + resolution: {integrity: sha512-l8TRLjSLwilTQwzvZ8AS2L0Xwj1lTIjynXiegbZOKiUk0ga5yYhj5w77jsxGX9sy+LTYmkqYwRGhnSUujKB6Jg==} peerDependencies: - '@mantine/core': 9.2.2 - '@mantine/hooks': 9.2.2 + '@mantine/core': 9.3.0 + '@mantine/hooks': 9.3.0 react: ^19.2.0 react-dom: ^19.2.0 - '@mantine/store@9.2.2': - resolution: {integrity: sha512-ZzS1yERapPURUG1vs7G+lD+oc8AZKissP1eUzefpyOAgEoEJXjOckc5/voiik3dcc/WP4AE9GQsT3Z4cS27E/A==} + '@mantine/store@9.3.0': + resolution: {integrity: sha512-hTSXBRCPiQeVcoYRTAOtlSPFHcD6Iexf1Sbhjk/Ne5k+LUdeYAaDOZHdyljepO0+8HVavR31ZvkNCQXuoV793w==} peerDependencies: react: ^19.2.0 - '@mantine/tiptap@9.2.2': - resolution: {integrity: sha512-+Ki/+TVSATME+3V4zpAEToHvEcJilE2RNZwo183wYNaHTsdn6WgGOHsUrdPV0LSV8AajZwBBIE5mJaStVVZXvg==} + '@mantine/tiptap@9.3.0': + resolution: {integrity: sha512-NO7fe901b0eF6UoxGy6qhMyZMdcV2d548HLnA4FM/UD0WY4VIThslo5FoxsUQc6JhUbNczAvgibPd4PGjaczbw==} peerDependencies: - '@mantine/core': 9.2.2 - '@mantine/hooks': 9.2.2 + '@mantine/core': 9.3.0 + '@mantine/hooks': 9.3.0 '@tiptap/extension-link': '>=3.3.0' '@tiptap/react': '>=3.3.0' react: ^19.2.0 @@ -4909,27 +4936,27 @@ packages: resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==} engines: {node: '>=12'} - '@tanstack/query-core@5.100.14': - resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==} + '@tanstack/query-core@5.101.0': + resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} - '@tanstack/query-devtools@5.100.14': - resolution: {integrity: sha512-g96SmSSQecYTYcyuAMRXr895GplJv01UGt7qttQWPOUyZ5EGz5tbRc589bMc2m5BsPFD6O0PCEAHdbDYNP6UBw==} + '@tanstack/query-devtools@5.101.0': + resolution: {integrity: sha512-MVqw17k08RQtGGLEL654+dX/btbX9p/8WjkznO//zusLTMaObxi3Q+MoFwGVkC9K3tqjn8qrrNhJevXx4fJTeQ==} - '@tanstack/react-query-devtools@5.100.14': - resolution: {integrity: sha512-JkP5VDgKOw3t/QSA1OABRHEqx8BuNs5MfvZRooNqdvN57SzTuGq3fKR1a2IH5rqa5HDLUm+FOXUEnB9ueHiLzg==} + '@tanstack/react-query-devtools@5.101.0': + resolution: {integrity: sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==} peerDependencies: - '@tanstack/react-query': ^5.100.14 + '@tanstack/react-query': ^5.101.0 react: ^18 || ^19 - '@tanstack/react-query-next-experimental@5.100.14': - resolution: {integrity: sha512-/0uuRT7cXRJEifEuH3F5twngMHlznJs9nl62PMBvQJhgs5y7sA4GugElszXTStsgS3/TlkkfjWhSFDlIgEfiAg==} + '@tanstack/react-query-next-experimental@5.101.0': + resolution: {integrity: sha512-YzNMm2v+wfPMYsKoD06MLdtDpXcI6AGDRTHxvZurXA2ozBEABQzaBzcMFduriytyMPz1bnkzqcTJ+mcLCejMZA==} peerDependencies: - '@tanstack/react-query': ^5.100.14 + '@tanstack/react-query': ^5.101.0 next: ^13 || ^14 || ^15 || ^16 react: ^18 || ^19 - '@tanstack/react-query@5.100.14': - resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==} + '@tanstack/react-query@5.101.0': + resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} peerDependencies: react: ^18 || ^19 @@ -4962,215 +4989,226 @@ packages: '@testcontainers/redis@12.0.1': resolution: {integrity: sha512-x5iDR6Y2Mc+2rTJYSQlRTtBMCPt77pKxDogpqRwXgJVmWoDrWFpiFcA/dw6IEoNuyxYUg03tbS8/VzCJI5JX6A==} - '@tiptap/core@3.24.0': - resolution: {integrity: sha512-GTAsXAI32p4hEZgPzvUv2RPrObxamy9AFhmhG10fXSvN/cDUs8naEYVIqDV3Sh99jMwQEbTFKW1E1mcspsY6ow==} + '@tiptap/core@3.26.0': + resolution: {integrity: sha512-7jTed/RirIVsp+lLdLvGzGqF3EBGpnGHGYKOwz6t28V2BIJLAFdUhfEVdWie7xPxQNWK0TP+fPlsqZS0vxfHBg==} peerDependencies: - '@tiptap/pm': 3.24.0 + '@tiptap/pm': 3.26.0 - '@tiptap/extension-blockquote@3.24.0': - resolution: {integrity: sha512-DgwEEJ1GbDQcT054ynxoaZGmB9apGeUklPrinq9o6xdLHpdg+bO9HCQzggdB8n21VLLglb8jfAEWsVNwh3eASQ==} + '@tiptap/extension-blockquote@3.26.0': + resolution: {integrity: sha512-57accpka9affjiJRjP2LMNCDJDTMjTvO23RJCxtP43sp9cTIZ7YZnyDfRxCINTRBNK0X4o4w2+emOLyRwsk3CA==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.26.0 - '@tiptap/extension-bold@3.24.0': - resolution: {integrity: sha512-CujogYaynasklFKHADUseuvj8X2FnWktTCCo3Hl+nlyRvBTmm5TK2aqiamg3v2P4dBh3O6a70mo8BfRJPuiR1g==} + '@tiptap/extension-bold@3.26.0': + resolution: {integrity: sha512-j6CzTMofcGJ5iMoUgDRQpM0FkG00jBID3aKqs+UBbgtzLgtG/CI/91tMFv0XPC30LeFA895qYgvGZtHdejZhiQ==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.26.0 - '@tiptap/extension-bubble-menu@3.24.0': - resolution: {integrity: sha512-jRXD+JPu9ayvq78g8hsCxx4q/qUFtrdfIYirRSf5YUseuuUbtfrq83AsGabcygpUTefjJkMQoXNITkh6294Ggw==} + '@tiptap/extension-bubble-menu@3.26.0': + resolution: {integrity: sha512-H2E3Hp0lV79jQV8YGtdDJkXkUalXZeYzKCx+vCZlDpb2ChS7/rNT9YY7poRA1NlJLUO0DH1wbAnFhx9KZMUx5g==} peerDependencies: - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 - '@tiptap/extension-bullet-list@3.24.0': - resolution: {integrity: sha512-IOpAm5c4XVVVvkOef+V9XYMVpea+3MgBpCQgn83UQRlwO9eIMwmcyxOznu7gQPQVShTEpkt4T6uK+ZN9o8meIA==} + '@tiptap/extension-bullet-list@3.26.0': + resolution: {integrity: sha512-Jv7BX+kBB2wUIvO/NhuUjv+T3kAed2Tjr664fgQ2zKT6X69jKIkYuCCedrIHuOyaOQ+SBDuH9h51wYv/E97QgQ==} peerDependencies: - '@tiptap/extension-list': 3.24.0 + '@tiptap/extension-list': 3.26.0 - '@tiptap/extension-code-block@3.24.0': - resolution: {integrity: sha512-NZglw4oHoH6oJ5+HvxxQCYk+wODJmsxzUpRQdsOmje08sekQH+Zt9i4UKimBhg4urpd5r+dKXTslab9a5eQ86w==} + '@tiptap/extension-code-block@3.26.0': + resolution: {integrity: sha512-WPN9iZ3UjeDD2ckDzSs9tleibXv0cLj7j575NxuvjhwZTehYGNeYDSUTi+6DQUG6bKbhGg9Wcei5H0131vvJHg==} peerDependencies: - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 - '@tiptap/extension-code@3.24.0': - resolution: {integrity: sha512-MAQtrPRQ+HRmcGotWbksdIGeH1gqayFAdvi4lNGeFT7taHXP1o1XD7CQp7iYIKmg8IU4/MQ+RdetSfuC1A9edQ==} + '@tiptap/extension-code@3.26.0': + resolution: {integrity: sha512-VJYcV6rvjnENRTroOi9tDcHWW6G0pmCoRETwatlbgfDzuCmkTOwVwQjeJCXOVMMLNPzNiXZzibsRCUt+Azq/jw==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.26.0 - '@tiptap/extension-color@3.24.0': - resolution: {integrity: sha512-gVv9zfQ9diq/sk+SVJzqadShsOtDK1XPojBZR1zXg1QxedzdwLKuRDxvnlfIc3kFIyaDP4mNrek+anednTbWBA==} + '@tiptap/extension-color@3.25.0': + resolution: {integrity: sha512-eBrxTDD9vKrrN5VPyvSRlwM9ApADSBqkCW8fA7Y3iedmIOAFb+iySv5LIh57tcnIL0e62LRyiXwTWpaxEfJFbw==} peerDependencies: - '@tiptap/extension-text-style': 3.24.0 + '@tiptap/extension-text-style': 3.25.0 - '@tiptap/extension-document@3.24.0': - resolution: {integrity: sha512-yxgM3+yXy2XZzEwH43y2Kp8D1BkblxEWLXqo0YCoAKtxyKCcEaT8kdlf70kS7D0+VSzYU4D0iN7VdQIYHcL2mA==} + '@tiptap/extension-document@3.26.0': + resolution: {integrity: sha512-Xhd6DCjaxCN4otQNvV6qra+XuoIjk6Vyjm87E5xn5Y/BMw7UGAG7LTkk3C2IEvxKrVZwJjalfxEqdHOgXQzVfw==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.26.0 - '@tiptap/extension-dropcursor@3.24.0': - resolution: {integrity: sha512-Dbv1c5LnvG3PT+yEbCNroyOeeUkHq9wcir2pbC7wri7g7d2sCi0+HvKH0MAxLwY3j5NJJSiSyG2ypMaXOAs4sg==} + '@tiptap/extension-dropcursor@3.26.0': + resolution: {integrity: sha512-rhAtp5J/YVDUCUIc5T7b0XY9dLeuI72JgOr53w0QQc0VA0uwbfTn7sx0LI9PDCE9uwmDH8H3snVRZRnAvlM8oA==} peerDependencies: - '@tiptap/extensions': 3.24.0 + '@tiptap/extensions': 3.26.0 - '@tiptap/extension-floating-menu@3.24.0': - resolution: {integrity: sha512-7QEbf3mUzFAkejjQGX9f0L507oMtnOBRwHt2skUTR+9yXgudsN8zaDBSSRHLeMWGk9b7L293ZMA6zCRrZaHrfA==} + '@tiptap/extension-floating-menu@3.26.0': + resolution: {integrity: sha512-reQ77NRYAOP7iPudsNbzLBuBTdL2aGxZzjccUFmE2lNdmwP23n9A/JhkuUhshVBs/6IozvahI+smG3Bnea0TCQ==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 - '@tiptap/extension-gapcursor@3.24.0': - resolution: {integrity: sha512-CzCP5/jni5RFwW9jCfBO6auh83GbaioMTpSk6tyR3sd+CbwlBcUdsJFGJkbaRdiSS9dgIyi+6hRbhjpYdHcp+w==} + '@tiptap/extension-gapcursor@3.26.0': + resolution: {integrity: sha512-SIe68SDwx2fozt/XKG0FhCwzz/yRN6Bvo4D5TqvfDg6NK3PQb1DS4GN9PilmJqbY+kXryuiWEEJOWi7HpO8SuQ==} peerDependencies: - '@tiptap/extensions': 3.24.0 + '@tiptap/extensions': 3.26.0 - '@tiptap/extension-hard-break@3.24.0': - resolution: {integrity: sha512-T/ZEBiHQPMyTqDvXG0tiqBToNeuSemIPmNtdoGSgBN/degVl7VJZqQIrLIvOUHfjf3QkRs7TE/mcqTJsIboO/g==} + '@tiptap/extension-hard-break@3.26.0': + resolution: {integrity: sha512-baXvv/rtOTVd2Axjb7Zbb41Y9Qmy3U2fP7EHqLuhViqGxVX8LwQtP0PHUXEZkPokbBpRez10+dmOlvvsYFKAZQ==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.26.0 - '@tiptap/extension-heading@3.24.0': - resolution: {integrity: sha512-GCSgapIzQPqEGNcVGE0/Pcjg5wITMLYJlrS3GGVw7BPmECJwgexcoOsEwkxtzJnXT/HpFXbvOFW43sM0KeHSjg==} + '@tiptap/extension-heading@3.26.0': + resolution: {integrity: sha512-qenEQEgzE5FjQay/H6iKOnwIt6DPO27cS+v0mGhXmrL1MjrNER4X0ZkATJbVd0WA6ffsAGaP44NKYDworGeidw==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.26.0 - '@tiptap/extension-highlight@3.24.0': - resolution: {integrity: sha512-+Vx6YKSP+lzCBiTbgQOpR2rIOzL8Ns6UnOBtW4O6tYc7TMTexycaG66RWxXMpAD6QkamwLwX3H5TTYviWtU9RQ==} + '@tiptap/extension-highlight@3.25.0': + resolution: {integrity: sha512-YEtqiiyjYUxdIuSbwC5wvUrdK/hK8uPab70avVmiHlmuav+e8FGShdsKCQ17F9z/ECfwlhr0u9sojYyyhSJXLg==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.25.0 - '@tiptap/extension-horizontal-rule@3.24.0': - resolution: {integrity: sha512-DFzWJTrb23x+qssLLs85vEyho8ItUGp3RY9XUsVTIAGZn5IsoUw8wMsvIBlH1ux4Ch7gLchtcD6kpTdMdrL9kw==} + '@tiptap/extension-horizontal-rule@3.26.0': + resolution: {integrity: sha512-a+N/C4wkQV+/8x4ShdoiC2JdTW3Tw84C5cAloYLFMeaWmRa2me9ACSI+zo0SO9bbH9RJwsoRp7eaxBbk27eF1Q==} peerDependencies: - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 - '@tiptap/extension-image@3.24.0': - resolution: {integrity: sha512-mH+bvsX2cPKuZzV7YMQi4FV2YbDP+Kmq36bY+Bwi/x4mYUc8u0cjQxcu8RzLO7GtsgUJPxGMwfkQxmDqXFLZvw==} + '@tiptap/extension-image@3.25.0': + resolution: {integrity: sha512-LO1W36UovZzs7CqqHo1Fo3/nQDz9UJaV26D+YolqnbMBC2UDxozBPuxxvwkfl7965E3Q/gM8HjdDVM2DcH0VoQ==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.25.0 - '@tiptap/extension-italic@3.24.0': - resolution: {integrity: sha512-mf3cbNlbMPUNj3IyUkIke+o3ZpOUrtVeY5Yqs5IM/VhkUUh/PdIzqw74VuqEAJ0Z4oZ6nNDHeYLrl3Be1j99lQ==} + '@tiptap/extension-italic@3.26.0': + resolution: {integrity: sha512-s8oFpH+0xmhvY19f452/2dExO3p1tjxh761g6cg4irwEUNUEAJKF2VLcjiaeOhNJ+pmnQYxb+VSkwkXvO+7vHQ==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.26.0 - '@tiptap/extension-link@3.24.0': - resolution: {integrity: sha512-MwMoNGG2mL5XGFV1tEGunBRglwsIbW+ZOB2QnKiv+Mcbi2JCWMrorndJZBqpVPR5nM+Bef2KnpchEJmYlQLvKQ==} + '@tiptap/extension-link@3.26.0': + resolution: {integrity: sha512-FA/d157aBxyvZFvsdc5eSu46tmHWXebAsqOQSvivOMyw+deBb00VlMsf+iD2J8+sekjbMYwx/hvbsu+xUoX43Q==} peerDependencies: - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 - '@tiptap/extension-list-item@3.24.0': - resolution: {integrity: sha512-zl/U3viJiV9OzkKM37AHIUN1af1TSLrcbHUUoNLkfJ33Nq+NlpaXpCVK0rKRqiLFJf7zk/a5KWG5CrOy9TxjKA==} + '@tiptap/extension-list-item@3.26.0': + resolution: {integrity: sha512-MccGyj9HY4fkl04eIiFoTCkr8067Jku/VVdJNtRWW104Spx43C/7V2zpbxPvpcDhq3dW384fDxYXfpnb186xLg==} peerDependencies: - '@tiptap/extension-list': 3.24.0 + '@tiptap/extension-list': 3.26.0 - '@tiptap/extension-list-keymap@3.24.0': - resolution: {integrity: sha512-69fKcrngYGEKWNn4R5oLwl0YuV3FY4kufEValVcjnihUmqJTE1vx+fwctYoTsOGnIuNGpUIQ7f9YDD/0w34qBw==} + '@tiptap/extension-list-keymap@3.26.0': + resolution: {integrity: sha512-oBcj6qaNrRHQ+N0+pDuOVAQa4Nx9r8Cm5ANvyM2lTpoy60sOLOizuVvcvw1andVxbSrsZ1N/Sk+RZWyv1uoWyQ==} peerDependencies: - '@tiptap/extension-list': 3.24.0 + '@tiptap/extension-list': 3.26.0 - '@tiptap/extension-list@3.24.0': - resolution: {integrity: sha512-GcxDVMMmDGj7OFTBrV7JpVgr5wxlr2vmjwH7U8QxZX7OJI5vrsMYl/U6KRTvUpG8wP+Zmo5jRlLM+BbL+a/W3g==} + '@tiptap/extension-list@3.25.0': + resolution: {integrity: sha512-bYw4o2YiTdj/tdgktgbMRUfqAJgsnRkwUQTTKElycPdIwlNNs6EQiXku+E2ACftLaFxd3Ek+P50H0AQ5fA/hPw==} peerDependencies: - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.25.0 + '@tiptap/pm': 3.25.0 - '@tiptap/extension-ordered-list@3.24.0': - resolution: {integrity: sha512-buRa6bmBDw0TztH+rAcusIye14DiLDS+yGheo6GiNCTD7kKJnksXagBdxvip3jhW5sx7gyAKvoBmvGSg1BbsGA==} + '@tiptap/extension-list@3.26.0': + resolution: {integrity: sha512-EM8woyHDNKLEQ+lWUEoDtA4KrwP6fei/mYX1NxseMzKHHo7LFecx7wk6sovAXZrUvdML/yFBihgiMiO5VIsfkg==} peerDependencies: - '@tiptap/extension-list': 3.24.0 + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 - '@tiptap/extension-paragraph@3.24.0': - resolution: {integrity: sha512-wD06aB6hO7LgcrlhGiw7I64k2tus9kNoICX5R+UecBSB1DVJdzKvXoXL2kPNv4DqYvljHdkIeK/OpuOTQd6MJA==} + '@tiptap/extension-ordered-list@3.26.0': + resolution: {integrity: sha512-ItLdFlcMsJz2vhbs1PcUfcN7nzVqGBOwPeCrrWxjrgscp+K3JoOGD+HhVVpBACOMwivUrlh8Ry5Ohvues2nOeA==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/extension-list': 3.26.0 - '@tiptap/extension-placeholder@3.24.0': - resolution: {integrity: sha512-3jfYYCIuwMADhvZ92vB6c80YiTmgTSFR23JqyLps8qkQtV59Va5CBYpwJtSs1+VrbCVnNxhZBHhVXusZO3uRkA==} + '@tiptap/extension-paragraph@3.26.0': + resolution: {integrity: sha512-h8fYLikg4qN39IghQ1y9g+zzUsgxBpDi5YS3IZbWoxWYYx1YqLL8nAvOiPr7Us14aQ0TjA2/xY7zqmyf29rX1A==} peerDependencies: - '@tiptap/extensions': 3.24.0 + '@tiptap/core': 3.26.0 - '@tiptap/extension-strike@3.24.0': - resolution: {integrity: sha512-sfN1iQs6Fdlorrfe8wipDkTPwu/Egx3s2fkY7TAWusTGFHwlovuRUGFKqCL9dI4N3u6uqUMpEuWmQNgv+aQGjQ==} + '@tiptap/extension-placeholder@3.26.0': + resolution: {integrity: sha512-q9RsGWmL0xEwrMK5Wx53eMdZlkA3J1cqloIc69rEugjOCjoExEkfF4e2nC9YK49qGxJZBKKksw1Ij7oCWEyu+g==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/extensions': 3.26.0 - '@tiptap/extension-table-cell@3.24.0': - resolution: {integrity: sha512-jg62iVc1L8tOycSuwZ5i82EGKGuvelCIkpFX7wvHMl9EiPX9fv70enmb4ua9DyLgRtYg/MNrGOdUATRpxWsFRA==} + '@tiptap/extension-strike@3.26.0': + resolution: {integrity: sha512-jUll3Pqhq7u1JKvO0B6USW/bmVmUsO6sRcxo/d5tXqLhS0tWAobOGoGU2IgwXnQDSjf+vF73RYD5tRGDLkRC9Q==} peerDependencies: - '@tiptap/extension-table': 3.24.0 + '@tiptap/core': 3.26.0 - '@tiptap/extension-table-header@3.24.0': - resolution: {integrity: sha512-z1TGVoBD5lVX48hpKcrQY5vETB8aCR61Enk5wGl45zNO/sMEMNxYfDKTkMydyOFUWfGqvOPnjDHJqJuT36/19w==} + '@tiptap/extension-table-cell@3.25.0': + resolution: {integrity: sha512-o0pSlv9wvQGVqNOX94IN9CIk23RlK9pEUAMNFXJHXFjcQwsWKOHAX6bS8Srs6nm5XWwS5Va0Yushx6XzajpFGA==} peerDependencies: - '@tiptap/extension-table': 3.24.0 + '@tiptap/extension-table': 3.25.0 - '@tiptap/extension-table-row@3.24.0': - resolution: {integrity: sha512-kASEYMWZF9ZqO6DRGz0LiD11c3LPfzOlFiJQVDrOyLP7xkJpHzIAKH0K+8u4aohOziDLn8nz+8I+3xjRS46oGg==} + '@tiptap/extension-table-header@3.25.0': + resolution: {integrity: sha512-RCu3EtRpu+agTShhFd/m1H8yeTagrjXJXGGFJAFNAhlqdWb8YlK3pttdKW/u//H272yKFgNQJpxUPfTRxk1Egg==} peerDependencies: - '@tiptap/extension-table': 3.24.0 + '@tiptap/extension-table': 3.25.0 - '@tiptap/extension-table@3.24.0': - resolution: {integrity: sha512-lr5elob3uJnB+ltgqPDEeVQmIPRx6JoS0I6z93tOgKsI2mIsaw5ErghteeiCTpExdyax7aWR0fn5pZzLVDQL8A==} + '@tiptap/extension-table-row@3.25.0': + resolution: {integrity: sha512-Sd6EIuLp7XZHKkXbKDE0jjf6NpNXBiVj0VqQu5bn1Oer69+XLfUGKXlrqASLJahXHcogLyfAB9ow1CFlwJfw8w==} peerDependencies: - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/extension-table': 3.25.0 - '@tiptap/extension-task-item@3.24.0': - resolution: {integrity: sha512-2VtX6VOp4SJk5E/VHeSC2Acfbge6h69TUBhF7hFebbiCDyB73xCaPKcuClIyzOlWJgpWz2ZPXwVB+Y3meXw+yw==} + '@tiptap/extension-table@3.25.0': + resolution: {integrity: sha512-at1XYnO0b3OyZjx7ZaIKYRvkjTjHtI/xb+LstN51TrHInf+cc8b10EGWHbQztBPaEmg9t3JC/tNYjzCYy+874g==} peerDependencies: - '@tiptap/extension-list': 3.24.0 + '@tiptap/core': 3.25.0 + '@tiptap/pm': 3.25.0 - '@tiptap/extension-task-list@3.24.0': - resolution: {integrity: sha512-EIbB/WQ7L92WfaY5AF9upH93nIgBHjGt5zwPYoX+ZTpBncebr1AEDF7rc+LHNuSa1g1ToOKbFB88ep02IIXOjA==} + '@tiptap/extension-task-item@3.25.0': + resolution: {integrity: sha512-SLneMmcXltJUjuyxbthr3glCXygkBvU4OsMXWNIVvh3/oE/PDp06i6sbFLUmeBl+LJGgWBNTj2DBM2BGY7PUSg==} peerDependencies: - '@tiptap/extension-list': 3.24.0 + '@tiptap/extension-list': 3.25.0 - '@tiptap/extension-text-align@3.24.0': - resolution: {integrity: sha512-WKFtYXGthtkUc+Cwy2fItSr+9FKwLZjkJVAY1GhkRdcq35qTuVhkb4Q4wR2Rhkb6QRqtlxF1NDuTf2vxiQmfBQ==} + '@tiptap/extension-task-list@3.25.0': + resolution: {integrity: sha512-9cP0G7MjmFkoqkmPNRre0CklkMreNfYdm63NQJ/ZB7L+27qXuqar0MmFL38EGU+FtbMqNu9DNWvooEL5Q3UL/A==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/extension-list': 3.25.0 - '@tiptap/extension-text-style@3.24.0': - resolution: {integrity: sha512-1Hy+5tFEAsnoLhZ/eqmza4USvFHwMA8haeAdCGlwTeshBrt+nUKTrEsRHidF60cGsRwlTcuqxSkjT94dULgp5Q==} + '@tiptap/extension-text-align@3.25.0': + resolution: {integrity: sha512-/VcaNtcljC4O7hOVTwwekgwef+hiAIwnx/xErYme9oHTeLjlGsN9ZKLRQxcW7kjT9A1SSD/hPGNvQSkQJ2jh3w==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.25.0 - '@tiptap/extension-text@3.24.0': - resolution: {integrity: sha512-Im7keLPEihxm3+LyF+drYCoaOY5hlq35lvHAp/el6M8pJ/scts88HrYpdR1Yc4BtpZBIhfHSyWgPaupI4qwdeg==} + '@tiptap/extension-text-style@3.25.0': + resolution: {integrity: sha512-Oq0Gqa44tl2hEPhtriNyBlFBV2ZAk/1iYBqbUTW4qe0pkoe34yTZN7VQQ47RPZnGR7J3ddh8n67kM/9zoy4f8w==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.25.0 - '@tiptap/extension-underline@3.24.0': - resolution: {integrity: sha512-D4W4X3UMq9dLVIOfPB9+UodQ4eAJ8yDcm8qFWAwq0a15YWH6bnwulCuIdV+U5dEG+yaRxN8haB9GrrID9jmrSA==} + '@tiptap/extension-text@3.26.0': + resolution: {integrity: sha512-yZXdevp3/8omGbb40Z52VfvID+tsRNhPQ1GNUToD56XSr2BjdJyAzAb9rWGgDKgVMUPLgJ26yT0O278RFqOKhA==} peerDependencies: - '@tiptap/core': 3.24.0 + '@tiptap/core': 3.26.0 - '@tiptap/extensions@3.24.0': - resolution: {integrity: sha512-z6gRYzy2ucJp07OQ0F2W07NxyhMTxPYH1ia2eGiQkWax1i56oExpjMsDHP8THWlg8Tb7NnbfKpkfh881EsmofA==} + '@tiptap/extension-underline@3.25.0': + resolution: {integrity: sha512-GTCjXnOhjQ8ipiOrdskMdBqQ8nUnczFWWNJ5IoCkMcEDWviOS14Mr2n6zewjlKjtPoRTzwOpFDQUevSK1SHpJg==} peerDependencies: - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.25.0 - '@tiptap/pm@3.24.0': - resolution: {integrity: sha512-QQP/78ryOZDN99gNBV7dgh69/8AYaOYQYFklq/iR+ZRFaaL3+qqHFvPVJapGkzPdymBgNJ34xjFM8n5pJ4QmMg==} - - '@tiptap/react@3.24.0': - resolution: {integrity: sha512-KxnrlQbzOgA02EMsfuGGHtNhfkJQGqVlQttmQctI9DOl/F3gcaRqg+wNTBY1Fof8yDaZ8Z1LL1F0C05W0o3vUw==} + '@tiptap/extension-underline@3.26.0': + resolution: {integrity: sha512-LlVkivH5cBwov/EMD8BL7ZRcU6YcadiSVIffLW1hyalw9YfhaFzoLxjtWhL7jiU/n2Kg+9dXSZxmV2hTeTwyrQ==} peerDependencies: - '@tiptap/core': 3.24.0 - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0 + + '@tiptap/extensions@3.26.0': + resolution: {integrity: sha512-4wajuqnO2X0+LVvsBjW/xk3/tmdb16bNL939QhicAay4YYqXITeV2v3XJsryzmG4L5GkK1yLxvRGk4aLoxWrnA==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 + + '@tiptap/pm@3.26.0': + resolution: {integrity: sha512-q4RDeWwVrhOL0jJCGRgGxLSdjOYwzQ4h2InURZVhC66433ipcHd6f3bqSOhcXZ4r0sFmMNsuF7aZmUntjWLc7w==} + + '@tiptap/react@3.26.0': + resolution: {integrity: sha512-NLPAG6tk4/AsfOsUNsbGqdgIHuGsD4A/hlYriozuo+LCAAduuluhzsL/MEHZXtFT4GXUOlCdaEqNCOrMuz/zaw==} + peerDependencies: + '@tiptap/core': 3.26.0 + '@tiptap/pm': 3.26.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tiptap/starter-kit@3.24.0': - resolution: {integrity: sha512-Ef4PCP96vcY2GonXN9J0M8iC6zvxPTmQlL/QZiCwuYqqnH/hNpYIjNSQdTndiDpxRKofa32Sr2HWktgEnL32Bg==} + '@tiptap/starter-kit@3.26.0': + resolution: {integrity: sha512-o34EtMfqtBaljdmeElZsRG/067oGx9Zcq+j2GWo71KlZe22ga/ALexeTf1c+ETsjCxSTKR6eyQ4RZvz/2JpYfg==} '@tree-sitter-grammars/tree-sitter-yaml@0.7.1': resolution: {integrity: sha512-AynBwkIoQCTgjDR33bDUp9Mqq+YTco0is3n5hRApMqG9of/6A4eQsfC1/uSEeHSUyMQSYawcAWamsexnVpIP4Q==} @@ -6350,8 +6388,8 @@ packages: dompurify@3.4.5: resolution: {integrity: sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==} - dompurify@3.4.7: - resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==} + dompurify@3.4.8: + resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==} dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} @@ -7040,6 +7078,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==} @@ -7209,8 +7250,8 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} - isomorphic-dompurify@3.15.0: - resolution: {integrity: sha512-9ZtkbQ8+SgNf6LuDAdu9bq23dVXMIGNM8ZYnyl2MufyZiSD5dqAUJcyjtYZz7B80HuPpEn/f0NCS6zKvavHtfA==} + isomorphic-dompurify@3.16.0: + resolution: {integrity: sha512-KzTJsMuVPqPwBOD0F6jKXoV/+v8CSx0rxVp+/67KV6Xbw++0zD/avT3bZsgwR0M5QRMiOE+QqQtoZLEJYy1C4Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} isomorphic-ws@5.0.0: @@ -7336,6 +7377,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'} @@ -7359,6 +7403,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==} @@ -8064,6 +8111,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==} @@ -8350,26 +8400,26 @@ packages: prosemirror-inputrules@1.5.1: resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} - prosemirror-keymap@1.2.2: - resolution: {integrity: sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==} + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} - prosemirror-model@1.24.1: - resolution: {integrity: sha512-YM053N+vTThzlWJ/AtPtF1j0ebO36nvbmDy4U7qA2XQB8JVaQp1FmB9Jhrps8s+z+uxhhVTny4m20ptUvhk0Mg==} + prosemirror-model@1.25.7: + resolution: {integrity: sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==} prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} - prosemirror-state@1.4.3: - resolution: {integrity: sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==} + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} - prosemirror-tables@1.6.4: - resolution: {integrity: sha512-TkDY3Gw52gRFRfRn2f4wJv5WOgAOXLJA2CQJYIJ5+kdFbfj3acR4JUW6LX2e1hiEBiUwvEhzH5a3cZ5YSztpIA==} + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} - prosemirror-transform@1.10.2: - resolution: {integrity: sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==} + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} - prosemirror-view@1.41.4: - resolution: {integrity: sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==} + prosemirror-view@1.41.8: + resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -8776,8 +8826,8 @@ packages: engines: {node: ^22.14.0 || >= 24.10.0} hasBin: true - semver-parser@4.2.0: - resolution: {integrity: sha512-4/Pru3U/BQed3eCHWLS+lCZLrnNbSjEhP/Bk+0Fu0qLiaAE+T9CM6M/ARBo/V1ZqzcqAtFgFU2MdXjSqJ3XBLw==} + semver-parser@4.2.1: + resolution: {integrity: sha512-JZ2DLMzJ+jiNAl5+uc+gnXvo4K/pPtkpo0SuxFBB8dU1FRIviTwqv01sbqPlslMVGaJdsSpRVfb9vGUeAgHN0Q==} semver-regex@4.0.5: resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} @@ -8806,6 +8856,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==} @@ -8942,6 +8995,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==} @@ -10557,10 +10613,10 @@ snapshots: '@formatjs/fast-memoize': 3.1.0 tslib: 2.8.1 - '@gfazioli/mantine-onboarding-tour@4.0.4(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@gfazioli/mantine-onboarding-tour@4.0.4(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.2.2(react@19.2.7) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) '@tabler/icons-react': 3.44.0(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -10605,7 +10661,7 @@ snapshots: eventemitter2: 6.4.9 http-cookie-agent: 6.0.8(tough-cookie@5.1.2)(undici@7.24.6) tough-cookie: 5.1.2 - ws: 8.20.1 + ws: 8.21.0 transitivePeerDependencies: - bufferutil - debug @@ -10963,22 +11019,22 @@ snapshots: js-base64: 3.7.8 optional: true - '@mantine/charts@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(recharts@3.8.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1))': + '@mantine/charts@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(recharts@3.8.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1))': dependencies: - '@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.2.2(react@19.2.7) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) recharts: 3.8.1(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1) - '@mantine/colors-generator@9.2.2(chroma-js@3.2.0)': + '@mantine/colors-generator@9.3.0(chroma-js@3.2.0)': dependencies: chroma-js: 3.2.0 - '@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.2.2(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) clsx: 2.1.1 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -10988,68 +11044,68 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@mantine/dates@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.2.2(react@19.2.7) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) clsx: 2.1.1 dayjs: 1.11.21 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@mantine/dropzone@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@mantine/dropzone@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.2.2(react@19.2.7) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) react-dropzone: 15.0.0(react@19.2.7) - '@mantine/form@9.2.2(react@19.2.7)': + '@mantine/form@9.3.0(react@19.2.7)': dependencies: '@standard-schema/spec': 1.1.0 fast-deep-equal: 3.1.3 klona: 2.0.6 react: 19.2.7 - '@mantine/hooks@9.2.2(react@19.2.7)': + '@mantine/hooks@9.3.0(react@19.2.7)': dependencies: react: 19.2.7 - '@mantine/modals@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@mantine/modals@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.2.2(react@19.2.7) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@mantine/notifications@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@mantine/notifications@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.2.2(react@19.2.7) - '@mantine/store': 9.2.2(react@19.2.7) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) + '@mantine/store': 9.3.0(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) react-transition-group: 4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/spotlight@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@mantine/spotlight@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.2.2(react@19.2.7) - '@mantine/store': 9.2.2(react@19.2.7) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) + '@mantine/store': 9.3.0(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@mantine/store@9.2.2(react@19.2.7)': + '@mantine/store@9.3.0(react@19.2.7)': dependencies: react: 19.2.7 - '@mantine/tiptap@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(@tiptap/extension-link@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))(@tiptap/react@3.24.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@mantine/tiptap@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tiptap/extension-link@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))(@tiptap/react@3.26.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.2.2(react@19.2.7) - '@tiptap/extension-link': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) - '@tiptap/react': 3.24.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) + '@tiptap/extension-link': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/react': 3.26.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -12208,25 +12264,25 @@ snapshots: dependencies: remove-accents: 0.5.0 - '@tanstack/query-core@5.100.14': {} + '@tanstack/query-core@5.101.0': {} - '@tanstack/query-devtools@5.100.14': {} + '@tanstack/query-devtools@5.101.0': {} - '@tanstack/react-query-devtools@5.100.14(@tanstack/react-query@5.100.14(react@19.2.7))(react@19.2.7)': + '@tanstack/react-query-devtools@5.101.0(@tanstack/react-query@5.101.0(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/query-devtools': 5.100.14 - '@tanstack/react-query': 5.100.14(react@19.2.7) + '@tanstack/query-devtools': 5.101.0 + '@tanstack/react-query': 5.101.0(react@19.2.7) react: 19.2.7 - '@tanstack/react-query-next-experimental@5.100.14(@tanstack/react-query@5.100.14(react@19.2.7))(next@16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7)': + '@tanstack/react-query-next-experimental@5.101.0(@tanstack/react-query@5.101.0(react@19.2.7))(next@16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react@19.2.7)': dependencies: - '@tanstack/react-query': 5.100.14(react@19.2.7) + '@tanstack/react-query': 5.101.0(react@19.2.7) next: 16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0) react: 19.2.7 - '@tanstack/react-query@5.100.14(react@19.2.7)': + '@tanstack/react-query@5.101.0(react@19.2.7)': dependencies: - '@tanstack/query-core': 5.100.14 + '@tanstack/query-core': 5.101.0 react: 19.2.7 '@tanstack/react-table@8.20.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': @@ -12266,168 +12322,177 @@ snapshots: - bare-buffer - supports-color - '@tiptap/core@3.24.0(@tiptap/pm@3.24.0)': + '@tiptap/core@3.26.0(@tiptap/pm@3.26.0)': dependencies: - '@tiptap/pm': 3.24.0 + '@tiptap/pm': 3.26.0 - '@tiptap/extension-blockquote@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-blockquote@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-bold@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-bold@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-bubble-menu@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + '@tiptap/extension-bubble-menu@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': dependencies: '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 optional: true - '@tiptap/extension-bullet-list@3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-bullet-list@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-code-block@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + '@tiptap/extension-code-block@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 - '@tiptap/extension-code@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-code@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-color@3.24.0(@tiptap/extension-text-style@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)))': + '@tiptap/extension-color@3.25.0(@tiptap/extension-text-style@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)))': dependencies: - '@tiptap/extension-text-style': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) + '@tiptap/extension-text-style': 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) - '@tiptap/extension-document@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-document@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-dropcursor@3.24.0(@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-dropcursor@3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/extensions': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-floating-menu@3.24.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + '@tiptap/extension-floating-menu@3.26.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': dependencies: '@floating-ui/dom': 1.7.6 - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 optional: true - '@tiptap/extension-gapcursor@3.24.0(@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-gapcursor@3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/extensions': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-hard-break@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-hard-break@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-heading@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-heading@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-highlight@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-highlight@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-horizontal-rule@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + '@tiptap/extension-horizontal-rule@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 - '@tiptap/extension-image@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-image@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-italic@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-italic@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-link@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + '@tiptap/extension-link@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 linkifyjs: 4.3.3 - '@tiptap/extension-list-item@3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-list-item@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-list-keymap@3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-list-keymap@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + '@tiptap/extension-list@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 - '@tiptap/extension-ordered-list@3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': dependencies: - '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 - '@tiptap/extension-paragraph@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-ordered-list@3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-placeholder@3.24.0(@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-paragraph@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/extensions': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-strike@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-placeholder@3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-table-cell@3.24.0(@tiptap/extension-table@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-strike@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/extension-table': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-table-header@3.24.0(@tiptap/extension-table@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-table-cell@3.25.0(@tiptap/extension-table@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/extension-table': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-table': 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-table-row@3.24.0(@tiptap/extension-table@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-table-header@3.25.0(@tiptap/extension-table@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/extension-table': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-table': 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-table@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + '@tiptap/extension-table-row@3.25.0(@tiptap/extension-table@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) - '@tiptap/pm': 3.24.0 + '@tiptap/extension-table': 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-task-item@3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-table@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': dependencies: - '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 - '@tiptap/extension-task-list@3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0))': + '@tiptap/extension-task-item@3.25.0(@tiptap/extension-list@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-list': 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-text-align@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-task-list@3.25.0(@tiptap/extension-list@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/extension-list': 3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) - '@tiptap/extension-text-style@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-text-align@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-text@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-text-style@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extension-underline@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))': + '@tiptap/extension-text@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)': + '@tiptap/extension-underline@3.25.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) - '@tiptap/pm@3.24.0': + '@tiptap/extension-underline@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + + '@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': + dependencies: + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 + + '@tiptap/pm@3.26.0': dependencies: prosemirror-changeset: 2.3.0 prosemirror-commands: 1.6.2 @@ -12435,18 +12500,18 @@ snapshots: prosemirror-gapcursor: 1.3.2 prosemirror-history: 1.4.1 prosemirror-inputrules: 1.5.1 - prosemirror-keymap: 1.2.2 - prosemirror-model: 1.24.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 prosemirror-schema-list: 1.5.1 - prosemirror-state: 1.4.3 - prosemirror-tables: 1.6.4 - prosemirror-transform: 1.10.2 - prosemirror-view: 1.41.4 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 - '@tiptap/react@3.24.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tiptap/react@3.26.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) '@types/use-sync-external-store': 0.0.6 @@ -12455,37 +12520,37 @@ snapshots: react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) - '@tiptap/extension-floating-menu': 3.24.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) + '@tiptap/extension-bubble-menu': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-floating-menu': 3.26.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) transitivePeerDependencies: - '@floating-ui/dom' - '@tiptap/starter-kit@3.24.0': + '@tiptap/starter-kit@3.26.0': dependencies: - '@tiptap/core': 3.24.0(@tiptap/pm@3.24.0) - '@tiptap/extension-blockquote': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) - '@tiptap/extension-bold': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) - '@tiptap/extension-bullet-list': 3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) - '@tiptap/extension-code': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) - '@tiptap/extension-code-block': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) - '@tiptap/extension-document': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) - '@tiptap/extension-dropcursor': 3.24.0(@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) - '@tiptap/extension-gapcursor': 3.24.0(@tiptap/extensions@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) - '@tiptap/extension-hard-break': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) - '@tiptap/extension-heading': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) - '@tiptap/extension-horizontal-rule': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) - '@tiptap/extension-italic': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) - '@tiptap/extension-link': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) - '@tiptap/extension-list': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) - '@tiptap/extension-list-item': 3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) - '@tiptap/extension-list-keymap': 3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) - '@tiptap/extension-ordered-list': 3.24.0(@tiptap/extension-list@3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0)) - '@tiptap/extension-paragraph': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) - '@tiptap/extension-strike': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) - '@tiptap/extension-text': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) - '@tiptap/extension-underline': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0)) - '@tiptap/extensions': 3.24.0(@tiptap/core@3.24.0(@tiptap/pm@3.24.0))(@tiptap/pm@3.24.0) - '@tiptap/pm': 3.24.0 + '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) + '@tiptap/extension-blockquote': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-bold': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-bullet-list': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-code': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-code-block': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-document': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-dropcursor': 3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-gapcursor': 3.26.0(@tiptap/extensions@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-hard-break': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-heading': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-horizontal-rule': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-italic': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-link': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-list': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/extension-list-item': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-list-keymap': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-ordered-list': 3.26.0(@tiptap/extension-list@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)) + '@tiptap/extension-paragraph': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-strike': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-text': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extension-underline': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) + '@tiptap/extensions': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0) + '@tiptap/pm': 3.26.0 '@tree-sitter-grammars/tree-sitter-yaml@0.7.1(tree-sitter@0.22.4)': dependencies: @@ -12500,7 +12565,7 @@ snapshots: '@trpc/server': 11.17.0(typescript@5.9.3) typescript: 5.9.3 - '@trpc/next@11.17.0(@tanstack/react-query@5.100.14(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.17.0(@tanstack/react-query@5.100.14(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(next@16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3)': + '@trpc/next@11.17.0(@tanstack/react-query@5.101.0(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/react-query@11.17.0(@tanstack/react-query@5.101.0(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(next@16.2.7(@babel/core@7.29.0)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.100.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3)': dependencies: '@trpc/client': 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3) '@trpc/server': 11.17.0(typescript@5.9.3) @@ -12509,12 +12574,12 @@ snapshots: react-dom: 19.2.7(react@19.2.7) typescript: 5.9.3 optionalDependencies: - '@tanstack/react-query': 5.100.14(react@19.2.7) - '@trpc/react-query': 11.17.0(@tanstack/react-query@5.100.14(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3) + '@tanstack/react-query': 5.101.0(react@19.2.7) + '@trpc/react-query': 11.17.0(@tanstack/react-query@5.101.0(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3) - '@trpc/react-query@11.17.0(@tanstack/react-query@5.100.14(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3)': + '@trpc/react-query@11.17.0(@tanstack/react-query@5.101.0(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3)': dependencies: - '@tanstack/react-query': 5.100.14(react@19.2.7) + '@tanstack/react-query': 5.101.0(react@19.2.7) '@trpc/client': 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3) '@trpc/server': 11.17.0(typescript@5.9.3) react: 19.2.7 @@ -12524,9 +12589,9 @@ snapshots: dependencies: typescript: 5.9.3 - '@trpc/tanstack-react-query@11.17.0(@tanstack/react-query@5.100.14(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3)': + '@trpc/tanstack-react-query@11.17.0(@tanstack/react-query@5.101.0(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3)': dependencies: - '@tanstack/react-query': 5.100.14(react@19.2.7) + '@tanstack/react-query': 5.101.0(react@19.2.7) '@trpc/client': 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3) '@trpc/server': 11.17.0(typescript@5.9.3) react: 19.2.7 @@ -13717,7 +13782,7 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 - dompurify@3.4.7: + dompurify@3.4.8: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -13749,7 +13814,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 @@ -13758,10 +13823,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: @@ -14435,6 +14501,8 @@ snapshots: ieee754@1.2.1: {} + immediate@3.0.6: {} + immer@10.2.0: {} immer@11.1.4: {} @@ -14568,9 +14636,9 @@ snapshots: isexe@3.1.5: optional: true - isomorphic-dompurify@3.15.0(@noble/hashes@2.0.1): + isomorphic-dompurify@3.16.0(@noble/hashes@2.0.1): dependencies: - dompurify: 3.4.7 + dompurify: 3.4.8 jsdom: 29.1.1(@noble/hashes@2.0.1) transitivePeerDependencies: - '@noble/hashes' @@ -14700,6 +14768,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 @@ -14720,6 +14795,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 @@ -14828,19 +14907,11 @@ snapshots: dependencies: semver: 7.7.4 - mantine-datatable@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + mantine-react-table-open@9.0.3(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.2.2(react@19.2.7) - clsx: 2.1.1 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - - mantine-react-table-open@9.0.3(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - '@mantine/core': 9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/dates': 9.2.2(@mantine/core@9.2.2(@mantine/hooks@9.2.2(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.2.2(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.2.2(react@19.2.7) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/dates': 9.3.0(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.3.0(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@mantine/hooks': 9.3.0(react@19.2.7) '@tabler/icons-react': 3.44.0(react@19.2.7) '@tanstack/match-sorter-utils': 8.19.4 '@tanstack/react-table': 8.20.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -15485,6 +15556,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + parchment@3.0.0: {} parent-module@1.0.1: @@ -15761,77 +15834,77 @@ snapshots: prosemirror-changeset@2.3.0: dependencies: - prosemirror-transform: 1.10.2 + prosemirror-transform: 1.12.0 prosemirror-commands@1.6.2: dependencies: - prosemirror-model: 1.24.1 - prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.2 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 prosemirror-dropcursor@1.8.1: dependencies: - prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.2 - prosemirror-view: 1.41.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 prosemirror-gapcursor@1.3.2: dependencies: - prosemirror-keymap: 1.2.2 - prosemirror-model: 1.24.1 - prosemirror-state: 1.4.3 - prosemirror-view: 1.41.4 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 prosemirror-history@1.4.1: dependencies: - prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.2 - prosemirror-view: 1.41.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 rope-sequence: 1.3.4 prosemirror-inputrules@1.5.1: dependencies: - prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.2 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 - prosemirror-keymap@1.2.2: + prosemirror-keymap@1.2.3: dependencies: - prosemirror-state: 1.4.3 + prosemirror-state: 1.4.4 w3c-keyname: 2.2.8 - prosemirror-model@1.24.1: + prosemirror-model@1.25.7: dependencies: orderedmap: 2.1.1 prosemirror-schema-list@1.5.1: dependencies: - prosemirror-model: 1.24.1 - prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.2 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 - prosemirror-state@1.4.3: + prosemirror-state@1.4.4: dependencies: - prosemirror-model: 1.24.1 - prosemirror-transform: 1.10.2 - prosemirror-view: 1.41.4 + prosemirror-model: 1.25.7 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 - prosemirror-tables@1.6.4: + prosemirror-tables@1.8.5: dependencies: - prosemirror-keymap: 1.2.2 - prosemirror-model: 1.24.1 - prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.2 - prosemirror-view: 1.41.4 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 - prosemirror-transform@1.10.2: + prosemirror-transform@1.12.0: dependencies: - prosemirror-model: 1.24.1 + prosemirror-model: 1.25.7 - prosemirror-view@1.41.4: + prosemirror-view@1.41.8: dependencies: - prosemirror-model: 1.24.1 - prosemirror-state: 1.4.3 - prosemirror-transform: 1.10.2 + prosemirror-model: 1.25.7 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 proto-list@1.2.4: {} @@ -16331,7 +16404,7 @@ snapshots: - supports-color - typescript - semver-parser@4.2.0: {} + semver-parser@4.2.1: {} semver-regex@4.0.5: {} @@ -16357,6 +16430,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: @@ -16525,6 +16600,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 33a9580de..94f6fadd8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,17 +21,17 @@ catalog: "@immich/sdk": ^2.7.5 "@jellyfin/sdk": ^0.13.0 "@kubernetes/client-node": ^1.4.0 - "@mantine/charts": ^9.2.2 - "@mantine/colors-generator": ^9.2.2 - "@mantine/core": ^9.2.2 - "@mantine/dates": ^9.2.2 - "@mantine/dropzone": ^9.2.2 - "@mantine/form": ^9.2.2 - "@mantine/hooks": ^9.2.2 - "@mantine/modals": ^9.2.2 - "@mantine/notifications": ^9.2.2 - "@mantine/spotlight": ^9.2.2 - "@mantine/tiptap": ^9.2.2 + "@mantine/charts": ^9.3.0 + "@mantine/colors-generator": ^9.3.0 + "@mantine/core": ^9.3.0 + "@mantine/dates": ^9.3.0 + "@mantine/dropzone": ^9.3.0 + "@mantine/form": ^9.3.0 + "@mantine/hooks": ^9.3.0 + "@mantine/modals": ^9.3.0 + "@mantine/notifications": ^9.3.0 + "@mantine/spotlight": ^9.3.0 + "@mantine/tiptap": ^9.3.0 "@next/eslint-plugin-next": 16.2.7 "@octokit/auth-app": ^8.2.0 "@paralleldrive/cuid2": ^3.3.0 @@ -44,28 +44,28 @@ catalog: "@semantic-release/release-notes-generator": ^14.1.1 "@t3-oss/env-nextjs": ^0.13.11 "@tabler/icons-react": ^3.44.0 - "@tanstack/react-query": ^5.100.14 - "@tanstack/react-query-devtools": ^5.100.14 - "@tanstack/react-query-next-experimental": ^5.100.14 + "@tanstack/react-query": ^5.101.0 + "@tanstack/react-query-devtools": ^5.101.0 + "@tanstack/react-query-next-experimental": ^5.101.0 "@testcontainers/mysql": ^12.0.1 "@testcontainers/postgresql": ^12.0.1 "@testcontainers/redis": ^12.0.1 - "@tiptap/extension-color": 3.24.0 - "@tiptap/extension-highlight": 3.24.0 - "@tiptap/extension-image": 3.24.0 - "@tiptap/extension-link": ^3.24.0 - "@tiptap/extension-placeholder": ^3.24.0 - "@tiptap/extension-table": 3.24.0 - "@tiptap/extension-table-cell": 3.24.0 - "@tiptap/extension-table-header": 3.24.0 - "@tiptap/extension-table-row": 3.24.0 - "@tiptap/extension-task-item": 3.24.0 - "@tiptap/extension-task-list": 3.24.0 - "@tiptap/extension-text-align": 3.24.0 - "@tiptap/extension-text-style": 3.24.0 - "@tiptap/extension-underline": 3.24.0 - "@tiptap/react": ^3.24.0 - "@tiptap/starter-kit": ^3.24.0 + "@tiptap/extension-color": 3.25.0 + "@tiptap/extension-highlight": 3.25.0 + "@tiptap/extension-image": 3.25.0 + "@tiptap/extension-link": ^3.25.0 + "@tiptap/extension-placeholder": ^3.25.0 + "@tiptap/extension-table": 3.25.0 + "@tiptap/extension-table-cell": 3.25.0 + "@tiptap/extension-table-header": 3.25.0 + "@tiptap/extension-table-row": 3.25.0 + "@tiptap/extension-task-item": 3.25.0 + "@tiptap/extension-task-list": 3.25.0 + "@tiptap/extension-text-align": 3.25.0 + "@tiptap/extension-text-style": 3.25.0 + "@tiptap/extension-underline": 3.25.0 + "@tiptap/react": ^3.25.0 + "@tiptap/starter-kit": ^3.25.0 "@trpc/client": ^11.17.0 "@trpc/next": ^11.17.0 "@trpc/react-query": ^11.17.0 @@ -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 @@ -126,7 +128,7 @@ catalog: glob: ^13.0.6 ical.js: ^2.2.1 ioredis: 5.11.0 - isomorphic-dompurify: ^3.15.0 + isomorphic-dompurify: ^3.16.0 jotai: ^2.20.0 jsdom: ^29.1.1 json5: ^2.2.3 @@ -157,7 +159,7 @@ catalog: recharts: ^3.8.1 sass: ^1.100.0 semantic-release: ^25.0.3 - semver-parser: ^4.2.0 + semver-parser: ^4.2.1 superjson: 2.2.6 svgson: ^5.3.1 swagger-ui-react: ^5.32.6 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