fix: preserve app links and hover previews

This commit is contained in:
Thomas Camlong committed 2026-08-08 21:42:13 +02:00
1 parent 9edeba5cd2
commit f24d757ec8
7 files changed
+47 -13

No files matched your search

@@ -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],
@@ -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({
+16
View File
@@ -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);
+2 -2
View File
@@ -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
+5 -5
View File
@@ -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 (
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
const href = getSafeAppHref(app.href);
return (
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
<UnstyledButton
@@ -263,7 +263,7 @@ const AdvancedBookmarksLayout = ({
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
const href = getSafeAppHref(app.href);
return (
<UnstyledButton
key={app.id}
@@ -342,7 +342,7 @@ const GridLayout = ({
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
>
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
const href = getSafeAppHref(app.href);
return (
<UnstyledButton
className={classes.bookmarkButton}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getSafeApplicationUrl } from "./application-url";
import { getSafeApplicationUrl, getSafeAppHref } from "./application-url";
describe("getSafeApplicationUrl", () => {
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());
});
@@ -1 +1 @@
export { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
export { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "@homarr/common";