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:
Femi Odugbesan 2021-08-14 20:53:59 -05:00 committed by GitHub
parent 21c709ee46
commit 65366b7c5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 706 additions and 30 deletions

View File

@ -34,3 +34,7 @@ EMAIL_SERVER_USER='<office365_emailAddress>'
EMAIL_SERVER_PASSWORD='<office365_password>'
# ApiKey for cronjobs
CRON_API_KEY='0cc0e6c35519bba620c9360cfe3e68d0'
# Application Key for symmetric encryption and decryption
# must be 32 bytes for AES256 encryption algorithm
CALENDSO_ENCRYPTION_KEY=

View File

@ -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 (

42
lib/crypto.ts Normal file
View File

@ -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;
};

View File

@ -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";
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -30,6 +30,7 @@
"dayjs-business-days": "^1.0.4",
"googleapis": "^67.1.1",
"handlebars": "^4.7.7",
"ical.js": "^1.4.0",
"ics": "^2.27.0",
"lodash.debounce": "^4.0.8",
"lodash.merge": "^4.6.2",
@ -46,6 +47,7 @@
"react-select": "^4.3.0",
"react-timezone-select": "^1.0.2",
"short-uuid": "^4.2.0",
"tsdav": "^1.0.2",
"tslog": "^3.2.0",
"uuid": "^8.3.2"
},

View File

@ -0,0 +1,79 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getSession } from "next-auth/client";
import prisma from "../../../../lib/prisma";
import { symmetricEncrypt } from "@lib/crypto";
import logger from "@lib/logger";
import { davRequest, getBasicAuthHeaders } from "tsdav";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// Check that user is authenticated
const session = await getSession({ req: req });
if (!session) {
res.status(401).json({ message: "You must be logged in to do this" });
return;
}
const { username, password, url } = req.body;
// Get user
await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true,
},
});
const header = getBasicAuthHeaders({
username,
password,
});
try {
const [response] = await davRequest({
url: url,
init: {
method: "PROPFIND",
namespace: "d",
body: {
propfind: {
_attributes: {
"xmlns:d": "DAV:",
},
prop: { "d:current-user-principal": {} },
},
},
headers: header,
},
});
if (!response.ok) {
logger.error("Could not add this caldav account", response?.statusText);
logger.error(response.error);
return res.status(200).json({ message: "Could not add this caldav account" });
}
if (response.ok) {
await prisma.credential.create({
data: {
type: "caldav_calendar",
key: symmetricEncrypt(
JSON.stringify({ username, password, url }),
process.env.CALENDSO_ENCRYPTION_KEY
),
userId: session.user.id,
},
});
}
} catch (reason) {
logger.error("Could not add this caldav account", reason);
return res.status(200).json({ message: "Could not add this caldav account" });
}
// TODO VALIDATE URL
// TODO VALIDATE CONNECTION IS POSSIBLE
return res.status(200).json({});
}
}

View File

