Add CalDav routes and methods, with mock data

This commit is contained in:
Bruno Bernardino
2024-03-18 19:18:29 +00:00
parent 3c66eec301
commit 062c0d3d09
11 changed files with 1008 additions and 115 deletions

View File

@@ -0,0 +1,7 @@
// Nextcloud/ownCloud mimicry
export function handler(): Response {
return new Response('Redirecting...', {
status: 307,
headers: { Location: '/dav' },
});
}

View File

@@ -1,107 +1,9 @@
import { Handlers, PageProps } from 'fresh/server.ts';
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
// import { getCalendars, getCalendarEvents } from '/lib/data/calendar.ts';
import { getCalendarEvents, getCalendars } from '/lib/data/calendar.ts';
import MainCalendar from '/islands/calendar/MainCalendar.tsx';
async function getCalendars(userId: string): Promise<Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[]> {
// TODO: Remove this
await new Promise((resolve) => setTimeout(() => resolve(true), 1));
return [
{
id: 'family-1',
name: 'Family',
color: 'bg-purple-500',
is_visible: true,
},
{
id: 'personal-1',
name: 'Personal',
color: 'bg-sky-600',
is_visible: true,
},
{
id: 'house-chores-1',
name: 'House Chores',
color: 'bg-red-700',
is_visible: true,
},
];
}
async function getCalendarEvents(userId: string, calendarIds: string[]): Promise<CalendarEvent[]> {
// TODO: Remove this
await new Promise((resolve) => setTimeout(() => resolve(true), 1));
return [
{
id: 'event-1',
user_id: userId,
calendar_id: 'family-1',
revision: 'fake-rev',
title: 'Dentist',
start_date: new Date('2024-03-18T14:00:00.000Z'),
end_date: new Date('2024-03-18T15:00:00.000Z'),
is_all_day: false,
status: 'scheduled',
extra: {
visibility: 'default',
},
updated_at: new Date(),
created_at: new Date(),
},
{
id: 'event-2',
user_id: userId,
calendar_id: 'family-1',
revision: 'fake-rev',
title: 'Dermatologist',
start_date: new Date('2024-03-18T16:30:00.000Z'),
end_date: new Date('2024-03-18T17:30:00.000Z'),
is_all_day: false,
status: 'scheduled',
extra: {
visibility: 'default',
},
updated_at: new Date(),
created_at: new Date(),
},
{
id: 'event-3',
user_id: userId,
calendar_id: 'house-chores-1',
revision: 'fake-rev',
title: 'Vacuum',
start_date: new Date('2024-03-19T15:00:00.000Z'),
end_date: new Date('2024-03-19T16:00:00.000Z'),
is_all_day: false,
status: 'scheduled',
extra: {
visibility: 'default',
},
updated_at: new Date(),
created_at: new Date(),
},
{
id: 'event-4',
user_id: userId,
calendar_id: 'personal-1',
revision: 'fake-rev',
title: 'Schedule server updates',
start_date: new Date('2024-03-20T09:00:00.000Z'),
end_date: new Date('2024-03-20T21:00:00.000Z'),
is_all_day: true,
status: 'scheduled',
extra: {
visibility: 'default',
},
updated_at: new Date(),
created_at: new Date(),
},
];
}
interface Data {
userCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[];
userCalendarEvents: CalendarEvent[];

133
routes/dav/calendars.tsx Normal file
View File

@@ -0,0 +1,133 @@
import { Handler } from 'fresh/server.ts';
import { FreshContextState } from '/lib/types.ts';
import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils.ts';
import { createSessionCookie } from '/lib/auth.ts';
import { getCalendars } from '/lib/data/calendar.ts';
interface Data {}
interface DavResponse {
'd:href': string;
'd:propstat': {
'd:prop': {
'd:resourcetype'?: {
'd:collection': {};
'cal:calendar'?: {};
};
'd:displayname'?: string | {};
'd:getetag'?: string | {};
'cs:getctag'?: string | {};
'd:current-user-principal'?: {
'd:href': string;
};
'd:principal-URL'?: {};
};
'd:status': string;
}[];
}
interface DavMultiStatusResponse {
'd:multistatus': {
'd:response': DavResponse[];
};
'd:multistatus_attributes': {
'xmlns:d': string;
'xmlns:s': string;
'xmlns:cal': string;
'xmlns:oc': string;
'xmlns:nc': string;
'xmlns:cs': string;
};
}
interface ResponseBody extends DavMultiStatusResponse {}
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
if (!context.state.user) {
return new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
});
}
if (request.method === 'GET') {
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
}
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
return new Response('Bad Request', { status: 400 });
}
const responseBody: ResponseBody = {
'd:multistatus': {
'd:response': [
{
'd:href': '/dav/calendars/',
'd:propstat': [{
'd:prop': {
'd:resourcetype': {
'd:collection': {},
},
'd:current-user-principal': {
'd:href': '/dav/principals/',
},
},
'd:status': 'HTTP/1.1 200 OK',
}, {
'd:prop': { 'd:principal-URL': {}, 'd:displayname': {}, 'cs:getctag': {} },
'd:status': 'HTTP/1.1 404 Not Found',
}],
},
],
},
'd:multistatus_attributes': {
'xmlns:d': 'DAV:',
'xmlns:s': 'http://sabredav.org/ns',
'xmlns:cal': 'urn:ietf:params:xml:ns:caldav',
'xmlns:oc': 'http://owncloud.org/ns',
'xmlns:nc': 'http://nextcloud.org/ns',
'xmlns:cs': 'http://calendarserver.org/ns/',
},
};
const calendars = await getCalendars(context.state.user.id);
for (const calendar of calendars) {
responseBody['d:multistatus']['d:response'].push({
'd:href': `/dav/calendars/${calendar.id}`,
'd:propstat': [{
'd:prop': {
'd:resourcetype': {
'd:collection': {},
'cal:calendar': {},
},
'd:displayname': calendar.name,
'd:getetag': escapeHtml(`"${calendar.revision}"`),
'cs:getctag': calendar.revision,
'd:current-user-principal': {
'd:href': '/dav/principals/',
},
},
'd:status': 'HTTP/1.1 200 OK',
}, {
'd:prop': { 'd:principal-URL': {} },
'd:status': 'HTTP/1.1 404 Not Found',
}],
});
}
const response = new Response(convertObjectToDavXml(responseBody, true), {
headers: {
'content-type': 'application/xml; charset=utf-8',
'dav': DAV_RESPONSE_HEADER,
},
status: 207,
});
if (context.state.session) {
return response;
}
return createSessionCookie(request, context.state.user, response, true);
};

