From 967ed8dd73543412d7573655f95c2388aff8d5a2 Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 28 Jul 2026 21:28:53 +0200 Subject: [PATCH] fix: harden automatic mobile boards --- apps/docs/tsconfig.json | 11 +--- .../boards/(content)/_guarded-history.spec.ts | 51 +++++++++++++++ .../boards/(content)/_guarded-history.ts | 58 +++++++++++++++++ .../board/mobile/mobile-presentation.spec.ts | 14 ++++ .../board/mobile/mobile-presentation.ts | 5 +- .../components/board/use-mobile-board.spec.ts | 19 ++++-- .../src/components/board/use-mobile-board.tsx | 12 ++-- e2e/mobile-board.spec.ts | 41 +++++++++--- packages/api/src/router/serverSettings.ts | 26 ++------ .../src/router/test/serverSettings.spec.ts | 59 ++++++++++++++++- packages/api/src/settle-integrations.spec.ts | 29 +++++---- packages/api/src/settle-integrations.ts | 44 ++++++++++--- packages/boards/src/context.spec.ts | 11 ++++ packages/boards/src/edit-mode.spec.ts | 11 ++++ packages/boards/src/edit-mode.tsx | 22 ++++++- packages/db/collection.ts | 2 +- packages/db/migrations/seed.spec.ts | 9 +++ packages/db/queries/server-setting.ts | 65 +++++++++++++++++-- .../src/lib/request-handler.spec.ts | 54 +++++++++++++++ packages/translation/src/lang/en.json | 1 + packages/widgets/package.json | 4 +- packages/widgets/src/common/query-state.tsx | 5 +- .../src/rssFeed/sanitize-description.spec.ts | 12 ++++ .../src/rssFeed/sanitize-description.ts | 11 ++++ pnpm-lock.yaml | 6 -- 25 files changed, 490 insertions(+), 92 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.spec.ts create mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.ts create mode 100644 packages/widgets/src/rssFeed/sanitize-description.spec.ts create mode 100644 packages/widgets/src/rssFeed/sanitize-description.ts diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index 5062b181b..483552e60 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -17,15 +17,8 @@ "paths": { "@/*": ["./src/*"], "@site/*": ["./*"] - }, + } }, - "include": [ - "**/*.ts", - "**/*.jsx", - "**/*.js", - "**/*.tsx", - "**/*.md", - "**/*.mdx", - ], + "include": ["**/*.ts", "**/*.jsx", "**/*.js", "**/*.tsx", "**/*.md", "**/*.mdx"], "exclude": ["build", "node_modules"] } diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.spec.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.spec.ts new file mode 100644 index 000000000..d96d80f09 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.spec.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { classifyGuardedNavigation, removeGuardHistoryEntry, replaceGuardedSameDocumentUrl } from "./_guarded-history"; + +describe("guarded board edit history", () => { + beforeEach(() => { + window.history.replaceState({ page: "board" }, "", "/boards/demo"); + }); + + test("replaces the guard entry for same-document fragment navigation", () => { + const section = document.createElement("section"); + section.id = "Media requests"; + document.body.appendChild(section); + const scrollIntoView = vi.fn(); + Object.defineProperty(section, "scrollIntoView", { value: scrollIntoView }); + + replaceGuardedSameDocumentUrl(new URL("/boards/demo#Media%20requests", window.location.href), { + key: "__guard", + id: "guard-id", + }); + + expect(window.location.hash).toBe("#Media%20requests"); + expect(window.history.state).toStrictEqual({ + page: "board", + __guard: "guard-id", + }); + expect(scrollIntoView).toHaveBeenCalledOnce(); + section.remove(); + }); + + test("treats an explicit empty fragment as same-document navigation", () => { + const current = new URL(window.location.href); + const destination = new URL("#", current); + + expect(classifyGuardedNavigation(destination, current)).toBe("same-document"); + }); + + test("removes the guard while preserving the fragment when editing ends", () => { + window.history.replaceState({ page: "board", __guard: "guard-id" }, "", "/boards/demo#section"); + const back = vi.spyOn(window.history, "back").mockImplementation(() => { + window.history.replaceState({ page: "board" }, "", "/boards/demo"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + + removeGuardHistoryEntry("http://localhost:3000/boards/demo#section", "__guard"); + + expect(back).toHaveBeenCalledOnce(); + expect(window.location.href).toBe("http://localhost:3000/boards/demo#section"); + expect(window.history.state).toStrictEqual({ page: "board" }); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.ts new file mode 100644 index 000000000..29677f8e1 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_guarded-history.ts @@ -0,0 +1,58 @@ +const getHistoryState = () => + window.history.state && typeof window.history.state === "object" + ? (window.history.state as Record) + : {}; + +interface GuardState { + key: string; + id: string; +} + +export const classifyGuardedNavigation = (destination: URL, current: URL) => { + if (destination.href === current.href) return "same-url"; + if ( + destination.origin === current.origin && + destination.pathname === current.pathname && + destination.search === current.search + ) { + return "same-document"; + } + return "other-document"; +}; + +export const replaceGuardedSameDocumentUrl = (destination: URL, guard: GuardState) => { + window.history.replaceState( + { + ...getHistoryState(), + [guard.key]: guard.id, + }, + document.title, + destination.href, + ); + + if (!destination.hash) { + window.scrollTo({ top: 0, left: 0 }); + return; + } + + let targetId = destination.hash.slice(1); + try { + targetId = decodeURIComponent(targetId); + } catch { + // Keep the encoded value when the fragment contains malformed escape sequences. + } + document.getElementById(targetId)?.scrollIntoView(); +}; + +export const removeGuardHistoryEntry = (guardedUrl: string, guardStateKey: string) => { + window.addEventListener( + "popstate", + () => { + const state = { ...getHistoryState() }; + delete state[guardStateKey]; + window.history.replaceState(state, document.title, guardedUrl); + }, + { once: true }, + ); + window.history.back(); +}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts index 45e59a506..0dcdeb4f9 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts @@ -18,6 +18,20 @@ describe("resolveMobileItemPresentation", () => { }); }); + test("does not unmount apps that use the eager default", () => { + expect( + resolveMobileItemPresentation( + { kind: "app", height: 1 }, + { + unmountWhenOffscreen: true, + }, + ), + ).toMatchObject({ + eager: true, + unmountWhenOffscreen: false, + }); + }); + test("defaults widgets to full width without inventing a detail view", () => { expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({ width: 2, diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts index 08125d5bf..a88e7eec2 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts @@ -8,6 +8,7 @@ export const resolveMobileItemPresentation = ( const supportsCompactSummary = mobile?.supportsCompactSummary === true; const supportsDetails = mobile?.supportsDetailView === true; const usesGenericSummary = supportsDetails && !supportsCompactSummary; + const eager = mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"); return { width: mobile?.width ?? (item.kind === "app" ? 1 : 2), @@ -15,8 +16,8 @@ export const resolveMobileItemPresentation = ( displayMode: supportsCompactSummary || usesGenericSummary ? ("mobileSummary" as const) : ("default" as const), supportsDetails, usesGenericSummary, - eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"), - unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false), + eager, + unmountWhenOffscreen: eager ? false : (mobile?.unmountWhenOffscreen ?? false), }; }; diff --git a/apps/nextjs/src/components/board/use-mobile-board.spec.ts b/apps/nextjs/src/components/board/use-mobile-board.spec.ts index df8827ac5..026f9767d 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.spec.ts +++ b/apps/nextjs/src/components/board/use-mobile-board.spec.ts @@ -12,7 +12,7 @@ describe("resolveMobileBoardViewport", () => { resolveMobileBoardViewport({ deviceClass: "phone", matchesMobileWidth: false, - hasResolvedClientWidth: true, + hasMounted: true, }), ).toEqual({ deviceClass: "phone", @@ -29,7 +29,7 @@ describe("resolveMobileBoardViewport", () => { resolveMobileBoardViewport({ deviceClass: "tablet", matchesMobileWidth, - hasResolvedClientWidth: true, + hasMounted: true, }).isMobile, ).toBe(expectedIsMobile); }); @@ -39,7 +39,7 @@ describe("resolveMobileBoardViewport", () => { resolveMobileBoardViewport({ deviceClass: "tablet", matchesMobileWidth: false, - hasResolvedClientWidth: false, + hasMounted: false, }).isResolved, ).toBe(false); }); @@ -52,7 +52,7 @@ describe("resolveMobileBoardViewport", () => { resolveMobileBoardViewport({ deviceClass: "desktop", matchesMobileWidth, - hasResolvedClientWidth: true, + hasMounted: true, }), ).toMatchObject({ isMobile: expectedIsMobile, @@ -73,7 +73,9 @@ describe("resolveIsAutomaticMobileBoard", () => { expect(resolveIsAutomaticMobileBoard({ isMobileViewport, enableAutomaticMobileLayout })).toBe(expected); }, ); +}); +describe("shouldShowMobileBoardViewportSkeleton", () => { test("does not show the tablet resolution skeleton in legacy mode", () => { expect( shouldShowMobileBoardViewportSkeleton({ @@ -82,4 +84,13 @@ describe("resolveIsAutomaticMobileBoard", () => { }), ).toBe(false); }); + + test("shows a skeleton while an automatic mobile viewport is unresolved", () => { + expect( + shouldShowMobileBoardViewportSkeleton({ + enableAutomaticMobileLayout: true, + isResolved: false, + }), + ).toBe(true); + }); }); diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx index 1972206f2..cce597c61 100644 --- a/apps/nextjs/src/components/board/use-mobile-board.tsx +++ b/apps/nextjs/src/components/board/use-mobile-board.tsx @@ -26,15 +26,15 @@ const MobileBoardViewportContext = createContext({ export const resolveMobileBoardViewport = ({ deviceClass, matchesMobileWidth, - hasResolvedClientWidth, + hasMounted, }: { deviceClass: MobileBoardDeviceClass; matchesMobileWidth: boolean; - hasResolvedClientWidth: boolean; + hasMounted: boolean; }): MobileBoardViewport => ({ deviceClass, isMobile: deviceClass === "phone" || matchesMobileWidth, - isResolved: deviceClass !== "tablet" || hasResolvedClientWidth, + isResolved: deviceClass !== "tablet" || hasMounted, }); const MobileBoardViewportSkeleton = () => ( @@ -73,19 +73,19 @@ export const MobileBoardViewportProvider = ({ children, }: PropsWithChildren<{ initialDeviceClass: MobileBoardDeviceClass }>) => { const { enableAutomaticMobileLayout } = useSettings(); - const [hasResolvedClientWidth, setHasResolvedClientWidth] = useState(false); + const [hasMounted, setHasMounted] = useState(false); const matchesMobileWidth = useMediaQuery(mobileBoardMediaQuery, initialDeviceClass === "phone", { getInitialValueInEffect: true, }); useEffect(() => { - setHasResolvedClientWidth(true); + setHasMounted(true); }, []); const viewport = resolveMobileBoardViewport({ deviceClass: initialDeviceClass, matchesMobileWidth, - hasResolvedClientWidth, + hasMounted, }); return ( diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts index 6c929b16e..879a3f595 100644 --- a/e2e/mobile-board.spec.ts +++ b/e2e/mobile-board.spec.ts @@ -161,15 +161,40 @@ describe("Automatic mobile board", () => { await setAutomaticMobileLayoutAsync(loginPage, true); const storageState = await loginContext.storageState(); - const mobileViewports = [ - { name: "small phone", width: 320, height: 568, userAgent: devices["iPhone 13"].userAgent, phone: true }, - { name: "phone", width: 390, height: 844, userAgent: devices["iPhone 13"].userAgent, phone: true }, - { name: "large phone", width: 430, height: 932, userAgent: devices["iPhone 13"].userAgent, phone: true }, + const iPhoneUserAgent = devices["iPhone 13"].userAgent; + const mobileViewports: { + name: string; + width: number; + height: number; + userAgent: string; + phone: boolean; + checksZoomOverflow?: boolean; + checksInteractions?: boolean; + reducedMotion?: boolean; + }[] = [ + { + name: "small phone", + width: 320, + height: 568, + userAgent: iPhoneUserAgent, + phone: true, + checksZoomOverflow: true, + }, + { + name: "phone", + width: 390, + height: 844, + userAgent: iPhoneUserAgent, + phone: true, + checksInteractions: true, + reducedMotion: true, + }, + { name: "large phone", width: 430, height: 932, userAgent: iPhoneUserAgent, phone: true }, { name: "phone landscape", width: 844, height: 390, - userAgent: devices["iPhone 13"].userAgent, + userAgent: iPhoneUserAgent, phone: true, }, { @@ -188,7 +213,7 @@ describe("Automatic mobile board", () => { userAgent: viewport.userAgent, hasTouch: true, isMobile: viewport.phone, - reducedMotion: viewport.width === 390 ? "reduce" : "no-preference", + reducedMotion: viewport.reducedMotion ? "reduce" : "no-preference", }); const page = await context.newPage(); await page.goto(`${baseUrl}/boards/${boardName}`); @@ -234,7 +259,7 @@ describe("Automatic mobile board", () => { expect(layoutMetrics.outOfBoundsControls, viewport.name).toBe(0); expect(layoutMetrics.layoutControls, viewport.name).toBe(0); - if (viewport.width === 320) { + if (viewport.checksZoomOverflow) { await page.evaluate(() => { document.documentElement.style.fontSize = "200%"; }); @@ -248,7 +273,7 @@ describe("Automatic mobile board", () => { expect(zoomOverflow.items).toBe(0); } - if (viewport.width === 390) { + if (viewport.checksInteractions) { await page.getByRole("button", { name: `Current board: ${boardName}` }).click(); await expect(page.getByRole("menuitem", { name: boardName })).toBeDisabled(); await page.keyboard.press("Escape"); diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts index dc117fc0c..2a603c757 100644 --- a/packages/api/src/router/serverSettings.ts +++ b/packages/api/src/router/serverSettings.ts @@ -5,10 +5,10 @@ import { and, eq, inArray } from "@homarr/db"; import { getServerSettingByKeyAsync, getServerSettingsAsync, - insertServerSettingByKeyAsync, + mergeServerSettingByKeyAsync, updateServerSettingByKeyAsync, } from "@homarr/db/queries"; -import { boards, serverSettings } from "@homarr/db/schema"; +import { boards } from "@homarr/db/schema"; import type { ServerSettings } from "@homarr/server-settings"; import { defaultServerSettingsKeys } from "@homarr/server-settings"; import { settingsInitSchema } from "@homarr/validation/settings"; @@ -82,17 +82,9 @@ export const serverSettingsRouter = createTRPCRouter({ const current = await getServerSettingByKeyAsync(ctx.db, "board"); const next = { ...current, ...input }; - const existing = await ctx.db.query.serverSettings.findFirst({ - where: eq(serverSettings.settingKey, "board"), - }); + await mergeServerSettingByKeyAsync(ctx.db, "board", next, input); - if (existing) { - await updateServerSettingByKeyAsync(ctx.db, "board", next); - } else { - await insertServerSettingByKeyAsync(ctx.db, "board", next); - } - - return next; + return await getServerSettingByKeyAsync(ctx.db, "board"); }), saveSettings: permissionRequiredProcedure .requiresPermission("admin") @@ -108,15 +100,7 @@ export const serverSettingsRouter = createTRPCRouter({ ...current, ...input.value, } as ServerSettings[keyof ServerSettings]; - const existing = await ctx.db.query.serverSettings.findFirst({ - where: eq(serverSettings.settingKey, input.settingsKey), - }); - - if (existing) { - await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, next); - } else { - await insertServerSettingByKeyAsync(ctx.db, input.settingsKey, next); - } + await mergeServerSettingByKeyAsync(ctx.db, input.settingsKey, next, input.value); }), initSettings: onboardingProcedure .requiresStep("settings") diff --git a/packages/api/src/router/test/serverSettings.spec.ts b/packages/api/src/router/test/serverSettings.spec.ts index d3d43b213..df1e917cf 100644 --- a/packages/api/src/router/test/serverSettings.spec.ts +++ b/packages/api/src/router/test/serverSettings.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; -import { boards, serverSettings } from "@homarr/db/schema"; +import { boards, layouts, serverSettings } from "@homarr/db/schema"; import { createDb } from "@homarr/db/test"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; @@ -116,6 +116,33 @@ describe("saveSettings", () => { }, ]); }); + + test("saveSettings should preserve concurrent partial updates", async () => { + const db = createDb(); + const caller = serverSettingsRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSession, + }); + + await Promise.all([ + caller.saveSettings({ + settingsKey: "analytics", + value: { enableGeneral: false }, + }), + caller.saveSettings({ + settingsKey: "analytics", + value: { instanceId: "concurrent-instance" }, + }), + ]); + + await expect(caller.getAll()).resolves.toMatchObject({ + analytics: { + enableGeneral: false, + instanceId: "concurrent-instance", + }, + }); + }); }); describe("board settings API", () => { @@ -137,6 +164,12 @@ describe("board settings API", () => { deviceType: undefined, session: defaultSession, }); + const boardId = createId(); + await db.insert(boards).values({ id: boardId, name: "legacy", isPublic: false }); + await db.insert(layouts).values([ + { id: createId(), boardId, name: "Base", columnCount: 12, breakpoint: 0 }, + { id: createId(), boardId, name: "Mobile", columnCount: 4, breakpoint: 480 }, + ]); const legacyBoardSettings = { homeBoardId: defaultServerSettings.board.homeBoardId, mobileHomeBoardId: defaultServerSettings.board.mobileHomeBoardId, @@ -148,7 +181,10 @@ describe("board settings API", () => { value: stringify(legacyBoardSettings), }); - await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board); + await expect(caller.getBoardSettings()).resolves.toStrictEqual({ + ...defaultServerSettings.board, + enableAutomaticMobileLayout: false, + }); }); test("updateBoardSettings should insert settings when defaults are not persisted", async () => { @@ -213,6 +249,25 @@ describe("board settings API", () => { ]); }); + test("updateBoardSettings should preserve concurrent partial updates", async () => { + const db = createDb(); + const caller = serverSettingsRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSession, + }); + + await Promise.all([ + caller.updateBoardSettings({ forceDisableStatus: true }), + caller.updateBoardSettings({ enableStatusByDefault: false }), + ]); + + await expect(caller.getBoardSettings()).resolves.toMatchObject({ + forceDisableStatus: true, + enableStatusByDefault: false, + }); + }); + test("updateBoardSettings should reject private home boards", async () => { const db = createDb(); const caller = serverSettingsRouter.createCaller({ diff --git a/packages/api/src/settle-integrations.spec.ts b/packages/api/src/settle-integrations.spec.ts index efddcf136..34bdf43a9 100644 --- a/packages/api/src/settle-integrations.spec.ts +++ b/packages/api/src/settle-integrations.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; -import { getIntegrationQueryProvenance, settleIntegrationQueries } from "./settle-integrations"; +import { settleIntegrationQueries, settleIntegrationQueriesWithProvenance } from "./settle-integrations"; const integrations = [ { id: "first", name: "First", kind: "mock" }, @@ -77,18 +77,23 @@ describe("settleIntegrationQueries", () => { }); }); -describe("getIntegrationQueryProvenance", () => { - it("counts dropped and stale integration results independently", () => { - expect(getIntegrationQueryProvenance(4, [{ isStale: false }, { isStale: true }, { isStale: true }])).toEqual({ - failedIntegrationCount: 1, - staleIntegrationCount: 2, - }); - }); +describe("settleIntegrationQueriesWithProvenance", () => { + it("counts failed queries even when fallbacks preserve the result count", async () => { + const { results, ...provenance } = await settleIntegrationQueriesWithProvenance( + integrations, + vi.fn(async (integration) => { + if (integration.id === "first") throw new Error("offline"); + return { integrationId: integration.id, isStale: true }; + }), + { + fallback: (integration) => ({ integrationId: integration.id, isStale: false }), + }, + ); - it("does not report failures for complete current results", () => { - expect(getIntegrationQueryProvenance(2, [{ isStale: false }, {}])).toEqual({ - failedIntegrationCount: 0, - staleIntegrationCount: 0, + expect(results).toHaveLength(2); + expect(provenance).toEqual({ + failedIntegrationCount: 1, + staleIntegrationCount: 1, }); }); }); diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index 4cd282d0c..d64e866ef 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -25,19 +25,16 @@ export interface IntegrationQueryProvenance { staleIntegrationCount: number; } -export const getIntegrationQueryProvenance = ( - selectedIntegrationCount: number, - results: readonly { isStale?: boolean }[], -): IntegrationQueryProvenance => ({ - failedIntegrationCount: Math.max(0, selectedIntegrationCount - results.length), - staleIntegrationCount: results.filter((result) => result.isStale === true).length, -}); +interface IntegrationQuerySettlement { + results: TResult[]; + failedIntegrationCount: number; +} -export async function settleIntegrationQueries( +const settleIntegrationQueriesInternal = async ( integrations: TIntegration[], fn: (integration: TIntegration) => Promise, options?: Options, -): Promise { +): Promise> => { const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); const results: TResult[] = []; const errors: unknown[] = []; @@ -76,5 +73,32 @@ export async function settleIntegrationQueries result.status === "rejected").length, + }; +}; + +export async function settleIntegrationQueries( + integrations: TIntegration[], + fn: (integration: TIntegration) => Promise, + options?: Options, +): Promise { + return (await settleIntegrationQueriesInternal(integrations, fn, options)).results; +} + +export async function settleIntegrationQueriesWithProvenance< + TIntegration extends IntegrationLike, + TResult extends { isStale?: boolean }, +>( + integrations: TIntegration[], + fn: (integration: TIntegration) => Promise, + options?: Options, +): Promise & IntegrationQueryProvenance> { + const settlement = await settleIntegrationQueriesInternal(integrations, fn, options); + + return { + ...settlement, + staleIntegrationCount: settlement.results.filter((result) => result.isStale === true).length, + }; } diff --git a/packages/boards/src/context.spec.ts b/packages/boards/src/context.spec.ts index a4ebfcb25..64a64732e 100644 --- a/packages/boards/src/context.spec.ts +++ b/packages/boards/src/context.spec.ts @@ -30,6 +30,17 @@ describe("board layout selection", () => { expect(getCurrentLayout(createBoard(), false, viewportWidth)).toBe(expectedLayoutId); }); + test("legacy mode falls back to the desktop layout before the client viewport is known", () => { + expect(getCurrentLayout(createBoard(), false, undefined)).toBe("large"); + }); + + test("rejects boards without a layout", () => { + const board = { ...createBoard(), layouts: [] }; + + expect(() => getDesktopLayout(board)).toThrow("Board must have a layout"); + expect(() => getCurrentLayout(board, false, 390)).toThrow("Board must have a layout"); + }); + test("keeps every saved layout available for reversible mutations", () => { expect(getBoardLayouts(createBoard())).toEqual(["small", "medium", "large"]); }); diff --git a/packages/boards/src/edit-mode.spec.ts b/packages/boards/src/edit-mode.spec.ts index 25bf237f8..8650a3134 100644 --- a/packages/boards/src/edit-mode.spec.ts +++ b/packages/boards/src/edit-mode.spec.ts @@ -27,4 +27,15 @@ describe("requestBoardEditAction", () => { void deferredAction?.(); expect(action).toHaveBeenCalledOnce(); }); + + test("reports rejected actions instead of creating an unhandled rejection", async () => { + const error = new Error("sign-out failed"); + const reportError = vi.fn(); + vi.stubGlobal("reportError", reportError); + + requestBoardEditAction(() => Promise.reject(error)); + + await vi.waitFor(() => expect(reportError).toHaveBeenCalledWith(error)); + vi.unstubAllGlobals(); + }); }); diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx index ce5395b30..857427b38 100644 --- a/packages/boards/src/edit-mode.tsx +++ b/packages/boards/src/edit-mode.tsx @@ -12,9 +12,27 @@ export type BoardEditActionResult = boolean | void; export type BoardEditAction = () => BoardEditActionResult | Promise; export type BoardEditActionEvent = CustomEvent<{ action: BoardEditAction }>; +const reportBoardEditActionError = (error: unknown) => { + const reportError = (globalThis as { reportError?: (reportedError: unknown) => void }).reportError; + if (reportError) { + reportError(error); + return; + } + + console.error("Board edit action failed", error); +}; + +const runBoardEditAction = (action: BoardEditAction) => { + try { + void Promise.resolve(action()).catch(reportBoardEditActionError); + } catch (error) { + reportBoardEditActionError(error); + } +}; + export const requestBoardEditAction = (action: BoardEditAction) => { if (typeof document === "undefined") { - void action(); + runBoardEditAction(action); return; } @@ -24,7 +42,7 @@ export const requestBoardEditAction = (action: BoardEditAction) => { }); if (document.dispatchEvent(event)) { - void action(); + runBoardEditAction(action); } }; diff --git a/packages/db/collection.ts b/packages/db/collection.ts index b70418759..9839dbf06 100644 --- a/packages/db/collection.ts +++ b/packages/db/collection.ts @@ -5,7 +5,7 @@ import { objectEntries } from "@homarr/common"; import { dbEnv } from "@homarr/core/infrastructure/db/env"; import type { HomarrDatabase, HomarrDatabaseMysql, HomarrDatabasePostgresql } from "./driver"; -import * as schema from "./schema"; +import { schema } from "./schema"; type TableKey = { [K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never; diff --git a/packages/db/migrations/seed.spec.ts b/packages/db/migrations/seed.spec.ts index b9139ace1..5346026c0 100644 --- a/packages/db/migrations/seed.spec.ts +++ b/packages/db/migrations/seed.spec.ts @@ -37,6 +37,15 @@ describe("automatic mobile layout upgrade default", () => { ).toBe(false); }); + test("preserves legacy mode when a board has duplicate canonical layouts", () => { + expect( + shouldEnableAutomaticMobileLayoutForUpgrade([ + { boardId: "board", name: "Base", breakpoint: 0 }, + { boardId: "board", name: "Base", breakpoint: 0 }, + ]), + ).toBe(false); + }); + test.each([ { boardId: "board", name: "Phone", breakpoint: 0 }, { boardId: "board", name: "Base", breakpoint: 480 }, diff --git a/packages/db/queries/server-setting.ts b/packages/db/queries/server-setting.ts index 679db8752..18f967582 100644 --- a/packages/db/queries/server-setting.ts +++ b/packages/db/queries/server-setting.ts @@ -4,8 +4,12 @@ import type { ServerSettings } from "@homarr/server-settings"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; import type { Database } from ".."; -import { eq } from ".."; +import { eq, sql } from ".."; +import { isMysql, isPostgresql } from "../collection"; +import type { HomarrDatabaseMysql, HomarrDatabasePostgresql } from "../driver"; import { serverSettings } from "../schema"; +import { serverSettings as mysqlServerSettings } from "../schema/mysql"; +import { serverSettings as postgresqlServerSettings } from "../schema/postgresql"; interface ExistingBoardLayout { boardId: string; @@ -45,6 +49,7 @@ const getAutomaticMobileLayoutUpgradeDefaultAsync = async (db: Database) => { export const getServerSettingsAsync = async (db: Database) => { const settings = await db.query.serverSettings.findMany(); + let parsedBoardSettings: Partial = {}; const mergedSettings = defaultServerSettingsKeys.reduce((acc, settingKey) => { const setting = settings.find((candidate) => candidate.settingKey === settingKey); @@ -55,6 +60,9 @@ export const getServerSettingsAsync = async (db: Database) => { } const persistedSettings = parse>(setting.value); + if (settingKey === "board") { + parsedBoardSettings = persistedSettings as Partial; + } acc[settingKey] = { ...defaultServerSettings[settingKey], ...persistedSettings, @@ -62,11 +70,6 @@ export const getServerSettingsAsync = async (db: Database) => { return acc; }, {} as ServerSettings); - const persistedBoardSettings = settings.find((setting) => setting.settingKey === "board"); - const parsedBoardSettings = persistedBoardSettings - ? parse>(persistedBoardSettings.value) - : {}; - if (typeof parsedBoardSettings.enableAutomaticMobileLayout !== "boolean") { mergedSettings.board.enableAutomaticMobileLayout = await getAutomaticMobileLayoutUpgradeDefaultAsync(db); } @@ -130,3 +133,53 @@ export const insertServerSettingByKeyAsync = async ( + db: Database, + key: TKey, + value: ServerSettings[TKey], + patch: Partial, +) => { + const serializedValue = stringify(value); + const serializedPatch = stringify(patch); + + if (isMysql()) { + await (db as unknown as HomarrDatabaseMysql) + .insert(mysqlServerSettings) + .values({ settingKey: key, value: serializedValue }) + .onDuplicateKeyUpdate({ + set: { + value: sql`JSON_MERGE_PATCH(${mysqlServerSettings.value}, ${serializedPatch})`, + }, + }); + return; + } + + if (isPostgresql()) { + await (db as unknown as HomarrDatabasePostgresql) + .insert(postgresqlServerSettings) + .values({ settingKey: key, value: serializedValue }) + .onConflictDoUpdate({ + target: postgresqlServerSettings.settingKey, + set: { + value: sql`jsonb_set( + ${postgresqlServerSettings.value}::jsonb, + '{json}', + (${postgresqlServerSettings.value}::jsonb -> 'json') || (${serializedPatch}::jsonb -> 'json'), + true + )::text`, + }, + }); + return; + } + + await db + .insert(serverSettings) + .values({ settingKey: key, value: serializedValue }) + .onConflictDoUpdate({ + target: serverSettings.settingKey, + set: { + value: sql`json_patch(${serverSettings.value}, ${serializedPatch})`, + }, + }); +}; diff --git a/packages/request-handler/src/lib/request-handler.spec.ts b/packages/request-handler/src/lib/request-handler.spec.ts index 85ee34339..bf2294d6a 100644 --- a/packages/request-handler/src/lib/request-handler.spec.ts +++ b/packages/request-handler/src/lib/request-handler.spec.ts @@ -31,6 +31,17 @@ afterEach(() => { vi.useRealTimers(); }); +const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, reject, resolve }; +}; + describe("createRequestHandler", () => { it("deduplicates in-flight work and caches completed data", async () => { let calls = 0; @@ -221,4 +232,47 @@ describe("createWidgetRequestHandler", () => { await expect(handler.handler({ id: "two" }).getDataAsync()).resolves.toMatchObject({ data: "two-2" }); expect(calls).toBe(2); }); + + test("does not let a pre-invalidation request overwrite a newer cached result", async () => { + const first = createDeferred(); + const second = createDeferred(); + const requestAsync = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const requestHandler = createRequestHandler({ requestAsync }); + const handler = requestHandler.handler({ integrationId: "first" }); + + const oldRequest = handler.getDataWithProvenanceAsync(); + requestHandler.invalidateCache(); + const newRequest = handler.getDataWithProvenanceAsync(); + + second.resolve("new"); + await expect(newRequest).resolves.toMatchObject({ data: "new" }); + first.resolve("old"); + await expect(oldRequest).resolves.toMatchObject({ data: "old" }); + + await expect(handler.getDataWithProvenanceAsync()).resolves.toMatchObject({ data: "new" }); + expect(requestAsync).toHaveBeenCalledTimes(2); + }); + + test("does not let a pre-invalidation request clear a newer inflight request", async () => { + const first = createDeferred(); + const second = createDeferred(); + const requestAsync = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const requestHandler = createRequestHandler({ requestAsync }); + const handler = requestHandler.handler({ integrationId: "first" }); + + const oldRequest = handler.getDataWithProvenanceAsync(); + requestHandler.invalidateCache(); + const newRequest = handler.getDataWithProvenanceAsync(); + + first.resolve("old"); + await expect(oldRequest).resolves.toMatchObject({ data: "old" }); + const sharedNewRequest = handler.getDataWithProvenanceAsync(); + expect(requestAsync).toHaveBeenCalledTimes(2); + + second.resolve("new"); + await expect(Promise.all([newRequest, sharedNewRequest])).resolves.toEqual([ + expect.objectContaining({ data: "new" }), + expect.objectContaining({ data: "new" }), + ]); + }); }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a4cd8c729..f2e68200f 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4031,6 +4031,7 @@ "main": "Media Stats", "approved": "Already approved", "pending": "Pending approvals", + "pendingCount": "{pending} pending approvals", "processing": "Being processed", "declined": "Already declined", "available": "Already Available", diff --git a/packages/widgets/package.json b/packages/widgets/package.json index d0b866d98..d5d9740f2 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -15,9 +15,10 @@ ".": "./index.ts", "./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx", "./custom-api/component": "./src/custom-api/component.tsx", + "./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx", + "./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts", "./catalog": "./src/catalog.ts", "./definition": "./src/definition.ts", - "./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx", "./errors": "./src/errors/component.tsx", "./manifest": "./src/manifest.ts", "./options": "./src/options.ts", @@ -80,6 +81,7 @@ "@tiptap/extension-underline": "catalog:", "@tiptap/react": "catalog:", "@tiptap/starter-kit": "catalog:", + "@trpc/react-query": "catalog:", "acorn": "catalog:", "acorn-jsx": "catalog:", "clsx": "catalog:", diff --git a/packages/widgets/src/common/query-state.tsx b/packages/widgets/src/common/query-state.tsx index 2347a8b57..250ab48be 100644 --- a/packages/widgets/src/common/query-state.tsx +++ b/packages/widgets/src/common/query-state.tsx @@ -43,11 +43,13 @@ export function WidgetDataState({ children, hasWarning }: WidgetDataStateProps) return ( + {children} {hasWarning && ( - +
)} - {children} ); } diff --git a/packages/widgets/src/rssFeed/sanitize-description.spec.ts b/packages/widgets/src/rssFeed/sanitize-description.spec.ts new file mode 100644 index 000000000..1f3439274 --- /dev/null +++ b/packages/widgets/src/rssFeed/sanitize-description.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "vitest"; + +import { sanitizeFeedDescription } from "./sanitize-description"; + +describe("sanitizeFeedDescription", () => { + test("keeps basic formatting while removing executable markup", () => { + const description = + ''; + + expect(sanitizeFeedDescription(description)).toBe("

News today

"); + }); +}); diff --git a/packages/widgets/src/rssFeed/sanitize-description.ts b/packages/widgets/src/rssFeed/sanitize-description.ts new file mode 100644 index 000000000..811d5f2b0 --- /dev/null +++ b/packages/widgets/src/rssFeed/sanitize-description.ts @@ -0,0 +1,11 @@ +import { sanitize } from "isomorphic-dompurify"; + +const allowedDescriptionTags = ["b", "br", "em", "i", "li", "ol", "p", "s", "strong", "u", "ul"]; + +export const sanitizeFeedDescription = (description: string) => + sanitize(description, { + ALLOW_ARIA_ATTR: false, + ALLOW_DATA_ATTR: false, + ALLOWED_ATTR: [], + ALLOWED_TAGS: allowedDescriptionTags, + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdc849556..06968df00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1574,9 +1574,6 @@ importers: '@homarr/api': specifier: workspace:^0.1.0 version: link:../api - '@homarr/settings': - specifier: workspace:^0.1.0 - version: link:../settings react: specifier: 'catalog:' version: 19.2.7 @@ -2575,9 +2572,6 @@ importers: '@homarr/auth': specifier: workspace:^0.1.0 version: link:../auth - '@homarr/boards': - specifier: workspace:^0.1.0 - version: link:../boards '@homarr/common': specifier: workspace:^0.1.0 version: link:../common