diff --git a/packages/api/src/router/test/widgets/app.spec.ts b/packages/api/src/router/test/widgets/app.spec.ts index 0a14c7c965..4fd4a443f4 100644 --- a/packages/api/src/router/test/widgets/app.spec.ts +++ b/packages/api/src/router/test/widgets/app.spec.ts @@ -1,3 +1,4 @@ +import { TRPCError } from "@trpc/server"; import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; @@ -67,10 +68,51 @@ describe("ping should call sendPingRequestAsync with url and return result", () }); }); -const createApp = ({ url }: { url: string }) => +const createApp = ({ url, pingUrl }: { url: string | null; pingUrl?: string | null }) => ({ id: createId(), iconUrl: "", name: "Test App", href: url, + pingUrl: pingUrl ?? null, }) satisfies InferInsertModel; + +describe("ping resolution under path-only hrefs", () => { + test("path-only href without pingUrl throws CONFLICT (no server-side host synthesis)", async () => { + const sendSpy = vi.spyOn(ping, "sendPingRequestAsync"); + const db = createDb(); + const app = createApp({ url: "/cockpit/" }); + await db.insert(apps).values(app); + const caller = appRouter.createCaller({ db, deviceType: undefined, session: null }); + + await expect(caller.ping({ id: app.id })).rejects.toThrow(TRPCError); + await expect(caller.ping({ id: app.id })).rejects.toMatchObject({ code: "CONFLICT" }); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + test("path-only href with explicit pingUrl uses pingUrl", async () => { + const expectedUrl = "https://host.docker.internal/cockpit/"; + vi.spyOn(ping, "sendPingRequestAsync").mockResolvedValueOnce({ statusCode: 204, durationMs: 7 }); + const db = createDb(); + const app = createApp({ url: "/cockpit/", pingUrl: expectedUrl }); + await db.insert(apps).values(app); + const caller = appRouter.createCaller({ db, deviceType: undefined, session: null }); + + const result = await caller.ping({ id: app.id }); + + expect(result.url).toBe(expectedUrl); + }); + + test("absolute href without pingUrl pings the href", async () => { + const expectedUrl = "https://docs.halos.fi"; + vi.spyOn(ping, "sendPingRequestAsync").mockResolvedValueOnce({ statusCode: 200, durationMs: 11 }); + const db = createDb(); + const app = createApp({ url: expectedUrl }); + await db.insert(apps).values(app); + const caller = appRouter.createCaller({ db, deviceType: undefined, session: null }); + + const result = await caller.ping({ id: app.id }); + + expect(result.url).toBe(expectedUrl); + }); +}); diff --git a/packages/api/src/router/widgets/app.ts b/packages/api/src/router/widgets/app.ts index d072417258..7dc30cdcf7 100644 --- a/packages/api/src/router/widgets/app.ts +++ b/packages/api/src/router/widgets/app.ts @@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; import { z } from "zod/v4"; +import { resolveServerUrl } from "@homarr/common"; import { getServerSettingByKeyAsync } from "@homarr/db/queries"; import { sendPingRequestAsync } from "@homarr/ping"; import { pingChannel, pingUrlChannel } from "@homarr/redis"; @@ -29,7 +30,7 @@ export const appRouter = createTRPCRouter({ }); } - const pingUrl = app.pingUrl ?? app.href; + const pingUrl = resolveServerUrl(app); if (!pingUrl) { throw new TRPCError({ @@ -70,7 +71,7 @@ export const appRouter = createTRPCRouter({ }); } - const pingUrl = app.pingUrl ?? app.href; + const pingUrl = resolveServerUrl(app); if (!pingUrl) { throw new TRPCError({ diff --git a/packages/common/src/test/url.spec.ts b/packages/common/src/test/url.spec.ts index 8b6600d934..471679816d 100644 --- a/packages/common/src/test/url.spec.ts +++ b/packages/common/src/test/url.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { getPortFromUrl, isAbsoluteUrl } from "../url.js"; +import { getPortFromUrl, isAbsoluteUrl, resolveServerUrl } from "../url.js"; describe("getPortFromUrl", () => { test.each([ @@ -56,3 +56,33 @@ describe("isAbsoluteUrl", () => { expect(result).toBe(expected); }); }); + +describe("resolveServerUrl", () => { + test("returns explicit pingUrl when set", () => { + expect(resolveServerUrl({ pingUrl: "http://x.local/ping", href: "/anything/" })).toBe("http://x.local/ping"); + }); + + test("returns absolute href as-is when no pingUrl", () => { + expect(resolveServerUrl({ pingUrl: null, href: "https://abs.example.com/x" })).toBe("https://abs.example.com/x"); + }); + + test("returns null for path-only href (no server-side expansion)", () => { + expect(resolveServerUrl({ pingUrl: null, href: "/cockpit/" })).toBeNull(); + }); + + test("returns null for schemeless relative href", () => { + expect(resolveServerUrl({ pingUrl: null, href: "relative/path" })).toBeNull(); + }); + + test("returns null when both pingUrl and href are null", () => { + expect(resolveServerUrl({ pingUrl: null, href: null })).toBeNull(); + }); + + test("path-only href with explicit pingUrl returns the pingUrl", () => { + // The HaLOS-shipped-card scenario: adapter sets explicit pingUrl while + // href stays path-only for browser-side multi-hostname resolution. + expect(resolveServerUrl({ pingUrl: "https://host.docker.internal/cockpit/", href: "/cockpit/" })).toBe( + "https://host.docker.internal/cockpit/", + ); + }); +}); diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts index 7550def44d..ef975f41bc 100644 --- a/packages/common/src/url.ts +++ b/packages/common/src/url.ts @@ -44,3 +44,27 @@ const absoluteUrlRegex = /^[a-z]+:(\/\/)?/; export const isAbsoluteUrl = (urlOrPath: string): boolean => { return absoluteUrlRegex.test(urlOrPath.toLowerCase()); }; + +/** + * Resolves an app to the absolute URL the server should use, or null. + * 1. explicit `pingUrl` -> as-is + * 2. absolute `href` -> as-is + * 3. non-absolute `href` -> null (path-only `/cockpit/`, schemeless `foo/bar`) + * 4. null/empty `href` -> null (short-circuits before the absoluteness check) + * + * Non-absolute hrefs are intentionally null server-side: synthesizing them + * from request headers would be a header-spoofing vector, and the browser + * already resolves them against the current origin natively. Apps that need + * server-side ping coverage should carry an explicit `pingUrl`. + */ +export const resolveServerUrl = (app: { href: string | null; pingUrl: string | null }): string | null => { + if (app.pingUrl) { + return app.pingUrl; + } + + if (app.href && isAbsoluteUrl(app.href)) { + return app.href; + } + + return null; +}; diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts index 22ad8b1ea1..aa4bd6e573 100644 --- a/packages/integrations/src/base/integration.ts +++ b/packages/integrations/src/base/integration.ts @@ -81,8 +81,18 @@ export abstract class Integration { protected externalUrl( path: `/${string}`, queryParams?: Record, - ) { - return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams); + ): URL | RenderablePath { + const base = this.integration.externalUrl ?? this.integration.url; + // Path-only externalUrl (e.g. "/cockpit/") cannot be parsed by `new URL`, + // but the rendered href ends up on the client where the browser resolves + // it against the current origin. Build a hostless RenderablePath instead. + // Scheme-relative bases ("//host/...") are rejected at the schema layer + // (packages/validation/src/app.ts) but explicitly excluded here too so + // that a malformed value can't cross-origin-escape through this branch. + if (base.startsWith("/") && !base.startsWith("//")) { + return new RenderablePath(base, path, queryParams); + } + return this.createUrl(base, path, queryParams); } public async testConnectionAsync(): Promise { @@ -125,3 +135,69 @@ export abstract class Integration { */ protected abstract testingAsync(input: IntegrationTestingInput): Promise; } + +/** + * URL-shaped wrapper for path-only externalUrl bases (e.g. "/cockpit/"). + * + * Path-only externalUrl is meaningful only on the client, where the browser + * resolves it against the current origin. The integration helpers can't use + * `new URL` server-side because there's no host to parse. RenderablePath + * mirrors just enough of the `URL` surface — `toString()`, `pathname`, + * `hostname`, `searchParams` — to keep the 19 caller sites of + * `super.externalUrl(...)` working unchanged. `hostname` is always the empty + * string for a path-only base; `pathname` is the joined base + path. + * + * Fragment handling note: the constructor splits on the first `?` only, not + * `#`. Path arguments that contain a fragment (e.g. Jellyfin / Emby hash-bang + * routes like `/web/index.html#!/details?id=abc`) keep the fragment as part + * of `pathname`, and any `?` inside the hash-bang is treated as the query + * separator. This matches what Jellyfin / Emby SPA routers expect: the + * post-`?` params must stay inside the hash, not be hoisted before it. Don't + * combine fragment-containing paths with extra `queryParams` here — the + * merged params would land inside the hash too. WHATWG URL behaves + * differently (it places `.hash` after `.search`), but mirroring that would + * break the SPA-routing callers this method exists to serve. + */ +export class RenderablePath { + public readonly searchParams: URLSearchParams; + private readonly pathOnly: string; + + constructor( + base: string, + path: `/${string}`, + queryParams?: Record, + ) { + const trimmedBase = removeTrailingSlash(base); + const combined = `${trimmedBase}${path}`; + const queryIndex = combined.indexOf("?"); + if (queryIndex === -1) { + this.pathOnly = combined; + this.searchParams = new URLSearchParams(); + } else { + this.pathOnly = combined.substring(0, queryIndex); + this.searchParams = new URLSearchParams(combined.substring(queryIndex + 1)); + } + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value === null || value === undefined) { + continue; + } + this.searchParams.set(key, value instanceof Date ? value.toISOString() : value.toString()); + } + } + } + + public get hostname(): string { + return ""; + } + + public get pathname(): string { + return this.pathOnly; + } + + public toString(): string { + const query = this.searchParams.toString(); + return query ? `${this.pathOnly}?${query}` : this.pathOnly; + } +} diff --git a/packages/integrations/test/base.spec.ts b/packages/integrations/test/base.spec.ts index 4fcce34580..b6700fb372 100644 --- a/packages/integrations/test/base.spec.ts +++ b/packages/integrations/test/base.spec.ts @@ -4,7 +4,7 @@ import { ResponseError } from "@homarr/common/server"; import { createDb } from "@homarr/db/test"; import type { IntegrationTestingInput } from "../src/base/integration"; -import { Integration } from "../src/base/integration"; +import { Integration, RenderablePath } from "../src/base/integration"; import type { TestingResult } from "../src/base/test-connection/test-connection-service"; vi.mock("@homarr/db", async (importActual) => { @@ -42,22 +42,73 @@ describe("Base integration", () => { expect(result.error.data.url).toContain("https://example.com"); expect(result.error.data.reason).toBe("internalServerError"); }); + + describe("externalUrl", () => { + test("returns a URL for an absolute externalUrl", () => { + const integration = new FakeIntegration(undefined, undefined, "https://example.com"); + const result = integration.callExternalUrl("/items/42"); + expect(result).toBeInstanceOf(URL); + expect(result.toString()).toBe("https://example.com/items/42"); + }); + + test("merges queryParams onto an absolute externalUrl", () => { + const integration = new FakeIntegration(undefined, undefined, "https://example.com"); + const result = integration.callExternalUrl("/items", { id: "42", since: new Date("2026-01-01T00:00:00Z") }); + expect(result.toString()).toBe("https://example.com/items?id=42&since=2026-01-01T00%3A00%3A00.000Z"); + }); + + test("returns a RenderablePath for a path-only externalUrl", () => { + const integration = new FakeIntegration(undefined, undefined, "/cockpit/"); + const result = integration.callExternalUrl("/web/index.html"); + expect(result).toBeInstanceOf(RenderablePath); + expect(result.toString()).toBe("/cockpit/web/index.html"); + expect(result.pathname).toBe("/cockpit/web/index.html"); + expect(result.hostname).toBe(""); + }); + + test("merges queryParams onto a path-only externalUrl", () => { + const integration = new FakeIntegration(undefined, undefined, "/cockpit/"); + const result = integration.callExternalUrl("/web/index.html", { id: "42" }); + expect(result.toString()).toBe("/cockpit/web/index.html?id=42"); + }); + + test("merges path-embedded query with extra queryParams on a path-only externalUrl", () => { + const integration = new FakeIntegration(undefined, undefined, "/signalk-server/"); + const result = integration.callExternalUrl("/items/42?width=100", { quality: "90" }); + expect(result.pathname).toBe("/signalk-server/items/42"); + expect(result.toString()).toBe("/signalk-server/items/42?width=100&quality=90"); + }); + + test("falls back to integration.url when externalUrl is null and the integration url is absolute", () => { + const integration = new FakeIntegration(undefined, undefined, null); + const result = integration.callExternalUrl("/items/42"); + expect(result.toString()).toBe("https://example.com/items/42"); + }); + }); }); class FakeIntegration extends Integration { constructor( private testingResult?: TestingResult, private error?: Error, + externalUrl: string | null = null, ) { super({ id: "test", name: "Test", url: "https://example.com", decryptedSecrets: [], - externalUrl: null, + externalUrl, }); } + public callExternalUrl( + path: `/${string}`, + queryParams?: Record, + ) { + return this.externalUrl(path, queryParams); + } + // eslint-disable-next-line no-restricted-syntax protected testingAsync(_: IntegrationTestingInput): Promise { if (this.error) { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a627288459..9e6e6bea52 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1190,7 +1190,9 @@ "invalidFileName": "Invalid file name", "fileTooLarge": "File is too large, maximum size is {maxSize}", "invalidConfiguration": "Invalid configuration", - "groupNameTaken": "Group name already taken" + "groupNameTaken": "Group name already taken", + "appHrefInvalid": "Must be an absolute URL (https://example.com) or a path starting with / (e.g. /cockpit/)", + "appHrefConsecutiveSlashes": "Path must not contain consecutive slashes" } } }, diff --git a/packages/validation/src/app.ts b/packages/validation/src/app.ts index 21a4335be1..57f95e722b 100644 --- a/packages/validation/src/app.ts +++ b/packages/validation/src/app.ts @@ -1,10 +1,65 @@ import { z } from "zod/v4"; -export const appHrefSchema = z +import { createCustomErrorParams } from "./form/i18n"; + +// `appHrefSchema` accepts: +// - empty string -> null +// - absolute URL with http/https scheme (or any non-javascript scheme) +// - path-only URL starting with "/" followed by a non-"/" character +// +// Path-only hrefs are resolved against the current origin in the browser, and +// against the request origin server-side via `resolveServerUrl`. This lets a +// single dashboard work across multiple hostnames (mDNS, VPN FQDN, DHCP DNS). +// +// Rejects: javascript: scheme, protocol-relative ("//host/..."), single-slash +// root ("/"), bare strings without scheme or leading slash. +const absoluteHrefSchema = z .string() .trim() .url() - .regex(/^(?!javascript)[a-zA-Z]*:\/\//i) // javascript: is not allowed, i for case insensitive (so Javascript: is also not allowed) + .regex(/^(?!javascript)[a-zA-Z]*:\/\//i); + +// Disallowed characters anywhere in a path-only href: +// - "/" past the leading position would either produce protocol-relative +// "//host" (already rejected outright) or run-on slashes "/foo//bar" +// (cosmetic, also rejected for consistency). +// - "\" — WHATWG URL parser normalizes backslash to forward slash for +// http(s), so "/\evil.example.com" rendered into navigates +// cross-origin. JS regex \s does not catch this; explicit reject. +// - JS \s whitespace. +// - C0 / C1 control bytes (U+0000-U+001F, U+007F-U+009F) — invisible in +// admin UI, may survive into the DOM. +// - Bidi / zero-width / formatting characters (U+200B-U+200F, U+2028-U+202F, +// U+2066-U+2069, U+FEFF). These bypass `\s` and enable display-spoofing +// in the rendered sub-label without affecting the resolved navigation +// target — admin-confusion / phishing of secondary navigation. +const PATH_DISALLOWED = "\\\\\\s\\u0000-\\u001F\\u007F-\\u009F\\u200B-\\u200F\\u2028-\\u202F\\u2066-\\u2069\\uFEFF"; + +const PATH_ONLY_REGEX = new RegExp(`^/[^/${PATH_DISALLOWED}][^${PATH_DISALLOWED}]*$`); + +const pathOnlyHrefSchema = z + .string() + .trim() + // Leading "/" followed by at least one non-"/" / non-disallowed char, + // then any mix of allowed chars (including "/" for internal segments). + .refine((value) => PATH_ONLY_REGEX.test(value), { + params: createCustomErrorParams({ + key: "appHrefInvalid", + params: {}, + }), + }) + // Reject consecutive slashes anywhere ("/foo//bar"). Cosmetic on its own + // but keeps the rendered sub-label canonical and matches the leading-"/" + // exclusion of "//host/...". + .refine((value) => !value.includes("//"), { + params: createCustomErrorParams({ + key: "appHrefConsecutiveSlashes", + params: {}, + }), + }); + +export const appHrefSchema = absoluteHrefSchema + .or(pathOnlyHrefSchema) .or(z.literal("")) .transform((value) => (value.length === 0 ? null : value)) .nullable(); diff --git a/packages/validation/src/test/app.spec.ts b/packages/validation/src/test/app.spec.ts new file mode 100644 index 0000000000..505d6253f2 --- /dev/null +++ b/packages/validation/src/test/app.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest"; + +import { appHrefSchema } from "../app"; + +describe("appHrefSchema", () => { + test.each([ + "https://example.com/path", + "http://example.com/path", + "https://example.com", + "/cockpit/", + "/signalk-server/@signalk/freeboard-sk/", + "/x", + ])("accepts %s", (input) => { + expect(appHrefSchema.parse(input)).toBe(input); + }); + + test("transforms empty string to null", () => { + expect(appHrefSchema.parse("")).toBeNull(); + }); + + test("accepts null", () => { + expect(appHrefSchema.parse(null)).toBeNull(); + }); + + test.each([ + // skipcq: JS-0087 — fixture asserts that javascript: scheme is rejected by the schema + ["javascript:alert(1)"], + // skipcq: JS-0087 — fixture asserts that JavaScript: (mixed case) is rejected by the schema + ["JavaScript:alert(1)"], + ["//evil.example.com/path"], + ["/"], + ["cockpit/"], + ["not-a-url"], + ["./relative"], + ["../relative"], + // Browser-normalized cross-origin escapes — WHATWG URL parser collapses + // backslash to forward slash for http(s), so these would navigate + // off-origin if rendered into . Reject up front. + ["/\\evil.example.com/x"], + ["/\\\\evil.example.com/x"], + // Whitespace / control characters anywhere in the path. + ["/foo bar"], + ["/foo\tbar"], + ["/foo\nbar"], + ["/\tfoo"], + // C0 / C1 controls (invisible). + ["/foo"], + ["/foo"], + ["/foo"], + ["/foo"], + // Zero-width / bidi / formatting characters (display-spoofing class). + ["/​foo"], // ZWSP + ["/‌foo"], // ZWNJ + ["/‍foo"], // ZWJ + ["/‮foo"], // RTL override + ["/foo"], // BOM + // Consecutive slashes mid-path. + ["/foo//bar"], + ["/cockpit//"], + ])("rejects %s", (input) => { + expect(() => appHrefSchema.parse(input)).toThrow(); + }); +}); diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 8b8444b3d5..ae2e828a96 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -74,6 +74,7 @@ "next": "catalog:", "react": "catalog:", "react-dom": "catalog:", + "react-error-boundary": "catalog:", "react-markdown": "catalog:", "react-quill-new": "catalog:", "recharts": "catalog:", diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 1caa1ce56f..52214d3c8c 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -1,25 +1,21 @@ "use client"; import type { PropsWithChildren } from "react"; -import { Fragment, Suspense } from "react"; +import { Fragment } from "react"; import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; -import { IconLoader } from "@tabler/icons-react"; import combineClasses from "clsx"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useSettings } from "@homarr/settings"; import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; -import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import classes from "./app.module.css"; -import { PingDot } from "./ping/ping-dot"; import { PingIndicator } from "./ping/ping-indicator"; export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) { - const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); const [app] = clientApi.app.byId.useSuspenseQuery( @@ -130,9 +126,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( - }> - - + ) : null} ); diff --git a/packages/widgets/src/app/ping/ping-indicator.tsx b/packages/widgets/src/app/ping/ping-indicator.tsx index a6299ed95a..b3cd4cb660 100644 --- a/packages/widgets/src/app/ping/ping-indicator.tsx +++ b/packages/widgets/src/app/ping/ping-indicator.tsx @@ -1,8 +1,12 @@ -import { useState } from "react"; -import { IconCheck, IconX } from "@tabler/icons-react"; +import { Suspense, useState } from "react"; +import { IconCheck, IconLoader, IconX } from "@tabler/icons-react"; +import { TRPCClientError } from "@trpc/client"; +import type { FallbackProps } from "react-error-boundary"; +import { ErrorBoundary } from "react-error-boundary"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; import { PingDot } from "./ping-dot"; @@ -11,10 +15,32 @@ interface PingIndicatorProps { } export const PingIndicator = ({ appId }: PingIndicatorProps) => { + const t = useI18n(); + const loadingDot = ; + + return ( + + + + + + ); +}; + +// Apps without a server-pingable URL (e.g. path-only href without an explicit +// pingUrl) yield a CONFLICT. Render an indeterminate orange dot for that case +// so the card stays usable. Other tRPC errors (FORBIDDEN, NOT_FOUND, …) are +// re-thrown so the widget's outer error boundary handles them as before. +const PingIndicatorErrorFallback = ({ error }: FallbackProps) => { + if (error instanceof TRPCClientError && error.data?.code === "CONFLICT") { + return ; + } + throw error; +}; + +const PingIndicatorInner = ({ appId }: PingIndicatorProps) => { const [ping] = clientApi.widget.app.ping.useSuspenseQuery( - { - id: appId, - }, + { id: appId }, { refetchOnMount: false, refetchOnWindowFocus: false, diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index 09066f90d2..e8083a1d40 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -11,6 +11,7 @@ import { MaskedOrNormalImage } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import classes from "./bookmark.module.css"; +import { getHrefSubLabel } from "./sub-label"; export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) { const board = useRequiredBoard(); @@ -232,7 +233,7 @@ const VerticalItem = ({ )} {!hideHostname && ( - {app.href ? new URL(app.href).hostname : undefined} + {getHrefSubLabel(app.href)} )} @@ -279,7 +280,7 @@ const HorizontalItem = ({ {!hideHostname && ( - {app.href ? new URL(app.href).hostname : undefined} + {getHrefSubLabel(app.href)} )} diff --git a/packages/widgets/src/bookmarks/sub-label.ts b/packages/widgets/src/bookmarks/sub-label.ts new file mode 100644 index 0000000000..78f4fcf737 --- /dev/null +++ b/packages/widgets/src/bookmarks/sub-label.ts @@ -0,0 +1,8 @@ +import { removeTrailingSlash } from "@homarr/common"; + +// Path-only hrefs render as the path itself; absolute hrefs render the host. +export const getHrefSubLabel = (href: string | null | undefined): string | undefined => { + if (!href) return undefined; + if (href.startsWith("/")) return removeTrailingSlash(href); + return new URL(href).hostname; +}; diff --git a/packages/widgets/src/bookmarks/test/sub-label.spec.ts b/packages/widgets/src/bookmarks/test/sub-label.spec.ts new file mode 100644 index 0000000000..0998dd1cf6 --- /dev/null +++ b/packages/widgets/src/bookmarks/test/sub-label.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; + +import { getHrefSubLabel } from "../sub-label"; + +describe("getHrefSubLabel", () => { + test.each([[null], [undefined], [""]])("returns undefined for %s", (input) => { + expect(getHrefSubLabel(input)).toBeUndefined(); + }); + + test.each([ + ["https://docs.halos.fi", "docs.halos.fi"], + ["https://docs.halos.fi/path", "docs.halos.fi"], + ["http://example.com:8080/x", "example.com"], + ])("returns hostname for absolute %s", (input, expected) => { + expect(getHrefSubLabel(input)).toBe(expected); + }); + + test.each([ + ["/cockpit/", "/cockpit"], + ["/cockpit", "/cockpit"], + ["/signalk-server/@signalk/freeboard-sk/", "/signalk-server/@signalk/freeboard-sk"], + ["/x", "/x"], + ])("returns trimmed path for path-only %s", (input, expected) => { + expect(getHrefSubLabel(input)).toBe(expected); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d09d3fd6c5..e0a311822c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2460,6 +2460,9 @@ importers: react-dom: specifier: 'catalog:' version: 19.2.6(react@19.2.6) + react-error-boundary: + specifier: 'catalog:' + version: 6.1.1(react@19.2.6) react-markdown: specifier: 'catalog:' version: 10.1.0(@types/react@19.2.15)(react@19.2.6)