cal-101-caldav-integration (#419)
* add generic calendar icon for caldav * module for symmetric encrypt/decrypt * caldav integration * use Radix dialog * Move caldav components to /caldav * remove duplicate cancel button, unused function * ensure app can connect to caldav server before adding * fix calendar clients can possibly return null * fix: add caldav dialog does not close when submitted * safely attempt all caldav operations * clarify variable name, fix typo * use common helper for stripping html * remove usage of request lib until "completed" * add types and usage comments to crypto lib * add encryption key to example env file
This commit is contained in:
1 parent
21c709ee46
commit
65366b7c5b
13 files changed
+706
-30
No files matched your search
@@ -9,6 +9,7 @@ import { EventResult } from "@lib/events/EventManager";
|
||||
import logger from "@lib/logger";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[lib] calendarClient"] });
|
||||
import { CalDavCalendar } from "./integrations/CalDav/CalDavCalendarAdapter";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { google } = require("googleapis");
|
||||
@@ -516,6 +517,8 @@ const calendars = (withCredentials): CalendarApiAdapter[] =>
|
||||
return GoogleCalendar(cred);
|
||||
case "office365_calendar":
|
||||
return MicrosoftOffice365Calendar(cred);
|
||||
case "caldav_calendar":
|
||||
return new CalDavCalendar(cred);
|
||||
default:
|
||||
return; // unknown credential, could be legacy? In any case, ignore
|
||||
}
|
||||
@@ -531,7 +534,7 @@ const getBusyCalendarTimes = (withCredentials, dateFrom, dateTo, selectedCalenda
|
||||
|
||||
const listCalendars = (withCredentials) =>
|
||||
Promise.all(calendars(withCredentials).map((c) => c.listCalendars())).then((results) =>
|
||||
results.reduce((acc, calendars) => acc.concat(calendars), [])
|
||||
results.reduce((acc, calendars) => acc.concat(calendars), []).filter((c) => c != null)
|
||||
);
|
||||
|
||||
const createEvent = async (
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
const ALGORITHM = "aes256";
|
||||
const INPUT_ENCODING = "utf8";
|
||||
const OUTPUT_ENCODING = "hex";
|
||||
const IV_LENGTH = 16; // AES blocksize
|
||||
|
||||
/**
|
||||
*
|
||||
* @param text Value to be encrypted
|
||||
* @param key Key used to encrypt value must be 32 bytes for AES256 encryption algorithm
|
||||
*
|
||||
* @returns Encrypted value using key
|
||||
*/
|
||||
export const symmetricEncrypt = function (text: string, key: string) {
|
||||
const _key = Buffer.from(key, "latin1");
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, _key, iv);
|
||||
let ciphered = cipher.update(text, INPUT_ENCODING, OUTPUT_ENCODING);
|
||||
ciphered += cipher.final(OUTPUT_ENCODING);
|
||||
const ciphertext = iv.toString(OUTPUT_ENCODING) + ":" + ciphered;
|
||||
|
||||
return ciphertext;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param text Value to decrypt
|
||||
* @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm
|
||||
*/
|
||||
export const symmetricDecrypt = function (text: string, key: string) {
|
||||
const _key = Buffer.from(key, "latin1");
|
||||
|
||||
const components = text.split(":");
|
||||
const iv_from_ciphertext = Buffer.from(components.shift(), OUTPUT_ENCODING);
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, _key, iv_from_ciphertext);
|
||||
let deciphered = decipher.update(components.join(":"), OUTPUT_ENCODING, INPUT_ENCODING);
|
||||
deciphered += decipher.final(INPUT_ENCODING);
|
||||
|
||||
return deciphered;
|
||||
};
|
||||
@@ -6,6 +6,8 @@ export function getIntegrationName(name: String) {
|
||||
return "Office 365 Calendar";
|
||||
case "zoom_video":
|
||||
return "Zoom";
|
||||
case "caldav_calendar":
|
||||
return "CalDav Server";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { IntegrationCalendar, CalendarApiAdapter, CalendarEvent } from "../../calendarClient";
|
||||
import { symmetricDecrypt } from "@lib/crypto";
|
||||
import {
|
||||
createAccount,
|
||||
fetchCalendars,
|
||||
fetchCalendarObjects,
|
||||
getBasicAuthHeaders,
|
||||
createCalendarObject,
|
||||
updateCalendarObject,
|
||||
deleteCalendarObject,
|
||||
} from "tsdav";
|
||||
import { Credential } from "@prisma/client";
|
||||
import ICAL from "ical.js";
|
||||
import { createEvent, DurationObject, Attendee, Person } from "ics";
|
||||
import dayjs from "dayjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { stripHtml } from "../../emails/helpers";
|
||||
|
||||
type EventBusyDate = Record<"start" | "end", Date>;
|
||||
|
||||
export class CalDavCalendar implements CalendarApiAdapter {
|
||||
private url: string;
|
||||
private credentials: Record<string, string>;
|
||||
private headers: Record<string, string>;
|
||||
private readonly integrationName: string = "caldav_calendar";
|
||||
|
||||
constructor(credential: Credential) {
|
||||
const decryptedCredential = JSON.parse(
|
||||
symmetricDecrypt(credential.key, process.env.CALENDSO_ENCRYPTION_KEY)
|
||||
);
|
||||
const username = decryptedCredential.username;
|
||||
const url = decryptedCredential.url;
|
||||
const password = decryptedCredential.password;
|
||||
|
||||
this.url = url;
|
||||
|
||||
this.credentials = {
|
||||
username,
|
||||
password,
|
||||
};
|
||||
|
||||
this.headers = getBasicAuthHeaders({
|
||||
username,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
convertDate(date: string): number[] {
|
||||
return dayjs(date)
|
||||
.utc()
|
||||
.toArray()
|
||||
.slice(0, 6)
|
||||
.map((v, i) => (i === 1 ? v + 1 : v));
|
||||
}
|
||||
|
||||
getDuration(start: string, end: string): DurationObject {
|
||||
return {
|
||||
minutes: dayjs(end).diff(dayjs(start), "minute"),
|
||||
};
|
||||
}
|
||||
|
||||
getAttendees(attendees: Person[]): Attendee[] {
|
||||
return attendees.map(({ email, name }) => ({ name, email, partstat: "NEEDS-ACTION" }));
|
||||
}
|
||||
|
||||
async createEvent(event: CalendarEvent): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const calendars = await this.listCalendars();
|
||||
const uid = uuidv4();
|
||||
|
||||
const { error, value: iCalString } = await createEvent({
|
||||
uid,
|
||||
startInputType: "utc",
|
||||
start: this.convertDate(event.startTime),
|
||||
duration: this.getDuration(event.startTime, event.endTime),
|
||||
title: event.title,
|
||||
description: stripHtml(event.description),
|
||||
location: event.location,
|
||||
organizer: { email: event.organizer.email, name: event.organizer.name },
|
||||
attendees: this.getAttendees(event.attendees),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
calendars.map((calendar) => {
|
||||
return createCalendarObject({
|
||||
calendar: {
|
||||
url: calendar.externalId,
|
||||
},
|
||||
filename: `${uid}.ics`,
|
||||
iCalString: iCalString,
|
||||
headers: this.headers,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
uid,
|
||||
id: uid,
|
||||
};
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
}
|
||||
}
|
||||
|
||||
async updateEvent(uid: string, event: CalendarEvent): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const calendars = await this.listCalendars();
|
||||
const events = [];
|
||||
|
||||
for (const cal of calendars) {
|
||||
const calEvents = await this.getEvents(cal.externalId, null, null);
|
||||
|
||||
for (const ev of calEvents) {
|
||||
events.push(ev);
|
||||
}
|
||||
}
|
||||
|
||||
const { error, value: iCalString } = await createEvent({
|
||||
uid,
|
||||
startInputType: "utc",
|
||||
start: this.convertDate(event.startTime),
|
||||
duration: this.getDuration(event.startTime, event.endTime),
|
||||
title: event.title,
|
||||
description: stripHtml(event.description),
|
||||
location: event.location,
|
||||
organizer: { email: event.organizer.email, name: event.organizer.name },
|
||||
attendees: this.getAttendees(event.attendees),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventsToUpdate = events.filter((event) => event.uid === uid);
|
||||
|
||||
await Promise.all(
|
||||
eventsToUpdate.map((event) => {
|
||||
return updateCalendarObject({
|
||||
calendarObject: {
|
||||
url: event.url,
|
||||
data: iCalString,
|
||||
etag: event?.etag,
|
||||
},
|
||||
headers: this.headers,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return null;
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteEvent(uid: string): Promise<void> {
|
||||
try {
|
||||
const calendars = await this.listCalendars();
|
||||
const events = [];
|
||||
|
||||
for (const cal of calendars) {
|
||||
const calEvents = await this.getEvents(cal.externalId, null, null);
|
||||
|
||||
for (const ev of calEvents) {
|
||||
events.push(ev);
|
||||
}
|
||||
}
|
||||
|
||||
const eventsToUpdate = events.filter((event) => event.uid === uid);
|
||||
|
||||
await Promise.all(
|
||||
eventsToUpdate.map((event) => {
|
||||
return deleteCalendarObject({
|
||||
calendarObject: {
|
||||
url: event.url,
|
||||
etag: event?.etag,
|
||||
},
|
||||
headers: this.headers,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return null;
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailability(
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
selectedCalendars: IntegrationCalendar[]
|
||||
): Promise<EventBusyDate[]> {
|
||||
try {
|
||||
const selectedCalendarIds = selectedCalendars
|
||||
.filter((e) => e.integration === this.integrationName)
|
||||
.map((e) => e.externalId);
|
||||
|
||||
const events = [];
|
||||
|
||||
for (const calId of selectedCalendarIds) {
|
||||
const calEvents = await this.getEvents(calId, dateFrom, dateTo);
|
||||
|
||||
for (const ev of calEvents) {
|
||||
events.push({ start: ev.startDate, end: ev.endDate });
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
}
|
||||
}
|
||||
|
||||
async listCalendars(): Promise<IntegrationCalendar[]> {
|
||||
try {
|
||||
const account = await this.getAccount();
|
||||
const calendars = await fetchCalendars({
|
||||
account,
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
return calendars
|
||||
.filter((calendar) => {
|
||||
return calendar.components.includes("VEVENT");
|
||||
})
|
||||
.map((calendar) => ({
|
||||
externalId: calendar.url,
|
||||
name: calendar.displayName,
|
||||
primary: false,
|
||||
integration: this.integrationName,
|
||||
}));
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
}
|
||||
}
|
||||
|
||||
async getEvents(calId: string, dateFrom: string, dateTo: string): Promise<unknown> {
|
||||
try {
|
||||
//TODO: Figure out Time range and filters
|
||||
console.log(dateFrom, dateTo);
|
||||
const objects = await fetchCalendarObjects({
|
||||
calendar: {
|
||||
url: calId,
|
||||
},
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
const events =
|
||||
objects &&
|
||||
objects?.length > 0 &&
|
||||
objects
|
||||
.map((object) => {
|
||||
if (object?.data) {
|
||||
const jcalData = ICAL.parse(object.data);
|
||||
const vcalendar = new ICAL.Component(jcalData);
|
||||
const vevent = vcalendar.getFirstSubcomponent("vevent");
|
||||
const event = new ICAL.Event(vevent);
|
||||
|
||||
const startDate = new Date(event.startDate.toUnixTime() * 1000);
|
||||
const endDate = new Date(event.endDate.toUnixTime() * 1000);
|
||||
|
||||
return {
|
||||
uid: event.uid,
|
||||
etag: object.etag,
|
||||
url: object.url,
|
||||
summary: event.summary,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
sequence: event.sequence,
|
||||
startDate,
|
||||
endDate,
|
||||
duration: {
|
||||
weeks: event.duration.weeks,
|
||||
days: event.duration.days,
|
||||
hours: event.duration.hours,
|
||||
minutes: event.duration.minutes,
|
||||
seconds: event.duration.seconds,
|
||||
isNegative: event.duration.isNegative,
|
||||
},
|
||||
organizer: event.organizer,
|
||||
attendees: event.attendees.map((a) => a.getValues()),
|
||||
recurrenceId: event.recurrenceId,
|
||||
timezone: vcalendar.getFirstSubcomponent("vtimezone")
|
||||
? vcalendar.getFirstSubcomponent("vtimezone").getFirstPropertyValue("tzid")
|
||||
: "",
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter((e) => e != null);
|
||||
|
||||
return events;
|
||||
} catch (reason) {
|
||||
console.error(reason);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAccount() {
|
||||
const account = await createAccount({
|
||||
account: {
|
||||
serverUrl: `${this.url}`,
|
||||
accountType: "caldav",
|
||||
credentials: this.credentials,
|
||||
},
|
||||
headers: this.headers,
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
const AddCalDavIntegration = React.forwardRef<HTMLFormElement, Props>((props, ref) => {
|
||||
const onSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
props.onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<form id="addCalDav" ref={ref} onSubmit={onSubmit}>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
Calendar URL
|
||||
</label>
|
||||
<div className="mt-1 rounded-md shadow-sm flex">
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="url"
|
||||
id="url"
|
||||
placeholder="https://example.com/calendar"
|
||||
className="focus:ring-black focus:border-black flex-grow block w-full min-w-0 rounded-none rounded-r-sm sm:text-sm border-gray-300 lowercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
placeholder="rickroll"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
placeholder="•••••••••••••"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-sm shadow-sm py-2 px-3 focus:outline-none focus:ring-neutral-500 focus:border-neutral-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
AddCalDavIntegration.displayName = "AddCalDavIntegrationForm";
|
||||
export default AddCalDavIntegration;
|
||||
Reference in new issue
Block a user