Compare commits

..
Author SHA1 Message Date
arturict 1ed6b16217 Keep existing Nextcloud DAV endpoints intact 2026-09-06 11:05:57 +02:00
Artur c6d22b3939 fix(integrations): preserve Nextcloud subpaths 2026-09-06 11:05:57 +02:00
10 changed files with 94 additions and 45 deletions

No files matched your search

+2 -2
View File
@@ -58,8 +58,8 @@ TURBO_TELEMETRY_DISABLED=1
# Enable kubernetes tool # Enable kubernetes tool
# ENABLE_KUBERNETES=true # ENABLE_KUBERNETES=true
# Enable the development-only mock integration. Never enable this in production. # Enable mock integration
# UNSAFE_ENABLE_MOCK_INTEGRATION=true UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Public URL contract. Values must be absolute HTTP(S) URLs without credentials, # Public URL contract. Values must be absolute HTTP(S) URLs without credentials,
# query strings, or fragments. Trailing slashes are normalized at startup. # query strings, or fragments. Trailing slashes are normalized at startup.
@@ -133,10 +133,9 @@ External redis is currently not supported with Proxmox Community Scripts
The advanced deployments environment variables should only be used if you know what you are doing. The advanced deployments environment variables should only be used if you know what you are doing.
::: :::
| Environment Variable | Description | Default | | Environment Variable | Description |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------- | | ------------------------ | --------------------------------------------------- |
| `DB_MIGRATIONS_DISABLED` | Disable database migrations. For example, when migrations are managed separately in Helm deployments. | `false` | | `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
| `UNSAFE_ENABLE_MOCK_INTEGRATION` | Enable the development-only Mock integration. This exposes fabricated data and must not be used in production. | `false` |
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts. trailing slashes when Homarr or Docusaurus starts.
@@ -47,9 +47,6 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
const tCreate = await getI18n("integration.page.create"); const tCreate = await getI18n("integration.page.create");
const currentKind = result.data; const currentKind = result.data;
if (currentKind === "mock" && !env.UNSAFE_ENABLE_MOCK_INTEGRATION) {
notFound();
}
return ( return (
<> <>
-2
View File
@@ -19,7 +19,6 @@ const publicHttpUrl = (variableName: string) =>
export const env = createEnv({ export const env = createEnv({
server: { server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(), KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false), DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true), DEMO_READ_ONLY: createBooleanSchema(true),
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"), HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
@@ -28,7 +27,6 @@ export const env = createEnv({
}, },
runtimeEnv: { runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME, KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
UNSAFE_ENABLE_MOCK_INTEGRATION: process.env.UNSAFE_ENABLE_MOCK_INTEGRATION,
DEMO_MODE: process.env.DEMO_MODE, DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY, DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL, HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
@@ -18,12 +18,7 @@ import {
integrationSecrets, integrationSecrets,
integrationUserPermissions, integrationUserPermissions,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { import type { GroupPermissionKey, IntegrationPermission, IntegrationSecretKind } from "@homarr/definitions";
GroupPermissionKey,
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
} from "@homarr/definitions";
import { import {
getIntegrationKindsByCategory, getIntegrationKindsByCategory,
getPermissionsWithParents, getPermissionsWithParents,
@@ -44,7 +39,6 @@ import {
} from "@homarr/validation/integration"; } from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { env } from "../../env";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access"; import { throwIfActionForbiddenAsync } from "./integration-access";
@@ -54,15 +48,6 @@ import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" }); const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch"); const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
const throwIfMockIntegrationIsDisabled = (kind: IntegrationKind) => {
if (kind !== "mock" || env.UNSAFE_ENABLE_MOCK_INTEGRATION) return;
throw new TRPCError({
code: "FORBIDDEN",
message: "Mock integrations are disabled",
});
};
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
getKinds: publicProcedure getKinds: publicProcedure
.meta({ .meta({
@@ -73,14 +58,12 @@ export const integrationRouter = createTRPCRouter({
}, },
}) })
.query(() => { .query(() => {
return objectEntries(integrationDefs) return objectEntries(integrationDefs).map(([kind, def]) => ({
.filter(([kind]) => kind !== "mock" || env.UNSAFE_ENABLE_MOCK_INTEGRATION) kind,
.map(([kind, def]) => ({ name: def.name,
kind, category: def.category,
name: def.name, requiredSecrets: def.secretKinds,
category: def.category, }));
requiredSecrets: def.secretKinds,
}));
}), }),
all: protectedProcedure all: protectedProcedure
.meta({ .meta({
@@ -246,8 +229,6 @@ export const integrationRouter = createTRPCRouter({
}) })
.input(integrationCreateSchema) .input(integrationCreateSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
throwIfMockIntegrationIsDisabled(input.kind);
logger.info("Creating integration", { logger.info("Creating integration", {
name: input.name, name: input.name,
kind: input.kind, kind: input.kind,
@@ -321,13 +321,6 @@ export const onboardRouter = createTRPCRouter({
.requiresStep("setup") .requiresStep("setup")
.input(onboardingCompleteSetupSchema) .input(onboardingCompleteSetupSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
if (
!env.UNSAFE_ENABLE_MOCK_INTEGRATION &&
input.integrations.some((integration) => integration.kind === "mock")
) {
throw new TRPCError({ code: "FORBIDDEN", message: "Mock integrations are disabled" });
}
const boardList = await ctx.db.query.boards.findMany({ const boardList = await ctx.db.query.boards.findMany({
with: { with: {
sections: { with: { layouts: true } }, sections: { with: { layouts: true } },
@@ -0,0 +1,13 @@
export const createNextcloudCalendarServerUrl = (integrationUrl: string) => {
const calendarServerUrl = new URL(integrationUrl);
const integrationPath = calendarServerUrl.pathname.replace(/\/+$/, "");
const davPath = "/remote.php/dav";
calendarServerUrl.pathname = integrationPath.endsWith(davPath)
? `${integrationPath}/`
: `${integrationPath}${davPath}/`;
calendarServerUrl.search = "";
calendarServerUrl.hash = "";
return calendarServerUrl.toString();
};
@@ -21,6 +21,7 @@ import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integ
import type { CalendarEvent } from "../interfaces/calendar/calendar-types"; import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
import type { Notification } from "../interfaces/notifications/notification-types"; import type { Notification } from "../interfaces/notifications/notification-types";
import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration"; import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration";
import { createNextcloudCalendarServerUrl } from "./nextcloud-url";
// Register all existing timezones // Register all existing timezones
if (ICAL.TimezoneService.count === 0) { if (ICAL.TimezoneService.count === 0) {
@@ -189,7 +190,7 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr
private async createCalendarClientAsync(agent?: Agent) { private async createCalendarClientAsync(agent?: Agent) {
return new DAVClient({ return new DAVClient({
serverUrl: this.integration.url, serverUrl: createNextcloudCalendarServerUrl(this.integration.url),
credentials: { credentials: {
username: this.getSecretValue("username"), username: this.getSecretValue("username"),
password: this.getSecretValue("password"), password: this.getSecretValue("password"),
@@ -0,0 +1,38 @@
import type { Agent } from "node:https";
import { DAVClient } from "tsdav";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { NextcloudIntegration } from "../nextcloud.integration";
vi.mock("tsdav", () => ({ DAVClient: vi.fn() }));
interface NextcloudCalendarClientFactory {
createCalendarClientAsync(agent: Agent): Promise<unknown>;
}
describe("NextcloudIntegration", () => {
beforeEach(() => {
vi.mocked(DAVClient).mockClear();
});
test("uses a calendar server URL that preserves the integration subpath", async () => {
const integration = new NextcloudIntegration({
id: "integration-id",
name: "Nextcloud",
url: "https://example.com/nextcloud",
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: "admin" },
{ kind: "password", value: "password" },
],
});
await (integration as unknown as NextcloudCalendarClientFactory).createCalendarClientAsync({} as Agent);
expect(DAVClient).toHaveBeenCalledWith(
expect.objectContaining({
serverUrl: "https://example.com/nextcloud/remote.php/dav/",
}),
);
});
});
@@ -0,0 +1,29 @@
import { describe, expect, test } from "vitest";
import { createNextcloudCalendarServerUrl } from "../nextcloud-url";
describe("createNextcloudCalendarServerUrl", () => {
test("uses the DAV endpoint for a root installation", () => {
expect(createNextcloudCalendarServerUrl("https://cloud.example.com")).toBe(
"https://cloud.example.com/remote.php/dav/",
);
});
test("preserves the base path for a subpath installation", () => {
expect(createNextcloudCalendarServerUrl("https://example.com/nextcloud")).toBe(
"https://example.com/nextcloud/remote.php/dav/",
);
});
test("does not duplicate a trailing slash", () => {
expect(createNextcloudCalendarServerUrl("https://example.com/nextcloud/")).toBe(
"https://example.com/nextcloud/remote.php/dav/",
);
});
test("does not duplicate an existing DAV endpoint", () => {
expect(createNextcloudCalendarServerUrl("https://example.com/nextcloud/remote.php/dav")).toBe(
"https://example.com/nextcloud/remote.php/dav/",
);
});
});