From f1ce0fffad68b5f9fa55d132ed723ccf626bd45f Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 14 Aug 2026 02:00:35 +0200 Subject: [PATCH] fix(onboarding): accept self-hosted addresses --- .../docs/integrations/docker-labels/index.mdx | 1 + packages/onboarding/src/setup-studio.tsx | 28 +++++++++++++++---- packages/translation/src/lang/en.json | 2 +- packages/validation/src/onboarding.spec.ts | 26 ++++++++--------- packages/validation/src/onboarding.ts | 15 ++++------ 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/apps/docs/docs/integrations/docker-labels/index.mdx b/apps/docs/docs/integrations/docker-labels/index.mdx index edc6a387d..377116020 100644 --- a/apps/docs/docs/integrations/docker-labels/index.mdx +++ b/apps/docs/docs/integrations/docker-labels/index.mdx @@ -52,6 +52,7 @@ services: Use an address that is reachable where it is needed. A Docker service name can work for server-side integration requests, while an app link opened in a browser usually needs a LAN hostname, IP address, or reverse-proxy address. +Onboarding accepts these self-hosted address formats directly and only requires the field to be non-empty. ## Homepage label fallback diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx index 5f50e5dd8..977ae718b 100644 --- a/packages/onboarding/src/setup-studio.tsx +++ b/packages/onboarding/src/setup-studio.tsx @@ -100,7 +100,6 @@ import { OnboardingBackdrop } from "./onboarding-backdrop"; import { OnboardingWordmark } from "./onboarding-wordmark"; import { useOnboardingSounds } from "./use-onboarding-sounds"; import { - isHttpUrl, normalizeServiceUrl, resolveDiscoveredAppUrl, resolveIntegrationDraftUrl, @@ -127,7 +126,7 @@ interface IntegrationDraft { } const isIntegrationDraftComplete = (draft: IntegrationDraft) => - isHttpUrl(draft.url) && draft.secrets.every((secret) => secret.value.trim().length > 0); + draft.url.trim().length > 0 && draft.secrets.every((secret) => secret.value.trim().length > 0); type DockerDiscoveryData = RouterOutputs["onboard"]["discoverDockerServices"]; type RuntimeCapabilitiesQuery = Omit< @@ -435,7 +434,9 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS } const draftsToApply = drafts.filter(isIntegrationDraftComplete); const appsToCreate = discoveredApps.filter((app) => selectedAppIds.includes(app.sourceId)); - const appWithoutAddress = appsToCreate.find((app) => !isHttpUrl(discoveredAppUrls[app.sourceId] ?? "")); + const appWithoutAddress = appsToCreate.find( + (app) => (discoveredAppUrls[app.sourceId] ?? "").trim().length === 0, + ); if (appWithoutAddress) { const message = t("connect.appAddressRequired", { name: appWithoutAddress.containerName }); setAppError(message); @@ -493,7 +494,8 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS }; }), apps: appsToCreate.flatMap((app) => { - const href = normalizeServiceUrl(discoveredAppUrls[app.sourceId] ?? ""); + const enteredAddress = (discoveredAppUrls[app.sourceId] ?? "").trim(); + const href = normalizeServiceUrl(enteredAddress) ?? enteredAddress; return href ? [ { @@ -635,13 +637,27 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS appErrorSourceId, }; const hasIncompleteIntegrations = drafts.some((draft) => !isIntegrationDraftComplete(draft)); + const selectedAppWithoutAddress = discoveredApps.find( + (app) => selectedAppIds.includes(app.sourceId) && (discoveredAppUrls[app.sourceId] ?? "").trim().length === 0, + ); const continueDisabled = isApplying || (activeSection === "essentials" && serverOrigin.trim().length === 0); const continueOnboarding = () => { if (continueDisabled) return; if (activeSection === "review") { void applySetupAsync(); - } else if (activeSection === "connect" && hasIncompleteIntegrations) { - setIncompleteIntegrationConfirmationOpened(true); + } else if (activeSection === "connect") { + if (selectedAppWithoutAddress) { + setAppError(t("connect.appAddressRequired", { name: selectedAppWithoutAddress.containerName })); + setAppErrorSourceId(selectedAppWithoutAddress.sourceId); + return; + } + setAppError(null); + setAppErrorSourceId(null); + if (hasIncompleteIntegrations) { + setIncompleteIntegrationConfirmationOpened(true); + } else { + move(1); + } } else { move(1); } diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 0ba3274d0..df14c588d 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -114,7 +114,7 @@ "otherIntegrations": "Add another integration", "configureIntegrations": "Configure integrations", "discoveredApps": "Detected apps ({count})", - "appAddressRequired": "Add an HTTP or HTTPS address for {name}, or remove it from the selection.", + "appAddressRequired": "Add an address for {name}, or remove it from the selection.", "empty": "No integrations selected. Homarr will still create a useful starter board with general widgets.", "dockerSource": "Docker", "manualSource": "Manual", diff --git a/packages/validation/src/onboarding.spec.ts b/packages/validation/src/onboarding.spec.ts index ebe8f4b32..93051b7d2 100644 --- a/packages/validation/src/onboarding.spec.ts +++ b/packages/validation/src/onboarding.spec.ts @@ -6,11 +6,11 @@ import { onboardingDiscoveredAppSchema, } from "./onboarding"; -describe("onboarding URL validation", () => { - test.each(["javascript:alert(1)", "data:text/html,hello", "file:///etc/passwd"])( - "rejects the %s scheme for integrations", +describe("onboarding address validation", () => { + test.each(["server.local:8989", "0.0.0.0:3000", "sonarr", "/services/sonarr"])( + "accepts the self-hosted address %s", (url) => { - expect(onboardingCreateIntegrationSchema.safeParse({ name: "Unsafe", url }).success).toBe(false); + expect(onboardingCreateIntegrationSchema.safeParse({ name: "Service", url }).success).toBe(true); }, ); @@ -27,7 +27,7 @@ describe("onboarding URL validation", () => { ).toBe(true); }); - test("accepts bounded integration metadata and rejects invalid metadata", () => { + test("accepts bounded integration metadata and rejects empty or oversized metadata", () => { expect( onboardingCreateIntegrationSchema.safeParse({ name: "Sonarr", @@ -41,7 +41,7 @@ describe("onboarding URL validation", () => { onboardingCreateIntegrationSchema.safeParse({ name: "Sonarr", url: "http://sonarr:8989", - pingUrl: "file:///etc/passwd", + pingUrl: "", }).success, ).toBe(false); expect( @@ -53,18 +53,18 @@ describe("onboarding URL validation", () => { ).toBe(false); }); - test("rejects non-web app and ping URLs", () => { + test("accepts custom app and ping addresses", () => { expect( onboardingDiscoveredAppSchema.safeParse({ - name: "Unsafe", - href: "javascript:alert(1)", - pingUrl: "file:///etc/passwd", + name: "Database", + href: "postgres://database:5432", + pingUrl: "database.local:5432", iconUrl: null, }).success, - ).toBe(false); + ).toBe(true); }); - test("requires every app submitted to completeSetup to have an HTTP(S) href", () => { + test("requires every app submitted to completeSetup to have a non-empty address", () => { const input = { server: { defaultLocale: "en", defaultColorScheme: "dark" }, board: { @@ -80,7 +80,7 @@ describe("onboarding URL validation", () => { expect( onboardingCompleteSetupSchema.safeParse({ ...input, - apps: [{ ...input.apps[0], href: "https://status.example.com" }], + apps: [{ ...input.apps[0], href: "status.local:3001" }], }).success, ).toBe(true); }); diff --git a/packages/validation/src/onboarding.ts b/packages/validation/src/onboarding.ts index add80399e..e0eb87413 100644 --- a/packages/validation/src/onboarding.ts +++ b/packages/validation/src/onboarding.ts @@ -13,10 +13,7 @@ import { boardNameSchema } from "./board"; import { zodEnumFromArray } from "./enums"; const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/); -const httpUrlSchema = z.url().refine((value) => { - const protocol = new URL(value).protocol; - return protocol === "http:" || protocol === "https:"; -}, "Only HTTP and HTTPS URLs are permitted"); +const serviceAddressSchema = z.string().trim().min(1).max(2048); const uniqueIdsSchema = (maxLength = 255) => z .array(z.string().trim().min(1).max(maxLength)) @@ -26,11 +23,11 @@ const sourceIdSchema = z.string().trim().min(1).max(512); export const onboardingCreateIntegrationSchema = z.object({ name: z.string().trim().min(1).max(127), - url: httpUrlSchema, + url: serviceAddressSchema, sourceId: sourceIdSchema.optional(), iconUrl: z.string().trim().min(1).max(2048).nullable().optional(), description: z.string().trim().max(512).nullable().optional(), - pingUrl: httpUrlSchema.nullable().optional(), + pingUrl: serviceAddressSchema.nullable().optional(), }); export const onboardingIntegrationDraftSchema = onboardingCreateIntegrationSchema.extend({ @@ -47,15 +44,15 @@ export const onboardingIntegrationDraftSchema = onboardingCreateIntegrationSchem export const onboardingDiscoveredAppSchema = z.object({ sourceId: sourceIdSchema.optional(), name: z.string().trim().min(1).max(127), - href: httpUrlSchema.nullable(), - pingUrl: httpUrlSchema.nullable().optional(), + href: serviceAddressSchema.nullable(), + pingUrl: serviceAddressSchema.nullable().optional(), iconUrl: z.string().trim().min(1).nullable(), description: z.string().trim().max(512).nullable().optional(), }); export const onboardingAppDraftSchema = onboardingDiscoveredAppSchema.extend({ sourceId: sourceIdSchema, - href: httpUrlSchema, + href: serviceAddressSchema, }); const integrationDraftsSchema = z