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:
@@ -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>
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
23
packages/common/src/app-url/base.ts
Normal file
23
packages/common/src/app-url/base.ts
Normal 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;
|
||||
};
|
||||
5
packages/common/src/app-url/client.ts
Normal file
5
packages/common/src/app-url/client.ts
Normal 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);
|
||||
};
|
||||
8
packages/common/src/app-url/server.ts
Normal file
8
packages/common/src/app-url/server.ts
Normal 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()));
|
||||
};
|
||||
1
packages/common/src/client.ts
Normal file
1
packages/common/src/client.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./app-url/client";
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./app-url/server";
|
||||
export * from "./security";
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
16
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user