Support CalDav

This commit is contained in:
Bruno Bernardino
2024-03-31 21:21:30 +01:00
parent 321341a2fb
commit c4788761d2
4 changed files with 185 additions and 52 deletions

View File

@@ -23,6 +23,29 @@ export const CALENDAR_COLOR_OPTIONS = [
'bg-rose-700', 'bg-rose-700',
] as const; ] as const;
const CALENDAR_COLOR_OPTIONS_HEX = [
'#B51E1F',
'#450A0A',
'#BF4310',
'#431407',
'#B0550F',
'#834F13',
'#4D7D16',
'#1A2E05',
'#148041',
'#066048',
'#107873',
'#0E7490',
'#075985',
'#1E3A89',
'#423BCA',
'#6A2BD9',
'#6923A9',
'#9D21B1',
'#9C174D',
'#BC133D',
] as const;
// NOTE: This variable isn't really used, _but_ it allows for tailwind to include the classes without having to move this into the tailwind.config.ts file // NOTE: This variable isn't really used, _but_ it allows for tailwind to include the classes without having to move this into the tailwind.config.ts file
export const CALENDAR_BORDER_COLOR_OPTIONS = [ export const CALENDAR_BORDER_COLOR_OPTIONS = [
'border-red-700', 'border-red-700',
@@ -47,6 +70,12 @@ export const CALENDAR_BORDER_COLOR_OPTIONS = [
'border-rose-700', 'border-rose-700',
] as const; ] as const;
export function getColorAsHex(calendarColor: string) {
const colorIndex = CALENDAR_COLOR_OPTIONS.findIndex((color) => color === calendarColor);
return CALENDAR_COLOR_OPTIONS_HEX[colorIndex];
}
function getVCalendarAttendeeStatus(status: CalendarEventAttendee['status']) { function getVCalendarAttendeeStatus(status: CalendarEventAttendee['status']) {
if (status === 'accepted' || status === 'rejected') { if (status === 'accepted' || status === 'rejected') {
return status.toUpperCase(); return status.toUpperCase();
@@ -77,7 +106,6 @@ function getSafelyUnescapedTextFromVCalendar(text: string) {
return text.replaceAll('\\n', '\n').replaceAll('\\,', ','); return text.replaceAll('\\n', '\n').replaceAll('\\,', ',');
} }
// TODO: Build this
export function formatCalendarEventsToVCalendar( export function formatCalendarEventsToVCalendar(
calendarEvents: CalendarEvent[], calendarEvents: CalendarEvent[],
calendars: Pick<Calendar, 'id' | 'color' | 'is_visible' | 'extra'>[], calendars: Pick<Calendar, 'id' | 'color' | 'is_visible' | 'extra'>[],
@@ -448,8 +476,6 @@ export function parseVCalendarFromTextContents(text: string): Partial<CalendarEv
continue; continue;
} }
// TODO: Build this ( https://en.wikipedia.org/wiki/ICalendar#List_of_components,_properties,_and_parameters )
} }
return partialCalendarEvents; return partialCalendarEvents;

View File

@@ -2,6 +2,7 @@ import { Handler } from 'fresh/server.ts';
import { FreshContextState } from '/lib/types.ts'; import { FreshContextState } from '/lib/types.ts';
import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts'; import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts';
import { getColorAsHex } from '/lib/utils/calendar.ts';
import { createSessionCookie } from '/lib/auth.ts'; import { createSessionCookie } from '/lib/auth.ts';
import { getCalendars } from '/lib/data/calendar.ts'; import { getCalendars } from '/lib/data/calendar.ts';
@@ -22,6 +23,21 @@ interface DavResponse {
'd:href': string; 'd:href': string;
}; };
'd:principal-URL'?: {}; 'd:principal-URL'?: {};
'd:current-user-privilege-set'?: {
'd:privilege': {
'd:write-properties'?: {};
'd:write'?: {};
'd:write-content'?: {};
'd:unlock'?: {};
'd:bind'?: {};
'd:unbind'?: {};
'd:write-acl'?: {};
'd:read'?: {};
'd:read-acl'?: {};
'd:read-current-user-privilege-set'?: {};
}[];
};
'ic:calendar-color'?: string;
}; };
'd:status': string; 'd:status': string;
}[]; }[];
@@ -38,6 +54,7 @@ interface DavMultiStatusResponse {
'xmlns:oc': string; 'xmlns:oc': string;
'xmlns:nc': string; 'xmlns:nc': string;
'xmlns:cs': string; 'xmlns:cs': string;
'xmlns:ic': string;
}; };
} }
@@ -88,13 +105,36 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
'xmlns:oc': 'http://owncloud.org/ns', 'xmlns:oc': 'http://owncloud.org/ns',
'xmlns:nc': 'http://nextcloud.org/ns', 'xmlns:nc': 'http://nextcloud.org/ns',
'xmlns:cs': 'http://calendarserver.org/ns/', 'xmlns:cs': 'http://calendarserver.org/ns/',
'xmlns:ic': 'http://apple.com/ns/ical/',
}, },
}; };
const calendars = await getCalendars(context.state.user.id); const calendars = await getCalendars(context.state.user.id);
const requestBody = (await request.clone().text()).toLowerCase();
const includePrivileges = requestBody.includes('current-user-privilege-set');
const includeColor = requestBody.includes('calendar-color');
if (includePrivileges) {
responseBody['d:multistatus']['d:response'][0]['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = {
'd:privilege': [
{ 'd:write-properties': {} },
{ 'd:write': {} },
{ 'd:write-content': {} },
{ 'd:unlock': {} },
{ 'd:bind': {} },
{ 'd:unbind': {} },
{ 'd:write-acl': {} },
{ 'd:read': {} },
{ 'd:read-acl': {} },
{ 'd:read-current-user-privilege-set': {} },
],
};
}
for (const calendar of calendars) { for (const calendar of calendars) {
responseBody['d:multistatus']['d:response'].push({ const parsedCalendar: DavResponse = {
'd:href': `/dav/calendars/${calendar.id}`, 'd:href': `/dav/calendars/${calendar.id}`,
'd:propstat': [{ 'd:propstat': [{
'd:prop': { 'd:prop': {
@@ -114,7 +154,30 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
'd:prop': { 'd:principal-URL': {} }, 'd:prop': { 'd:principal-URL': {} },
'd:status': 'HTTP/1.1 404 Not Found', 'd:status': 'HTTP/1.1 404 Not Found',
}], }],
}); };
if (includePrivileges) {
parsedCalendar['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = {
'd:privilege': [
{ 'd:write-properties': {} },
{ 'd:write': {} },
{ 'd:write-content': {} },
{ 'd:unlock': {} },
{ 'd:bind': {} },
{ 'd:unbind': {} },
{ 'd:write-acl': {} },
{ 'd:read': {} },
{ 'd:read-acl': {} },
{ 'd:read-current-user-privilege-set': {} },
],
};
}
if (includeColor) {
parsedCalendar['d:propstat'][0]['d:prop']['ic:calendar-color'] = getColorAsHex(calendar.color);
}
responseBody['d:multistatus']['d:response'].push(parsedCalendar);
} }
const response = new Response(convertObjectToDavXml(responseBody, true), { const response = new Response(convertObjectToDavXml(responseBody, true), {

View File

@@ -1,9 +1,11 @@
import { Handler } from 'fresh/server.ts'; import { Handler } from 'fresh/server.ts';
import { parse } from 'xml/mod.ts';
import { Calendar, FreshContextState } from '/lib/types.ts'; import { Calendar, FreshContextState } from '/lib/types.ts';
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts'; import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts'; import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts';
import { import {
createCalendar,
createCalendarEvent, createCalendarEvent,
getCalendar, getCalendar,
getCalendarEvent, getCalendarEvent,
@@ -76,8 +78,27 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
console.error(error); console.error(error);
} }
if (!calendar && request.method === 'MKCALENDAR') {
const requestBody = await request.clone().text();
try {
const parsedDocument = parse(requestBody);
const makeCalendarRequest = (parsedDocument['c:mkcalendar'] || parsedDocument['cal:mkcalendar']) as
| Record<string, any>
| undefined;
const name: string = makeCalendarRequest!['d:set']['d:prop']['d:displayname']!;
const calendar = await createCalendar(context.state.user.id, name);
return new Response('Created', { status: 201, headers: { 'etag': `"${calendar.revision}"` } });
} catch (error) {
console.error(`Failed to parse XML`, error);
}
}
if (!calendar) { if (!calendar) {
// TODO: Support MKCALENDAR
return new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 });
} }

View File

@@ -2,8 +2,14 @@ import { Handler } from 'fresh/server.ts';
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts'; import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts'; import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
import { formatCalendarEventsToVCalendar } from '/lib/utils/calendar.ts'; import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts';
import { getCalendar, getCalendarEvent, updateCalendarEvent } from '/lib/data/calendar.ts'; import {
createCalendarEvent,
deleteCalendarEvent,
getCalendar,
getCalendarEvent,
updateCalendarEvent,
} from '/lib/data/calendar.ts';
import { createSessionCookie } from '/lib/auth.ts'; import { createSessionCookie } from '/lib/auth.ts';
interface Data {} interface Data {}
@@ -83,71 +89,88 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
if (!calendarEvent) { if (!calendarEvent) {
if (request.method === 'PUT') { if (request.method === 'PUT') {
// TODO: Build this const requestBody = await request.clone().text();
// const newCalendarEvent = await createCalendarEvent(
// context.state.user.id,
// partialCalendarEvent.title,
// partialCalendarEvent.start_date,
// partialCalendarEvent.end_date,
// );
// // Use the sent id for the UID const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody);
// if (!partialCalendarEvent.extra?.uid) {
// partialCalendarEvent.extra = {
// ...(partialCalendarEvent.extra! || {}),
// uid: calendarId,
// };
// }
// newCalendarEvent.extra = partialCalendarEvent.extra!; if (partialCalendarEvent.title && partialCalendarEvent.start_date && partialCalendarEvent.end_date) {
const newCalendarEvent = await createCalendarEvent(
context.state.user.id,
calendarId,
partialCalendarEvent.title,
new Date(partialCalendarEvent.start_date),
new Date(partialCalendarEvent.end_date),
partialCalendarEvent.is_all_day,
);
// await updateCalendarEvent(newCalendarEvent); // Use the sent id for the UID
if (!partialCalendarEvent.extra?.uid) {
partialCalendarEvent.extra = {
...(partialCalendarEvent.extra! || {}),
uid: calendarEventId,
};
}
// const calendarEvent = await getCalendarEvent(newCalendarEvent.id, context.state.user.id); const parsedExtra = JSON.stringify(partialCalendarEvent.extra || {});
// return new Response('Created', { status: 201, headers: { 'etag': `"${calendarEvent.revision}"` } }); if (parsedExtra !== '{}') {
newCalendarEvent.extra = partialCalendarEvent.extra!;
if (
newCalendarEvent.extra.is_recurring && newCalendarEvent.extra.recurring_sequence === 0 &&
!newCalendarEvent.extra.recurring_id
) {
newCalendarEvent.extra.recurring_id = newCalendarEvent.id;
}
await updateCalendarEvent(newCalendarEvent);
}
const calendarEvent = await getCalendarEvent(newCalendarEvent.id, context.state.user.id);
return new Response('Created', { status: 201, headers: { 'etag': `"${calendarEvent.revision}"` } });
}
} }
return new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 });
} }
// TODO: Build this if (request.method === 'DELETE') {
// if (request.method === 'DELETE') { const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
// const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
// // Don't update outdated data // Don't update outdated data
// if (clientRevision && clientRevision !== `"${calendarEvent.revision}"`) { if (clientRevision && clientRevision !== `"${calendarEvent.revision}"`) {
// return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } }); return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } });
// } }
// await deleteContact(contactId, context.state.user.id); await deleteCalendarEvent(calendarEventId, calendarId, context.state.user.id);
// return new Response(null, { status: 202 }); return new Response(null, { status: 202 });
// } }
// if (request.method === 'PUT') { if (request.method === 'PUT') {
// const clientRevision = request.headers.get('if-match') || request.headers.get('etag'); const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
// // Don't update outdated data // Don't update outdated data
// if (clientRevision && clientRevision !== `"${contact.revision}"`) { if (clientRevision && clientRevision !== `"${calendarEvent.revision}"`) {
// return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } }); return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } });
// } }
// const requestBody = await request.clone().text(); const requestBody = await request.clone().text();
// const [partialContact] = parseVCardFromTextContents(requestBody); const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody);
// contact = { calendarEvent = {
// ...contact, ...calendarEvent,
// ...partialContact, ...partialCalendarEvent,
// }; };
// await updateContact(contact); await updateCalendarEvent(calendarEvent);
// contact = await getContact(contactId, context.state.user.id); calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id);
// return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } }); return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } });
// } }
if (request.method === 'GET') { if (request.method === 'GET') {
// Set a UID if there isn't one // Set a UID if there isn't one