@ -2,18 +2,36 @@ import Head from "next/head";
import Link from "next/link";
import prisma from "../../lib/prisma";
import Shell from "../../components/Shell";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { getSession, useSession } from "next-auth/client";
import { CheckCircleIcon, ChevronRightIcon, PlusIcon, XCircleIcon } from "@heroicons/react/solid";
import { InformationCircleIcon } from "@heroicons/react/outline";
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTrigger } from "@components/Dialog";
import Switch from "@components/ui/Switch";
import Loader from "@components/Loader";
import AddCalDavIntegration from "@lib/integrations/CalDav/components/AddCalDavIntegration";
type Integration = {
installed: boolean;
credential: unknown;
type: string;
title: string;
imageSrc: string;
description: string;
};
type Props = {
integrations: Integration[];
};
export default function Home({ integrations }: Props) {
const [, loading] = useSession();
export default function IntegrationHome({ integrations }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [session, loading] = useSession();
const [selectableCalendars, setSelectableCalendars] = useState([]);
const addCalDavIntegrationRef = useRef<HTMLFormElement>(null);
const [isAddCalDavIntegrationDialogOpen, setIsAddCalDavIntegrationDialogOpen] = useState(false);
useEffect(loadCalendars, [integrations]);
function loadCalendars() {
fetch("api/availability/calendar")
@ -24,11 +42,32 @@ export default function IntegrationHome({ integrations }) {
}
function integrationHandler(type) {
if (type === "caldav_calendar") {
setIsAddCalDavIntegrationDialogOpen(true);
return;
}
fetch("/api/integrations/" + type.replace("_", "") + "/add")
.then((response) => response.json())
.then((data) => (window.location.href = data.url));
}
const handleAddCalDavIntegration = async ({ url, username, password }) => {
const requestBody = JSON.stringify({
url,
username,
password,
});
await fetch("/api/integrations/caldav/add", {
method: "POST",
body: requestBody,
headers: {
"Content-Type": "application/json",
},
});
};
function calendarSelectionHandler(calendar) {
return (selected) => {
const i = selectableCalendars.findIndex((c) => c.externalId === calendar.externalId);
@ -59,6 +98,8 @@ export default function IntegrationHome({ integrations }) {
return "integrations/google-calendar.svg";
case "office365_calendar":
return "integrations/outlook.svg";
case "caldav_calendar":
return "integrations/generic-calendar.png";
default:
return "";
}
@ -68,12 +109,6 @@ export default function IntegrationHome({ integrations }) {
setSelectableCalendars([...selectableCalendars]);
}
useEffect(loadCalendars, [integrations]);
if (loading) {
return <Loader />;
}
const ConnectNewAppDialog = () => (
<Dialog>
<DialogTrigger className="py-2 px-4 mt-6 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
@ -87,24 +122,35 @@ export default function IntegrationHome({ integrations }) {
<ul className="divide-y divide-gray-200">
{integrations
.filter((integration) => integration.installed)
.map((integration) => (
<li key={integration.type} className="flex py-4">
<div className="w-1/12 mr-4 pt-2">
<img className="h-8 w-8 mr-2" src={integration.imageSrc} alt={integration.title} />
</div>
<div className="w-10/12">
<h2 className="text-gray-800 font-medium">{integration.title}</h2>
<p className="text-gray-400 text-sm">{integration.description}</p>
</div>
<div className="w-2/12 text-right pt-2">
<button
onClick={() => integrationHandler(integration.type)}
className="font-medium text-neutral-900 hover:text-neutral-500">
Add
</button>
</div>
</li>
))}
.map((integration) => {
return (
<li key={integration.type} className="flex py-4">
<div className="w-1/12 mr-4 pt-2">
<img className="h-8 w-8 mr-2" src={integration.imageSrc} alt={integration.title} />
</div>
<div className="w-10/12">
<h2 className="text-gray-800 font-medium">{integration.title}</h2>
<p className="text-gray-400 text-sm">{integration.description}</p>
</div>
<div className="w-2/12 text-right pt-2">
{integration.type === "caldav_calendar" ? (
<button
onClick={() => integrationHandler(integration.type)}
className="font-medium text-neutral-900 hover:text-neutral-500">
Add
</button>
) : (
// <ConnectCalDavServerDialog isOpen={isOpen}/>
<button
onClick={() => integrationHandler(integration.type)}
className="font-medium text-neutral-900 hover:text-neutral-500">
Add
</button>
)}
</div>
</li>
);
})}
</ul>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
@ -160,6 +206,85 @@ export default function IntegrationHome({ integrations }) {
</Dialog>
);
function handleAddCalDavIntegrationSaveButtonPress() {
const form = addCalDavIntegrationRef.current.elements;
const url = form.url.value;
const password = form.password.value;
const username = form.username.value;
try {
handleAddCalDavIntegration({ username, password, url });
} catch (reason) {
console.error(reason);
}
}
const onSubmit = () => {
const form = addCalDavIntegrationRef.current;
if (form) {
if (typeof form.requestSubmit === "function") {
form.requestSubmit();
} else {
form.dispatchEvent(new Event("submit", { cancelable: true }));
}
setIsAddCalDavIntegrationDialogOpen(false);
}
};
const ConnectCalDavServerDialog = ({ isOpen }) => {
return (
<Dialog open={isOpen}>
<DialogContent>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true"></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div className="inline-block align-bottom bg-white rounded-sm px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-neutral-100 sm:mx-0 sm:h-10 sm:w-10">
<PlusIcon className="h-6 w-6 text-neutral-900" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900" id="modal-title">
Connect to CalDav Server
</h3>
<div>
<p className="text-sm text-gray-400">Your credentials will be stored and encrypted.</p>
</div>
</div>
</div>
<div className="my-4">
<AddCalDavIntegration
ref={addCalDavIntegrationRef}
onSubmit={handleAddCalDavIntegrationSaveButtonPress}
/>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
onClick={onSubmit}
className="flex justify-center py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-neutral-900 hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900">
Save
</button>
<DialogClose as="button" className="btn btn-white mx-2">
Cancel
</DialogClose>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
if (loading) {
return <Loader />;
}
return (
<div>
<Head>
@ -262,6 +387,7 @@ export default function IntegrationHome({ integrations }) {
</div>
</div>
</div>
<ConnectCalDavServerDialog isOpen={isAddCalDavIntegrationDialogOpen} />
</Shell>
</div>
);
@ -329,6 +455,14 @@ export async function getServerSideProps(context) {
imageSrc: "integrations/zoom.svg",
description: "Video Conferencing",
},
{
installed: true,
type: "caldav_calendar",
credential: credentials.find((integration) => integration.type === "caldav_calendar") || null,
title: "CalDav Server",
imageSrc: "integrations/generic-calendar.png",
description: "For personal and business calendars",
},
];
return {

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1738,6 +1738,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base-64@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/base-64/-/base-64-1.0.0.tgz#09d0f2084e32a3fd08c2475b973788eee6ae8f4a"
integrity sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==
base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@ -2268,6 +2273,13 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
cross-fetch@^3.1.4:
version "3.1.4"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39"
integrity sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ==
dependencies:
node-fetch "2.6.1"
cross-spawn@^7.0.2, cross-spawn@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@ -3414,6 +3426,11 @@ husky@^6.0.0:
resolved "https://registry.yarnpkg.com/husky/-/husky-6.0.0.tgz#810f11869adf51604c32ea577edbc377d7f9319e"
integrity sha512-SQS2gDTB7tBN486QSoKPKQItZw97BMOd+Kdb6ghfpBc0yXyzrddI0oDV5MkDAbuB4X2mO3/nj60TRMcYxwzZeQ==
ical.js@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/ical.js/-/ical.js-1.4.0.tgz#fc5619dc55fe03d909bf04362aa0677f4541b9d7"
integrity sha512-ltHZuOFNNjcyEYbzDgjemS7LWIFh2vydJeznxQHUh3dnarbxqOYsWONYteBVAq1MEOHnwXFGN2eskZReHclnrA==
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@ -5887,7 +5904,7 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
sax@>=0.6.0:
sax@>=0.6.0, sax@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
@ -6487,6 +6504,16 @@ ts-pnp@^1.1.6:
resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92"
integrity sha512-csd+vJOb/gkzvcCHgTGSChYpy5f1/XKNsmvBGO4JXS+z1v2HobugDz4s1IeFXM3wZB44uczs+eazB5Q/ccdhQw==
tsdav@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/tsdav/-/tsdav-1.0.2.tgz#bc30b7c6278054771aabd3d3a13c4c1af013bd88"
integrity sha512-a6HgwzduoZWG3UbSeTeS3d/CQQBzrp9KrDdJ0gTng0whlgaPgV5AlxnCY5gah/GbpprsxBlB8QD41NxVnNTLyQ==
dependencies:
base-64 "^1.0.0"
cross-fetch "^3.1.4"
debug "^4.3.1"
xml-js "^1.6.11"
tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
@ -6885,6 +6912,13 @@ ws@^7.4.5:
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.1.tgz#44fc000d87edb1d9c53e51fbc69a0ac1f6871d66"
integrity sha512-2c6faOUH/nhoQN6abwMloF7Iyl0ZS2E9HGtsiLrWn0zOOMWlhtDmdf/uihDt6jnuCxgtwGBNy6Onsoy2s2O2Ow==
xml-js@^1.6.11:
version "1.6.11"
resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9"
integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==
dependencies:
sax "^1.2.4"
xml-name-validator@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"