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

View File

@@ -1,5 +1,7 @@
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.
* @param headers
@@ -11,16 +13,9 @@ export const createRedirectUri = (headers: ReadonlyHeaders | null, pathname: str
return pathname;
}
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 baseUrl = extractBaseUrlFromHeaders(headers);
const path = pathname.startsWith("/") ? pathname : `/${pathname}`;
const host = headers.get("x-forwarded-host") ?? headers.get("host");
return `${protocol}://${host}${path}`;
return `${baseUrl}${path}`;
};

View File

@@ -5,8 +5,9 @@
"type": "module",
"exports": {
".": "./index.ts",
"./types": "./src/types.ts",
"./server": "./src/server.ts",
"./types": "./src/types.ts"
"./client": "./src/client.ts"
},
"typesVersions": {
"*": {
@@ -24,8 +25,9 @@
},
"dependencies": {
"dayjs": "^1.11.12",
"next": "^14.2.5",
"react": "^18.3.1",
"next": "^14.2.5"
"tldts": "^6.1.37"
},
"devDependencies": {
"@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";

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) => {
const newUrl = new URL(url);
newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path;
@@ -7,3 +9,16 @@ export const appendPath = (url: URL | string, path: string) => {
const removeTrailingSlash = (path: string) => {
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 { clientApi } from "@homarr/api/client";
import { parseAppHrefWithVariablesClient } from "@homarr/common/client";
import { useRegisterSpotlightActions } from "@homarr/spotlight";
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;
clientApi.widget.app.updatedPing.useSubscription(
{ url: app?.href ?? "" },
{ url: parseAppHrefWithVariablesClient(app?.href ?? "") },
{
enabled: shouldRunPing,
onData(data) {
@@ -60,7 +61,7 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
icon: app.iconUrl,
group: "app",
type: "link",
href: app.href,
href: parseAppHrefWithVariablesClient(app.href),
openInNewTab: options.openInNewTab,
},
]
@@ -92,7 +93,11 @@ export default function AppWidget({ options, serverData, isEditMode, width }: Wi
}
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
label={app?.description}
position="right-start"

View File

@@ -2,6 +2,7 @@
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
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;
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 };

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", () => {
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:
specifier: ^18.3.1
version: 18.3.1
tldts:
specifier: ^6.1.37
version: 6.1.37
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0
@@ -6174,6 +6177,13 @@ packages:
title-case@2.1.1:
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:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'}
@@ -11869,6 +11879,12 @@ snapshots:
no-case: 2.3.2
upper-case: 1.1.3
tldts-core@6.1.37: {}
tldts@6.1.37:
dependencies:
tldts-core: 6.1.37
tmp@0.0.33:
dependencies:
os-tmpdir: 1.0.2