feat: add support for app url variables (#915)

* feat: add support for app url variables

* fix: test not working

* fix: format issue
This commit is contained in:
Meier Lukas
2024-08-06 21:43:12 +02:00
committed by GitHub
parent 693e319e26
commit c4c4d41e4d
13 changed files with 95 additions and 17 deletions

View File

@@ -4,6 +4,7 @@ import { IconApps, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
@@ -69,8 +70,8 @@ const AppCard = async ({ app }: AppCardProps) => {
</Text> </Text>
)} )}
{app.href && ( {app.href && (
<Anchor href={app.href} lineClamp={1} size="sm" w="min-content"> <Anchor href={parseAppHrefWithVariablesServer(app.href)} lineClamp={1} size="sm" w="min-content">
{app.href} {parseAppHrefWithVariablesServer(app.href)}
</Anchor> </Anchor>
)} )}
</Stack> </Stack>

View File

@@ -1,5 +1,7 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
/** /**
* The redirect_uri is constructed to work behind a reverse proxy. It is constructed from the headers x-forwarded-proto and x-forwarded-host. * The redirect_uri is constructed to work behind a reverse proxy. It is constructed from the headers x-forwarded-proto and x-forwarded-host.
* @param headers * @param headers
@@ -11,16 +13,9 @@ export const createRedirectUri = (headers: ReadonlyHeaders | null, pathname: str
return pathname; return pathname;
} }
let protocol = headers.get("x-forwarded-proto") ?? "http"; const baseUrl = extractBaseUrlFromHeaders(headers);
// @see https://support.glitch.com/t/x-forwarded-proto-contains-multiple-protocols/17219
if (protocol.includes(",")) {
protocol = protocol.includes("https") ? "https" : "http";
}
const path = pathname.startsWith("/") ? pathname : `/${pathname}`; const path = pathname.startsWith("/") ? pathname : `/${pathname}`;
const host = headers.get("x-forwarded-host") ?? headers.get("host"); return `${baseUrl}${path}`;
return `${protocol}://${host}${path}`;
}; };

View File

@@ -5,8 +5,9 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./types": "./src/types.ts",
"./server": "./src/server.ts", "./server": "./src/server.ts",
"./types": "./src/types.ts" "./client": "./src/client.ts"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {
@@ -24,8 +25,9 @@
}, },
"dependencies": { "dependencies": {
"dayjs": "^1.11.12", "dayjs": "^1.11.12",
"next": "^14.2.5",
"react": "^18.3.1", "react": "^18.3.1",
"next": "^14.2.5" "tldts": "^6.1.37"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -0,0 +1,23 @@
import * as tldts from "tldts";
const safeParseTldts = (url: string) => {
try {
return tldts.parse(url);
} catch {
return null;
}
};
export const parseAppHrefWithVariables = <TInput extends string | null>(url: TInput, currentHref: string): TInput => {
if (!url || url.length === 0) return url;
const tldtsResult = safeParseTldts(currentHref);
const urlObject = new URL(currentHref);
return url
.replaceAll("[homarr_base]", `${urlObject.protocol}//${urlObject.hostname}`)
.replaceAll("[homarr_hostname]", tldtsResult?.hostname ?? "")
.replaceAll("[homarr_domain]", tldtsResult?.domain ?? "")
.replaceAll("[homarr_protocol]", urlObject.protocol.replace(":", "")) as TInput;
};

View File

@@ -0,0 +1,5 @@
import { parseAppHrefWithVariables } from "./base";
export const parseAppHrefWithVariablesClient = <TInput extends string | null>(url: TInput): TInput => {
return parseAppHrefWithVariables(url, window.location.href);
};

View File

@@ -0,0 +1,8 @@
import { headers } from "next/headers";
import { extractBaseUrlFromHeaders } from "../url";
import { parseAppHrefWithVariables } from "./base";
export const parseAppHrefWithVariablesServer = <TInput extends string | null>(url: TInput): TInput => {
return parseAppHrefWithVariables(url, extractBaseUrlFromHeaders(headers()));
};

View File

@@ -0,0 +1 @@
export * from "./app-url/client";

View File

@@ -1 +1,2 @@
export * from "./app-url/server";
export * from "./security"; export * from "./security";

View File

@@ -1,3 +1,5 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
export const appendPath = (url: URL | string, path: string) => { export const appendPath = (url: URL | string, path: string) => {
const newUrl = new URL(url); const newUrl = new URL(url);
newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path; newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path;
@@ -7,3 +9,16 @@ export const appendPath = (url: URL | string, path: string) => {
const removeTrailingSlash = (path: string) => { const removeTrailingSlash = (path: string) => {
return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path; return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path;
}; };
export const extractBaseUrlFromHeaders = (headers: ReadonlyHeaders): `${string}://${string}` => {
let protocol = headers.get("x-forwarded-proto") ?? "http";
// @see https://support.glitch.com/t/x-forwarded-proto-contains-multiple-protocols/17219
if (protocol.includes(",")) {
protocol = protocol.includes("https") ? "https" : "http";
}
const host = headers.get("x-forwarded-host") ?? headers.get("host");
return `${protocol}://${host}`;
};

View File

@@ -8,6 +8,7 @@ import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { parseAppHrefWithVariablesClient } from "@homarr/common/client";
import { useRegisterSpotlightActions } from "@homarr/spotlight"; import { useRegisterSpotlightActions } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -40,7 +41,7 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
const shouldRunPing = Boolean(app?.href) && options.pingEnabled; const shouldRunPing = Boolean(app?.href) && options.pingEnabled;
clientApi.widget.app.updatedPing.useSubscription( clientApi.widget.app.updatedPing.useSubscription(
{ url: app?.href ?? "" }, { url: parseAppHrefWithVariablesClient(app?.href ?? "") },
{ {
enabled: shouldRunPing, enabled: shouldRunPing,
onData(data) { onData(data) {
@@ -60,7 +61,7 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
icon: app.iconUrl, icon: app.iconUrl,
group: "app", group: "app",
type: "link", type: "link",
href: app.href, href: parseAppHrefWithVariablesClient(app.href),
openInNewTab: options.openInNewTab, openInNewTab: options.openInNewTab,
}, },
] ]
@@ -92,7 +93,11 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
} }
return ( return (
<AppLink href={app?.href ?? ""} openInNewTab={options.openInNewTab} enabled={Boolean(app?.href) && !isEditMode}> <AppLink
href={parseAppHrefWithVariablesClient(app?.href ?? "")}
openInNewTab={options.openInNewTab}
enabled={Boolean(app?.href) && !isEditMode}
>
<Tooltip.Floating <Tooltip.Floating
label={app?.description} label={app?.description}
position="right-start" position="right-start"

View File

@@ -2,6 +2,7 @@
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import type { WidgetProps } from "../definition"; import type { WidgetProps } from "../definition";
@@ -15,7 +16,9 @@ export default async function getServerDataAsync({ options }: WidgetProps<"app">
let pingResult: RouterOutputs["widget"]["app"]["ping"] | null = null; let pingResult: RouterOutputs["widget"]["app"]["ping"] | null = null;
if (app.href && options.pingEnabled) { if (app.href && options.pingEnabled) {
pingResult = await api.widget.app.ping({ url: app.href }); pingResult = await api.widget.app.ping({
url: parseAppHrefWithVariablesServer(app.href),
});
} }
return { app, pingResult }; return { app, pingResult };

View File

@@ -30,6 +30,9 @@ vi.mock("@homarr/api/server", () => ({
}, },
}, },
})); }));
vi.mock("@homarr/common/server", () => ({
parseAppHrefWithVariablesServer: () => "http://localhost",
}));
describe("getServerDataAsync should load app and ping result", () => { describe("getServerDataAsync should load app and ping result", () => {
test("when appId is empty it should return null for app and pingResult", async () => { test("when appId is empty it should return null for app and pingResult", async () => {

16
pnpm-lock.yaml generated
View File

@@ -636,6 +636,9 @@ importers:
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
tldts:
specifier: ^6.1.37
version: 6.1.37
devDependencies: devDependencies:
'@homarr/eslint-config': '@homarr/eslint-config':
specifier: workspace:^0.2.0 specifier: workspace:^0.2.0
@@ -6174,6 +6177,13 @@ packages:
title-case@2.1.1: title-case@2.1.1:
resolution: {integrity: sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q==} resolution: {integrity: sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q==}
tldts-core@6.1.37:
resolution: {integrity: sha512-q6M/RBjZcUoF/KRhHFuGrcnaXLaXH8kHKH/e8XaAd9ULGYYhB32kr1ceIXR77a57OxRB/NR471BcYwU7jf4PAg==}
tldts@6.1.37:
resolution: {integrity: sha512-QMvNTwl3b3vyweq158Cf+IeEWe/P1HVDULo5n7qnt70rzkU3Ya2amaWO36lX0C8w6X3l92fftcuHwLIX9QBkZg==}
hasBin: true
tmp@0.0.33: tmp@0.0.33:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'} engines: {node: '>=0.6.0'}
@@ -11869,6 +11879,12 @@ snapshots:
no-case: 2.3.2 no-case: 2.3.2
upper-case: 1.1.3 upper-case: 1.1.3
tldts-core@6.1.37: {}
tldts@6.1.37:
dependencies:
tldts-core: 6.1.37
tmp@0.0.33: tmp@0.0.33:
dependencies: dependencies:
os-tmpdir: 1.0.2 os-tmpdir: 1.0.2