From a42af0e678b58b6c63a8bac4978fb2951c6ddef8 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 10 Jun 2026 19:51:56 +0200 Subject: [PATCH] fix(boards): add permissions to board summary schema to fix manage page crash (#5917) * fix(boards): add permissions to board summary schema to fix manage page crash The getAllBoards query fetches userPermissions and groupPermissions from the DB, but boardSummarySchema (the output validator) did not include them. tRPC stripped the fields, causing a TypeError when getBoardPermissionsAsync called .some() on undefined. * fix(backup): cast Buffer to Uint8Array for NextResponse body Node 24 narrowed the BodyInit type, making Buffer incompatible. Wrapping in Uint8Array satisfies the type constraint. * fix(lint): remove forbidden non-null assertions across backup and board code Replace non-null assertions with safe guards, optional chaining, and for-of iteration so oxlint passes in CI. Also remove unused Collapse import. --- .../init/_steps/start/sqlite-restore.tsx | 2 +- apps/nextjs/src/app/api/backup/import/route.ts | 10 ++++++++-- apps/nextjs/src/app/api/backup/sql-wasm/route.ts | 2 +- .../src/app/api/backup/test/backup.spec.ts | 6 +++--- .../components/backup/restore-progress-panel.tsx | 16 ++++++++-------- .../src/components/backup/use-backup-analysis.ts | 3 +-- packages/api/src/router/board.ts | 8 ++++++-- packages/validation/src/board.ts | 6 ++++++ 8 files changed, 34 insertions(+), 19 deletions(-) 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 index 75bc39f5f..ac5e433df 100644 --- a/apps/nextjs/src/app/[locale]/init/_steps/start/sqlite-restore.tsx +++ b/apps/nextjs/src/app/[locale]/init/_steps/start/sqlite-restore.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Button, Collapse, Stack, Text } from "@mantine/core"; +import { Button, Stack, Text } from "@mantine/core"; import { IconDatabaseImport } from "@tabler/icons-react"; import { getMantineColor } from "@homarr/common"; diff --git a/apps/nextjs/src/app/api/backup/import/route.ts b/apps/nextjs/src/app/api/backup/import/route.ts index 32002014a..995cce828 100644 --- a/apps/nextjs/src/app/api/backup/import/route.ts +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -129,14 +129,20 @@ export async function POST(req: Request) { let tempDb: InstanceType | null = null; try { - const dbEntry = zip.getEntry("db.sqlite")!; + const dbEntry = zip.getEntry("db.sqlite"); + if (!dbEntry) { + return NextResponse.json({ error: "Invalid backup: missing db.sqlite" }, { status: 400 }); + } 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 metadataEntry = zip.getEntry("metadata.json"); + if (!metadataEntry) { + return NextResponse.json({ error: "Invalid backup: missing metadata.json" }, { status: 400 }); + } const metadata = JSON.parse(metadataEntry.getData().toString()); const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim(); diff --git a/apps/nextjs/src/app/api/backup/sql-wasm/route.ts b/apps/nextjs/src/app/api/backup/sql-wasm/route.ts index de5917ecb..14cd4a8cd 100644 --- a/apps/nextjs/src/app/api/backup/sql-wasm/route.ts +++ b/apps/nextjs/src/app/api/backup/sql-wasm/route.ts @@ -21,7 +21,7 @@ export async function GET() { cachedWasm = fs.readFileSync(found); } - return new NextResponse(cachedWasm, { + return new NextResponse(new Uint8Array(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 index 8e4e6039d..4584b415f 100644 --- a/apps/nextjs/src/app/api/backup/test/backup.spec.ts +++ b/apps/nextjs/src/app/api/backup/test/backup.spec.ts @@ -85,7 +85,7 @@ describe("SQLite backup", () => { const dbEntry = readBack.getEntry("db.sqlite"); expect(dbEntry).toBeDefined(); - expect(dbEntry!.header.size).toBeGreaterThan(0); + expect(dbEntry?.header.size).toBeGreaterThan(0); }); it("should produce a valid SQLite database in the ZIP", () => { @@ -256,7 +256,7 @@ describe("SQLite backup", () => { 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"); + expect(app?.name).toBe("TestApp"); backupDb.close(); sqlite.close(); @@ -283,7 +283,7 @@ describe("SQLite backup", () => { 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"); + expect(board?.name).toBe("TestBoard"); importDb.close(); }); diff --git a/apps/nextjs/src/components/backup/restore-progress-panel.tsx b/apps/nextjs/src/components/backup/restore-progress-panel.tsx index 4faa397be..ebbbf9330 100644 --- a/apps/nextjs/src/components/backup/restore-progress-panel.tsx +++ b/apps/nextjs/src/components/backup/restore-progress-panel.tsx @@ -17,12 +17,12 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { MigrationFile } from "./types"; -const BASE_PHASE_DURATIONS: Record = { +const BASE_PHASE_DURATIONS = { extracting: 1200, encrypting: 1500, swapping: 800, restarting: 10000, -}; +} as const; const MIGRATION_STEP_DURATION = 100; @@ -45,7 +45,7 @@ export const RestoreProgressPanel = ({ active, migrations, onComplete }: Restore const steps = useMemo((): TimelineStep[] => { const result: TimelineStep[] = [ - { key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! }, + { key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting }, ]; if (migrations.length > 0) { @@ -67,9 +67,9 @@ export const RestoreProgressPanel = ({ active, migrations, onComplete }: Restore } 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! }, + { 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; @@ -80,10 +80,10 @@ export const RestoreProgressPanel = ({ active, migrations, onComplete }: Restore let cancelled = false; const run = async () => { - for (let i = 0; i < steps.length; i++) { + for (const [i, step] of steps.entries()) { if (cancelled) return; setCurrentStepIdx(i); - await new Promise((r) => setTimeout(r, steps[i]!.duration)); + await new Promise((r) => setTimeout(r, step.duration)); } if (!cancelled) { onComplete?.(); diff --git a/apps/nextjs/src/components/backup/use-backup-analysis.ts b/apps/nextjs/src/components/backup/use-backup-analysis.ts index a563e24cd..64c3dd23d 100644 --- a/apps/nextjs/src/components/backup/use-backup-analysis.ts +++ b/apps/nextjs/src/components/backup/use-backup-analysis.ts @@ -109,8 +109,7 @@ export const useBackupAnalysis = () => { }; if (pendingMigrations.length > 0) { - for (let i = 0; i < pendingMigrations.length; i++) { - const migration = pendingMigrations[i]!; + for (const [i, migration] of pendingMigrations.entries()) { setMigrationProgress({ current: i + 1, total: pendingMigrations.length, diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 3d4939559..fe8c0dcb9 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -1430,8 +1430,10 @@ export const boardRouter = createTRPCRouter({ for (const il of existingInSection) { for (let y = il.yOffset; y < il.yOffset + il.height; y++) { while (occupied.length <= y) occupied.push(Array.from({ length: layout.columnCount }).fill(false)); + const occupiedRow = occupied[y]; + if (!occupiedRow) continue; for (let x = il.xOffset; x < il.xOffset + il.width; x++) { - occupied[y]![x] = true; + occupiedRow[x] = true; } } } @@ -1439,8 +1441,10 @@ export const boardRouter = createTRPCRouter({ let placed = false; for (let y = 0; y < 9999 && !placed; y++) { if (!occupied[y]) occupied.push(Array.from({ length: layout.columnCount }).fill(false)); + const occupiedRow = occupied[y]; + if (!occupiedRow) continue; for (let x = 0; x < layout.columnCount && !placed; x++) { - if (!occupied[y]![x]) { + if (!occupiedRow[x]) { layoutRows.push({ itemId, sectionId: emptySection.id, diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index 24a0f3608..6c9e1a650 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -96,6 +96,10 @@ export const boardCreateSchema = z.object({ export const boardSavePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(boardPermissions)); +const boardPermissionEntrySchema = z.object({ + permission: z.enum(boardPermissions), +}); + export const boardSummarySchema = z.object({ id: z.string(), name: z.string(), @@ -111,6 +115,8 @@ export const boardSummarySchema = z.object({ .nullable(), isHome: z.boolean(), isMobileHome: z.boolean(), + userPermissions: z.array(boardPermissionEntrySchema), + groupPermissions: z.array(boardPermissionEntrySchema), }); export const addItemToBoardSchema = z.object({