View File

@@ -0,0 +1,209 @@
import { Handler } from 'fresh/server.ts';
import { Calendar, FreshContextState } from '/lib/types.ts';
import {
buildRFC822Date,
convertObjectToDavXml,
DAV_RESPONSE_HEADER,
escapeHtml,
escapeXml,
formatCalendarEventsToVCalendar,
parseVCalendarFromTextContents,
} from '/lib/utils.ts';
import { getCalendar, getCalendarEvents } from '/lib/data/calendar.ts';
import { createSessionCookie } from '/lib/auth.ts';
interface Data {}
interface DavResponse {
'd:href': string;
'd:propstat': {
'd:prop': {
'd:resourcetype'?: {
'd:collection'?: {};
'cal:calendar'?: {};
};
'd:displayname'?: string | {};
'cal:calendar-data'?: string;
'd:getlastmodified'?: string;
'd:getetag'?: string;
'cs:getctag'?: string;
'd:getcontenttype'?: string;
'd:getcontentlength'?: {};
'd:creationdate'?: string;
};
'd:status': string;
}[];
}
interface DavMultiStatusResponse {
'd:multistatus': {
'd:response': DavResponse[];
};
'd:multistatus_attributes': {
'xmlns:d': string;
'xmlns:s': string;
'xmlns:cal': string;
'xmlns:oc': string;
'xmlns:nc': string;
'xmlns:cs': string;
};
}
interface ResponseBody extends DavMultiStatusResponse {}
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
if (!context.state.user) {
return new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
});
}
if (
request.method !== 'PROPFIND' && request.method !== 'REPORT' && request.method !== 'GET' &&
request.method !== 'PUT' && request.method !== 'MKCALENDAR'
) {
return new Response('Bad Request', { status: 400 });
}
const { calendarId } = context.params;
let calendar: Calendar | null = null;
try {
calendar = await getCalendar(calendarId, context.state.user.id);
} catch (error) {
console.error(error);
}
if (!calendar) {
// TODO: Support MKCALENDAR
return new Response('Not found', { status: 404 });
}
if (request.method === 'PUT') {
const requestBody = await request.clone().text();
const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody);
if (partialCalendarEvent.title) {
// TODO: Build this
// const newCalendarEvent = await createCalendarEvent(
// context.state.user.id,
// partialCalendarEvent.title,
// partialCalendarEvent.start_date,
// partialCalendarEvent.end_date,
// );
// // Use the sent id for the UID
// if (!partialCalendarEvent.extra?.uid) {
// partialCalendarEvent.extra = {
// ...(partialCalendarEvent.extra! || {}),
// uid: calendarId,
// };
// }
// newCalendarEvent.extra = partialCalendarEvent.extra!;
// 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 });
}
}
const calendarEvents = await getCalendarEvents(context.state.user.id, [calendar.id]);
if (request.method === 'GET') {
const response = new Response(formatCalendarEventsToVCalendar(calendarEvents, calendar), {
status: 200,
});
if (context.state.session) {
return response;
}
return createSessionCookie(request, context.state.user, response, true);
}
const requestBody = (await request.clone().text()).toLowerCase();
const includeVCalendar = requestBody.includes('calendar-data');
const parsedCalendar: DavResponse = {
'd:href': `/dav/calendars/${calendar.id}`,
'd:propstat': [{
'd:prop': {
'd:displayname': calendar.name,
'd:getlastmodified': buildRFC822Date(calendar.updated_at.toISOString()),
'd:getetag': escapeHtml(`"${calendar.revision}"`),
'cs:getctag': calendar.revision,
'd:getcontenttype': 'text/calendar; charset=utf-8',
'd:resourcetype': {},
},
'd:status': 'HTTP/1.1 200 OK',
}],
};
const parsedCalendarEvents = calendarEvents.map((calendarEvent) => {
const parsedCalendarEvent: DavResponse = {
'd:href': `/dav/calendars/${calendar!.id}/${calendarEvent.id}.ics`,
'd:propstat': [{
'd:prop': {
'd:getlastmodified': buildRFC822Date(calendarEvent.updated_at.toISOString()),
'd:getetag': escapeHtml(`"${calendarEvent.revision}"`),
'cs:getctag': calendarEvent.revision,
'd:getcontenttype': 'text/calendar; charset=utf-8',
'd:resourcetype': {},
'd:creationdate': buildRFC822Date(calendarEvent.created_at.toISOString()),
},
'd:status': 'HTTP/1.1 200 OK',
}, {
'd:prop': {
'd:displayname': {},
'd:getcontentlength': {},
},
'd:status': 'HTTP/1.1 404 Not Found',
}],
};
if (includeVCalendar) {
parsedCalendarEvent['d:propstat'][0]['d:prop']['cal:calendar-data'] = escapeXml(
formatCalendarEventsToVCalendar([calendarEvent], calendar!),
);
}
return parsedCalendarEvent;
});
const responseBody: ResponseBody = {
'd:multistatus': {
'd:response': [parsedCalendar, ...parsedCalendarEvents],
},
'd:multistatus_attributes': {
'xmlns:d': 'DAV:',
'xmlns:s': 'http://sabredav.org/ns',
'xmlns:cal': 'urn:ietf:params:xml:ns:caldav',
'xmlns:oc': 'http://owncloud.org/ns',
'xmlns:nc': 'http://nextcloud.org/ns',
'xmlns:cs': 'http://calendarserver.org/ns/',
},
};
const response = new Response(convertObjectToDavXml(responseBody, true), {
headers: {
'content-type': 'application/xml; charset=utf-8',
'dav': DAV_RESPONSE_HEADER,
},
status: 207,
});
if (context.state.session) {
return response;
}
return createSessionCookie(request, context.state.user, response, true);
};

