From f24d757ec8dfba236dba32bf543bae20e6d9a696 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 8 Aug 2026 21:42:13 +0200 Subject: [PATCH] fix: preserve app links and hover previews --- .../board/advanced-focus/context.tsx | 3 ++- .../board/sections/use-open-section-apps.ts | 7 ++++--- packages/common/src/url.ts | 16 ++++++++++++++++ packages/widgets/src/app/component.tsx | 4 ++-- packages/widgets/src/bookmarks/component.tsx | 10 +++++----- .../widgets/src/common/application-url.spec.ts | 18 +++++++++++++++++- packages/widgets/src/common/application-url.ts | 2 +- 7 files changed, 47 insertions(+), 13 deletions(-) diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx index 6043a1a95..5a6d42131 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -170,7 +170,8 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { const leave = useCallback( (itemId: string) => { - if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null; + if (hoveredRef.current?.itemId !== itemId) return; + hoveredRef.current = null; cancelHoverTimer(); }, [cancelHoverTimer], diff --git a/apps/nextjs/src/components/board/sections/use-open-section-apps.ts b/apps/nextjs/src/components/board/sections/use-open-section-apps.ts index b8f7bb64f..d462fdda9 100644 --- a/apps/nextjs/src/components/board/sections/use-open-section-apps.ts +++ b/apps/nextjs/src/components/board/sections/use-open-section-apps.ts @@ -2,6 +2,7 @@ import { use, useCallback, useMemo } from "react"; import { clientApi } from "@homarr/api/client"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { getSafeAppHref } from "@homarr/common"; import { useConfirmModal } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; @@ -107,10 +108,10 @@ export const useOpenSectionApps = (sectionId: string, enabled: boolean) => { }); const open = useCallback(() => { - const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0); + const appUrls = apps.map((app) => getSafeAppHref(app.href)).filter((href) => href !== undefined); - for (const app of appsWithUrls) { - const openedWindow = window.open(app.href ?? undefined); + for (const href of appUrls) { + const openedWindow = window.open(href); if (openedWindow) continue; openConfirmModal({ diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts index 079de8418..271d380e0 100644 --- a/packages/common/src/url.ts +++ b/packages/common/src/url.ts @@ -75,6 +75,22 @@ export const getSafeApplicationUrl = (value: unknown, options: SafeApplicationUr } }; +/** Returns a safe user-configured app link, including same-origin paths and custom URI schemes. */ +export const getSafeAppHref = (value: unknown): string | undefined => { + if (typeof value !== "string" || value.trim() === "") return undefined; + + const href = value.trim(); + if (/^(?:javascript|data|vbscript|file|blob):/i.test(href)) return undefined; + + try { + const url = new URL(href); + if (url.username !== "" || url.password !== "") return undefined; + return url.toString(); + } catch { + return /^(?:[#?]|\/(?![\\/])|\.{1,2}\/)/.test(href) ? href : undefined; + } +}; + const parseHttpUrl = (value: unknown, baseUrl?: string): URL | undefined => { if (typeof value !== "string") return undefined; const url = new URL(value, baseUrl); diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 6e84876fd..acda12e12 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -14,7 +14,7 @@ import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; import { WidgetEmptyState } from "../common/empty-state"; -import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; @@ -28,7 +28,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget const board = useRequiredBoard(); const appQuery = clientApi.app.byId.useQuery({ id: options.appId }); const app = getUsableWidgetQueryData(appQuery); - const href = getSafeApplicationUrl(app?.href); + const href = getSafeAppHref(app?.href); useRegisterSpotlightContextResults( `app-${app?.id ?? options.appId}`, app && href diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index c241549ae..13e211f53 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -23,7 +23,7 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; import { MaskedOrNormalImage } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; -import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getUsableWidgetQueryData } from "../common/query-state"; import classes from "./bookmark.module.css"; @@ -79,7 +79,7 @@ export default function BookmarksWidget({ useRegisterSpotlightContextResults( `bookmark-${itemId}`, data.flatMap((app) => { - const href = getSafeApplicationUrl(app.href); + const href = getSafeAppHref(app.href); return href ? [ { @@ -187,7 +187,7 @@ const FlexLayout = ({ return ( {data.map((app) => { - const href = getSafeApplicationUrl(app.href); + const href = getSafeAppHref(app.href); return (
{data.map((app) => { - const href = getSafeApplicationUrl(app.href); + const href = getSafeAppHref(app.href); return ( {data.map((app) => { - const href = getSafeApplicationUrl(app.href); + const href = getSafeAppHref(app.href); return ( { test.each(["javascript:alert(1)", "data:text/html,test", "not a URL", "/relative", "./relative"])( @@ -28,3 +28,19 @@ describe("getSafeApplicationUrl", () => { expect(getSafeApplicationUrl(value)).toBeUndefined(); }); }); + +describe("getSafeAppHref", () => { + test.each(["#section", "/boards/home", "./relative", "vscode://file/example.ts", "https://example.com/path"])( + "allows configured app link %s", + (value) => expect(getSafeAppHref(value)).toBe(value), + ); + + test.each([ + "javascript:alert(1)", + "data:text/html,test", + "file:///etc/passwd", + "//example.com/path", + "not a URL", + "https://user:password@example.com/path", + ])("rejects unsafe app link %s", (value) => expect(getSafeAppHref(value)).toBeUndefined()); +}); diff --git a/packages/widgets/src/common/application-url.ts b/packages/widgets/src/common/application-url.ts index cfbef8abc..b43d335f3 100644 --- a/packages/widgets/src/common/application-url.ts +++ b/packages/widgets/src/common/application-url.ts @@ -1 +1 @@ -export { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common"; +export { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common";