import type { Agent } from "https"; import dayjs from "dayjs"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import type { RequestInit as NodeFetchRequestInit } from "node-fetch"; import * as ical from "node-ical"; import { DAVClient } from "tsdav"; import { z } from "zod"; import { ResponseError } from "@homarr/common/server"; import { createHttpsAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { HandleIntegrationErrors } from "../base/errors/decorator"; import { integrationTsdavHttpErrorHandler } from "../base/errors/http"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integration"; import type { CalendarEvent } from "../interfaces/calendar/calendar-types"; import type { Notification } from "../interfaces/notifications/notification-types"; import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration"; const notificationSchema = z.object({ ocs: z.object({ data: z.array( z.object({ notification_id: z.number(), datetime: z.string(), subject: z.string(), message: z.string(), }), ), }), }); const logger = createLogger({ module: "nextcloudIntegration" }); dayjs.extend(utc); dayjs.extend(timezone); @HandleIntegrationErrors([integrationTsdavHttpErrorHandler]) export class NextcloudIntegration extends Integration implements ICalendarIntegration, INotificationsIntegration { protected async testingAsync(input: IntegrationTestingInput): Promise { const client = await this.createCalendarClientAsync(await createHttpsAgentAsync(input.options)); await client.login(); return { success: true }; } public async getCalendarEventsAsync(start: Date, end: Date, _showUnmonitored?: boolean): Promise { const client = await this.createCalendarClientAsync(); await client.login(); const calendars = await client.fetchCalendars(); // Parameters must be in ISO-8601, See https://tsdav.vercel.app/docs/caldav/fetchCalendarObjects#arguments const calendarEvents = ( await Promise.all( calendars.map( async (calendar) => await client.fetchCalendarObjects({ calendar, timeRange: { start: start.toISOString(), end: end.toISOString() }, }), ), ) ).flat(); return calendarEvents .map((event) => { // @ts-expect-error the typescript definitions for this package are wrong // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access const icalData = ical.default.parseICS(event.data) as ical.CalendarResponse; const veventObject = Object.values(icalData).find((data) => data.type === "VEVENT"); if (!veventObject) { throw new Error(`Invalid event data object: ${JSON.stringify(event.data)}. Unable to process the calendar.`); } logger.debug(`Converting VEVENT event to ${event.etag} from Nextcloud: ${JSON.stringify(veventObject)}`); const eventUrlWithoutHost = new URL(event.url).pathname; const eventSlug = Buffer.from(eventUrlWithoutHost).toString("base64url"); const startDates = veventObject.rrule ? veventObject.rrule.between(start, end) : [veventObject.start]; const durationMs = veventObject.end.getTime() - veventObject.start.getTime(); return startDates.map((startDate) => { const timezoneOffsetMinutes = veventObject.rrule?.origOptions.tzid ? dayjs(startDate).tz(veventObject.rrule.origOptions.tzid).utcOffset() : 0; const utcStartDate = new Date(startDate.getTime() - timezoneOffsetMinutes * 60 * 1000); const endDate = new Date(utcStartDate.getTime() + durationMs); const dateInMillis = utcStartDate.valueOf(); return { title: veventObject.summary, subTitle: null, description: veventObject.description, startDate: utcStartDate, endDate, image: null, location: veventObject.location || null, indicatorColor: "color" in veventObject && typeof veventObject.color === "string" ? veventObject.color : "#ff8600", links: [ { href: this.externalUrl( `/apps/calendar/timeGridWeek/now/edit/sidebar/${eventSlug}/${dateInMillis / 1000}`, ).toString(), name: "Nextcloud", logo: "/images/apps/nextcloud.svg", color: undefined, isDark: true, }, ], }; }); }) .flat(); } public async getNotificationsAsync(): Promise { const url = this.url("/ocs/v2.php/apps/notifications/api/v2/notifications", { format: "json" }); const response = await fetchWithTrustedCertificatesAsync(url, { headers: { Authorization: `Basic ${Buffer.from( `${this.getSecretValue("username")}:${this.getSecretValue("password")}`, ).toString("base64")}`, "OCS-APIRequest": "true", }, }); if (!response.ok) throw new ResponseError(response); const json = notificationSchema.parse(await response.json()); return json.ocs.data.map((notification) => ({ id: String(notification.notification_id), time: new Date(notification.datetime), title: notification.subject, body: notification.message, })); } private async createCalendarClientAsync(agent?: Agent) { return new DAVClient({ serverUrl: this.integration.url, credentials: { username: this.getSecretValue("username"), password: this.getSecretValue("password"), }, authMethod: "Basic", defaultAccountType: "caldav", fetchOptions: { // tsdav is using cross-fetch which uses node-fetch for nodejs environments. // There is an agent property that is the same type as the http(s) agents of nodejs agent: agent ?? (await createHttpsAgentAsync()), } satisfies NodeFetchRequestInit as RequestInit, }); } }