fix: harden onboarding completion

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 01:20:27 +02:00
1 parent 576cc0b418
commit 07770e862c
44 files changed
+3003 -1000

No files matched your search

@@ -4,7 +4,7 @@ import { integrationIconSlugs } from "./docker-integration-match";
import type { IntegrationKind } from "./integration";
import { getIntegrationDefaultPort } from "./integration";
export type UrlTemplateMode = "hostPort" | "subdomain";
export type UrlTemplateMode = "hostPort" | "subdomain" | "path";
const getSlugForKind = (kind: IntegrationKind): string => integrationIconSlugs[kind];
@@ -13,7 +13,13 @@ const normalizeBaseHost = (value: string) => {
if (!trimmed) return null;
try {
const parsed = new URL(trimmed.includes("://") ? trimmed : `http://${trimmed}`);
return { hostname: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined };
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
return {
protocol: parsed.protocol,
hostname: parsed.hostname,
port: parsed.port ? Number(parsed.port) : undefined,
pathname: parsed.pathname.replace(/\/+$/, ""),
};
} catch {
return null;
}
@@ -26,6 +32,8 @@ const buildUrl = (slug: string, rawHost: string, mode: UrlTemplateMode, port?: n
const modeBuilders: Record<UrlTemplateMode, () => string> = {
subdomain: () => (base.hostname.startsWith("[") ? "" : `https://${slug}.${base.hostname}`),
hostPort: () => (effectivePort ? `http://${base.hostname}:${effectivePort}` : `http://${base.hostname}`),
path: () =>
`${base.protocol}//${base.hostname}${base.port ? `:${base.port}` : ""}${base.pathname}/${slug}`,
};
return modeBuilders[mode]();
};
+14 -4
View File
@@ -1,4 +1,4 @@
import { objectKeys, removeTrailingSlash } from "@homarr/common";
import { objectKeys } from "@homarr/common";
import type { AtLeastOneOf } from "@homarr/common/types";
import { createDocumentationLink } from "./docs";
@@ -546,9 +546,19 @@ export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string |
export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => {
const definition = integrationDefs[kind];
if (!("apiKeySettingsPath" in definition)) return null;
const base = removeTrailingSlash(integrationUrl);
if (!base) return null;
return `${base}${definition.apiKeySettingsPath}`;
try {
const value = integrationUrl.trim();
if (!value) return null;
const base = new URL(/^[a-z][a-z0-9+.-]*:\/\//iu.test(value) ? value : `http://${value}`);
if (base.protocol !== "http:" && base.protocol !== "https:") return null;
const settings = new URL(definition.apiKeySettingsPath, "http://localhost");
base.pathname = `${base.pathname.replace(/\/+$/, "")}/${settings.pathname.replace(/^\/+/, "")}`;
base.search = settings.search;
base.hash = settings.hash;
return base.toString();
} catch {
return null;
}
};
/**
@@ -51,8 +51,30 @@ describe("getIntegrationApiKeyUrl", () => {
},
);
it("returns null for empty URL", () => {
expect(getIntegrationApiKeyUrl("", "sonarr")).toBeNull();
it.each(["", "javascript:alert(1)", "data:text/html,test", "file:///etc/passwd"])(
"returns null for an empty or unsafe URL: %s",
(url) => {
expect(getIntegrationApiKeyUrl(url, "sonarr")).toBeNull();
},
);
it.each([
["sonarr.local:8989", "http://sonarr.local:8989/settings/general"],
["0.0.0.0:8989", "http://0.0.0.0:8989/settings/general"],
])("normalizes a self-hosted address without a scheme: %s", (url, expected) => {
expect(getIntegrationApiKeyUrl(url, "sonarr")).toBe(expected);
});
it("joins onto a reverse-proxy pathname without retaining stale query or fragment state", () => {
expect(getIntegrationApiKeyUrl("https://home.example.com/services/sonarr/?from=old#section", "sonarr")).toBe(
"https://home.example.com/services/sonarr/settings/general",
);
});
it("preserves configured credential fragments while joining pathnames", () => {
expect(getIntegrationApiKeyUrl("https://home.example.com/services/jellyfin/", "jellyfin")).toBe(
"https://home.example.com/services/jellyfin/web/index.html#!/dashboard/keys",
);
});
it("strips trailing slash from base URL", () => {
@@ -34,6 +34,15 @@ describe("buildIntegrationUrl", () => {
);
});
it("builds reverse-proxy path URLs and preserves the configured base path", () => {
expect(buildIntegrationUrl("sonarr", "https://home.example.com/services", "path")).toBe(
"https://home.example.com/services/sonarr",
);
expect(buildAppUrl("My Service", "home.lan/apps/", "path", 9999)).toBe(
"http://home.lan/apps/my-service",
);
});
it("replaces a supplied base port instead of producing a double port", () => {
expect(buildIntegrationUrl("sonarr", "https://home.example.com:8443/homarr", "hostPort")).toBe(
"http://home.example.com:8989",
@@ -2,6 +2,7 @@ import { createDocumentationLink } from "./docs";
import type { IntegrationKind } from "./integration";
import { getIntegrationKindsByCategory } from "./integration";
import type { WidgetKind } from "./widget";
import { widgetDefaultSizes } from "./widget";
/**
* Static mapping of widget kinds to the integration kinds they support.
@@ -121,6 +122,11 @@ export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
];
const defaultWidgetConfigByKind = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
export const getDefaultWidgetConfig = (kind: WidgetKind): DefaultWidgetConfig =>
defaultWidgetConfigByKind.get(kind) ?? { kind, ...(widgetDefaultSizes[kind] ?? { width: 1, height: 1 }) };
export const generalWidgets: WidgetKind[] = defaultWidgetConfigs
.filter((config) => !config.skip)
.map((config) => config.kind);