View File

@@ -0,0 +1,229 @@
import { Handler } from 'fresh/server.ts';
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
import {
buildRFC822Date,
convertObjectToDavXml,
DAV_RESPONSE_HEADER,
escapeHtml,
escapeXml,
formatCalendarEventsToVCalendar,
parseVCalendarFromTextContents,
} from '/lib/utils.ts';
import { getCalendar, getCalendarEvent, getCalendarEvents } from '/lib/data/calendar.ts';
import { createSessionCookie } from '/lib/auth.ts';
interface Data {}
interface DavResponse {
'd:href': string;
'd:propstat': {
'd:prop': {
'd:resourcetype'?: {
'd:collection'?: {};
'cal:calendar'?: {};
};
'd:displayname'?: string | {};
'cal:calendar-data'?: string;
'd:getlastmodified'?: string;
'd:getetag'?: string;
'cs:getctag'?: string;
'd:getcontenttype'?: string;
'd:getcontentlength'?: {};
'd:creationdate'?: string;
};
'd:status': string;
}[];
}
interface DavMultiStatusResponse {
'd:multistatus': {
'd:response': DavResponse[];
};
'd:multistatus_attributes': {
'xmlns:d': string;
'xmlns:s': string;
'xmlns:cal': string;
'xmlns:oc': string;
'xmlns:nc': string;
'xmlns:cs': string;
};
}
interface ResponseBody extends DavMultiStatusResponse {}
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
if (!context.state.user) {
return new Response('Unauthorized', {
status: 401,
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
});
}
if (
request.method !== 'PROPFIND' && request.method !== 'REPORT' && request.method !== 'GET' &&
request.method !== 'PUT' && request.method !== 'DELETE'
) {
return new Response('Bad Request', { status: 400 });
}
const { calendarId, calendarEventId } = context.params;
let calendar: Calendar | null = null;
let calendarEvent: CalendarEvent | null = null;
try {
calendar = await getCalendar(calendarId, context.state.user.id);
} catch (error) {
console.error(error);
}
if (!calendar) {
return new Response('Not found', { status: 404 });
}
try {
calendarEvent = await getCalendarEvent(calendarEventId, calendarId, context.state.user.id);
} catch (error) {
console.error(error);
}
if (!calendarEvent) {
if (request.method === 'PUT') {
// TODO: Build this
// const newCalendarEvent = await createCalendarEvent(
// context.state.user.id,
// partialCalendarEvent.title,
// partialCalendarEvent.start_date,
// partialCalendarEvent.end_date,
// );
// // Use the sent id for the UID
// if (!partialCalendarEvent.extra?.uid) {
// partialCalendarEvent.extra = {
// ...(partialCalendarEvent.extra! || {}),
// uid: calendarId,
// };
// }
// newCalendarEvent.extra = partialCalendarEvent.extra!;
// 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 });
}
// TODO: Build this
// if (request.method === 'DELETE') {
// const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
// // Don't update outdated data
// if (clientRevision && clientRevision !== `"${calendarEvent.revision}"`) {
// return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } });
// }
// await deleteContact(contactId, context.state.user.id);
// return new Response(null, { status: 202 });
// }
// if (request.method === 'PUT') {
// const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
// // Don't update outdated data
// if (clientRevision && clientRevision !== `"${contact.revision}"`) {
// return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
// }
// const requestBody = await request.clone().text();
// const [partialContact] = parseVCardFromTextContents(requestBody);
// contact = {
// ...contact,
// ...partialContact,
// };
// await updateContact(contact);
// contact = await getContact(contactId, context.state.user.id);
// return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
// }
if (request.method === 'GET') {
const response = new Response(formatCalendarEventsToVCalendar([calendarEvent], calendar), {
status: 200,
});
if (context.state.session) {
return response;
}
return createSessionCookie(request, context.state.user, response, true);
}
const requestBody = (await request.clone().text()).toLowerCase();
const includeVCalendar = requestBody.includes('calendar-data');
const parsedCalendarEvent: DavResponse = {
'd:href': `/dav/calendars/${calendar!.id}/${calendarEvent.id}.ics`,
'd:propstat': [{
'd:prop': {
'd:getlastmodified': buildRFC822Date(calendarEvent.updated_at.toISOString()),
'd:getetag': escapeHtml(`"${calendarEvent.revision}"`),
'cs:getctag': calendarEvent.revision,
'd:getcontenttype': 'text/calendar; charset=utf-8',
'd:resourcetype': {},
'd:creationdate': buildRFC822Date(calendarEvent.created_at.toISOString()),
},
'd:status': 'HTTP/1.1 200 OK',
}, {
'd:prop': {
'd:displayname': {},
'd:getcontentlength': {},
},
'd:status': 'HTTP/1.1 404 Not Found',
}],
};
if (includeVCalendar) {
parsedCalendarEvent['d:propstat'][0]['d:prop']['cal:calendar-data'] = escapeXml(
formatCalendarEventsToVCalendar([calendarEvent], calendar!),
);
}
const responseBody: ResponseBody = {
'd:multistatus': {
'd:response': [parsedCalendarEvent],
},
'd:multistatus_attributes': {
'xmlns:d': 'DAV:',
'xmlns:s': 'http://sabredav.org/ns',
'xmlns:cal': 'urn:ietf:params:xml:ns:caldav',
'xmlns:oc': 'http://owncloud.org/ns',
'xmlns:nc': 'http://nextcloud.org/ns',
'xmlns:cs': 'http://calendarserver.org/ns/',
},
};
const response = new Response(convertObjectToDavXml(responseBody, true), {
headers: {
'content-type': 'application/xml; charset=utf-8',
'dav': DAV_RESPONSE_HEADER,
},
status: 207,
});
if (context.state.session) {
return response;
}
return createSessionCookie(request, context.state.user, response, true);
};

View File

@@ -18,6 +18,9 @@ interface DavResponse {
'card:addressbook-home-set'?: {
'd:href': string;
};
'cal:calendar-home-set'?: {
'd:href': string;
};
'd:current-user-principal'?: {
'd:href': string;
};
@@ -109,6 +112,12 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
};
}
if (requestBody.includes('calendar-home-set')) {
propResponse['d:propstat']['d:prop']['cal:calendar-home-set'] = {
'd:href': `/dav/calendars/`,
};
}
responseBody['d:multistatus']['d:response'].push(propResponse);
}