caldav-adapter
Version:
CalDAV server for Node.js and Koa. Modernized and maintained for Forward Email.
698 lines (668 loc) • 23.5 kB
JavaScript
const { isEmail } = require('validator');
const { buildTag, href, response, status } = require('./x-build');
const winston = require('./winston');
const dav = 'DAV:';
const cal = 'urn:ietf:params:xml:ns:caldav';
const cs = 'http://calendarserver.org/ns/';
const ical = 'http://apple.com/ns/ical/';
/**
* Encode special characters for XML content to prevent parsing errors
* @param {string} str - String to encode
* @returns {string} - XML-safe encoded string
*/
function encodeXMLEntities(str) {
if (typeof str !== 'string') {
return str;
}
return str
.replaceAll('&', '&') // Must be first to avoid double-encoding
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
module.exports = function (options) {
const log = winston({ ...options, label: 'tags' });
const tags = {
[dav]: {
'current-user-principal': {
doc: 'https://tools.ietf.org/html/rfc5397#section-3',
async resp({ ctx }) {
return {
[buildTag(dav, 'current-user-principal')]: href(
ctx.state.principalUrl
)
};
}
},
'current-user-privilege-set': {
doc: 'https://tools.ietf.org/html/rfc3744#section-5.4',
async resp({ resource, calendar }) {
if (resource === 'calendar') {
const privileges = [{ [buildTag(dav, 'read')]: '' }];
if (!calendar.readonly) {
privileges.push(
{ [buildTag(dav, 'read')]: '' },
{ [buildTag(dav, 'read-acl')]: '' },
{ [buildTag(dav, 'read-current-user-privilege-set')]: '' },
{ [buildTag(dav, 'write')]: '' },
{ [buildTag(dav, 'write-content')]: '' },
{ [buildTag(dav, 'write-properties')]: '' },
{ [buildTag(dav, 'bind')]: '' }, // PUT - https://tools.ietf.org/html/rfc3744#section-3.9
{ [buildTag(dav, 'unbind')]: '' }, // DELETE - https://tools.ietf.org/html/rfc3744#section-3.10
{ [buildTag(cal, 'read-free-busy')]: '' } // https://tools.ietf.org/html/rfc4791#section-6.1.1
);
}
return {
[buildTag(dav, 'current-user-privilege-set')]: {
[buildTag(dav, 'privilege')]: privileges
}
};
}
}
},
displayname: {
doc: 'https://tools.ietf.org/html/rfc4918#section-15.2',
async resp({ resource, ctx, calendar }) {
if (resource === 'principal') {
return {
[buildTag(dav, 'displayname')]: ctx.state.user.principalName
};
}
if (resource === 'calendar')
return {
[buildTag(dav, 'displayname')]: encodeXMLEntities(calendar.name)
};
if (resource === 'calendarProppatch')
return response(ctx.url, status[200], [
{
[buildTag(dav, 'displayname')]: encodeXMLEntities(calendar.name)
}
]);
}
},
getcontenttype: {
doc: 'https://tools.ietf.org/html/rfc2518#section-13.5',
async resp({ resource, event }) {
if (resource === 'calendar') {
return {
[buildTag(dav, 'getcontenttype')]: 'text/calendar; charset=utf-8'
};
}
if (resource === 'event') {
const componentType = event?.componentType || 'VEVENT';
return {
[buildTag(dav, 'getcontenttype')]:
`text/calendar; charset=utf-8; component=${componentType}`
};
}
}
},
getetag: {
doc: 'https://tools.ietf.org/html/rfc4791#section-5.3.4',
async resp({ resource, ctx, event }) {
if (resource === 'event') {
return {
[buildTag(dav, 'getetag')]: options.data.getETag(ctx, event)
};
}
}
},
owner: {
doc: 'https://tools.ietf.org/html/rfc3744#section-5.1',
async resp({ resource, ctx }) {
if (resource === 'calendar') {
return {
[buildTag(dav, 'owner')]: href(ctx.state.principalUrl)
};
}
}
},
'principal-collection-set': {
doc: 'https://tools.ietf.org/html/rfc3744#section-5.8',
async resp({ resource, ctx }) {
if (resource === 'principal') {
return {
[buildTag(dav, 'principal-collection-set')]: href(
ctx.state.principalRootUrl
)
};
}
}
},
'principal-URL': {
doc: 'https://tools.ietf.org/html/rfc3744#section-4.2',
async resp({ ctx }) {
return {
[buildTag(dav, 'principal-URL')]: href(ctx.state.principalUrl)
};
}
},
'resource-id': {
doc: 'https://tools.ietf.org/html/rfc5842#section-3.1'
},
resourcetype: {
doc: 'https://tools.ietf.org/html/rfc4791#section-4.2',
async resp({ resource }) {
if (resource === 'calCollection') {
return {
[buildTag(dav, 'resourcetype')]: {
[buildTag(dav, 'collection')]: ''
}
};
}
if (resource === 'calendar') {
return {
[buildTag(dav, 'resourcetype')]: {
[buildTag(dav, 'collection')]: '',
[buildTag(cal, 'calendar')]: ''
}
};
}
if (resource === 'principal') {
return {
[buildTag(dav, 'resourcetype')]: {
[buildTag(dav, 'principal')]: ''
}
};
}
}
},
'supported-report-set': {
doc: 'https://tools.ietf.org/html/rfc3253#section-3.1.5',
async resp({ resource }) {
if (resource === 'calCollection') {
//
// The calendar home collection itself does not support
// sync-collection or calendar-query — those are per-calendar.
// Return an empty supported-report-set to avoid confusing
// clients that check the home's capabilities.
//
return {
[buildTag(dav, 'supported-report-set')]: ''
};
}
if (resource === 'calendar') {
return {
[buildTag(dav, 'supported-report-set')]: {
[buildTag(dav, 'supported-report')]: [
{
[buildTag(dav, 'report')]: {
[buildTag(cal, 'calendar-query')]: ''
}
},
{
[buildTag(dav, 'report')]: {
[buildTag(cal, 'calendar-multiget')]: ''
}
},
{
[buildTag(dav, 'report')]: {
[buildTag(cal, 'sync-collection')]: ''
}
}
]
}
};
}
}
},
'sync-token': {
doc: 'https://tools.ietf.org/html/rfc6578#section-3',
async resp({ resource, calendar }) {
if (resource === 'calendar') {
return {
[buildTag(dav, 'sync-token')]: calendar.synctoken
};
}
}
}
},
[cal]: {
'calendar-data': {
doc: 'https://tools.ietf.org/html/rfc4791#section-9.6',
async resp({ event, ctx, calendar }) {
const ics = await options.data.buildICS(ctx, event, calendar);
// Use entity encoding instead of CDATA for better API compatibility
// This ensures special characters are properly encoded while maintaining
// compatibility with tsdav and other CalDAV clients that expect string content
return {
[buildTag(cal, 'calendar-data')]: encodeXMLEntities(ics)
};
}
},
'calendar-home-set': {
doc: 'https://tools.ietf.org/html/rfc4791#section-6.2.1',
async resp({ resource, ctx }) {
if (resource === 'principal') {
return {
[buildTag(cal, 'calendar-home-set')]: href(
ctx.state.calendarHomeUrl
)
};
}
}
},
'calendar-description': {
doc: 'https://tools.ietf.org/html/rfc4791#section-5.2.1',
async resp({ resource, ctx, calendar }) {
if (resource === 'calendar')
return {
[buildTag(cal, 'calendar-description')]: encodeXMLEntities(
calendar.description
)
};
if (resource === 'calendarProppatch')
return response(ctx.url, status[200], [
{
[buildTag(cal, 'calendar-description')]: encodeXMLEntities(
calendar.description
)
}
]);
}
},
'calendar-timezone': {
doc: 'https://tools.ietf.org/html/rfc4791#section-5.2.2',
async resp({ resource, ctx, calendar }) {
if (resource === 'calendar')
return {
[buildTag(cal, 'calendar-timezone')]: encodeXMLEntities(
calendar.timezone
)
};
if (resource === 'calendarProppatch')
return response(ctx.url, status[200], [
{
[buildTag(cal, 'calendar-timezone')]: encodeXMLEntities(
calendar.timezone
)
}
]);
}
},
'calendar-user-address-set': {
doc: 'https://tools.ietf.org/html/rfc6638#section-2.4.1',
async resp({ ctx }) {
if (isEmail(ctx.state.user.principalName))
return {
[buildTag(cal, 'calendar-user-address-set')]: href(
`mailto:${ctx.state.user.principalName}`
)
};
if (isEmail(ctx.state.user.email))
return {
[buildTag(cal, 'calendar-user-address-set')]: href(
`mailto:${ctx.state.user.email}`
)
};
return {
[buildTag(cal, 'calendar-user-address-set')]: ''
};
}
},
'default-alarm-vevent-date': {
doc: 'https://tools.ietf.org/id/draft-daboo-valarm-extensions-01.html#rfc.section.9',
async resp({ resource, ctx }) {
if (resource === 'calCollectionProppatch') {
return response(ctx.url, status[403], [
{
[buildTag(cal, 'default-alarm-vevent-date')]: ''
}
]);
}
}
},
'default-alarm-vevent-datetime': {
doc: 'https://tools.ietf.org/id/draft-daboo-valarm-extensions-01.html#rfc.section.9',
async resp({ resource, ctx }) {
if (resource === 'calCollectionProppatch') {
return response(ctx.url, status[403], [
{
[buildTag(cal, 'default-alarm-vevent-datetime')]: ''
}
]);
}
}
},
'schedule-inbox-URL': {
doc: 'https://tools.ietf.org/html/rfc6638#section-2.2',
async resp({ ctx }) {
if (ctx && ctx.state && ctx.state.calendarHomeUrl) {
return {
[buildTag(cal, 'schedule-inbox-URL')]: href(
ctx.state.calendarHomeUrl.replace(/\/$/, '') + '/inbox/'
)
};
}
return {
[buildTag(cal, 'schedule-inbox-URL')]: href('')
};
}
},
'schedule-outbox-URL': {
doc: 'https://tools.ietf.org/html/rfc6638#section-2.1',
async resp({ ctx }) {
if (ctx && ctx.state && ctx.state.calendarHomeUrl) {
return {
[buildTag(cal, 'schedule-outbox-URL')]: href(
ctx.state.calendarHomeUrl.replace(/\/$/, '') + '/outbox/'
)
};
}
return {
[buildTag(cal, 'schedule-outbox-URL')]: href('')
};
}
},
'supported-calendar-component-set': {
doc: 'https://tools.ietf.org/html/rfc4791#section-5.2.3',
async resp({ resource, calendar }) {
if (resource === 'calendar') {
//
// RFC 4791 Section 5.2.3: report only the component types
// that this calendar actually supports. The calendar model
// exposes `has_vevent` and `has_vtodo` booleans; when both
// are absent/undefined we fall back to advertising both
// types for backward compatibility.
//
const hasVevent = calendar?.has_vevent !== false;
const hasVtodo = calendar?.has_vtodo !== false;
const comps = [];
if (hasVevent) comps.push({ '@name': 'VEVENT' });
if (hasVtodo) comps.push({ '@name': 'VTODO' });
// Safety: if somehow both are false, advertise both
if (comps.length === 0) {
comps.push({ '@name': 'VEVENT' }, { '@name': 'VTODO' });
}
return {
[buildTag(cal, 'supported-calendar-component-set')]: {
[buildTag(cal, 'comp')]: comps
}
};
}
}
},
'schedule-default-calendar-URL': {
doc: 'https://tools.ietf.org/html/rfc6638#section-9.2',
async resp({ ctx }) {
if (ctx && ctx.state && ctx.state.calendarUrl) {
return {
[buildTag(cal, 'schedule-default-calendar-URL')]: href(
ctx.state.calendarUrl
)
};
}
return {
[buildTag(cal, 'schedule-default-calendar-URL')]: href('')
};
}
},
'schedule-calendar-transp': {
doc: 'https://tools.ietf.org/html/rfc6638#section-9.1',
async resp({ resource, calendar }) {
if (resource === 'calendar') {
// Default to opaque (events affect free-busy)
const transp = calendar?.scheduleTransp || 'opaque';
return {
[buildTag(cal, 'schedule-calendar-transp')]: {
[buildTag(cal, transp)]: ''
}
};
}
}
},
'schedule-tag': {
doc: 'https://tools.ietf.org/html/rfc6638#section-3.2.10',
async resp({ resource, event }) {
if (resource === 'event' && event?.scheduleTag) {
return {
[buildTag(cal, 'schedule-tag')]: event.scheduleTag
};
}
}
},
/* RFC 8607 Managed Attachments */
'max-attachment-size': {
doc: 'https://www.rfc-editor.org/rfc/rfc8607.html#section-5.1',
async resp({ resource }) {
if (resource === 'calendar') {
return {
[buildTag(cal, 'max-attachment-size')]: '10485760'
};
}
}
},
'max-attachments-per-resource': {
doc: 'https://www.rfc-editor.org/rfc/rfc8607.html#section-5.2',
async resp({ resource }) {
if (resource === 'calendar') {
return {
[buildTag(cal, 'max-attachments-per-resource')]: '10'
};
}
}
},
'managed-attachments-server-URL': {
doc: 'https://www.rfc-editor.org/rfc/rfc8607.html#section-5.3',
async resp({ resource }) {
if (resource === 'calendar') {
return {
[buildTag(cal, 'managed-attachments-server-URL')]: ''
};
}
}
}
},
[cs]: {
'allowed-sharing-modes': {
doc: 'https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-sharing.txt',
async resp({ resource }) {
if (resource === 'calendar') {
return {
[buildTag(cs, 'allowed-sharing-modes')]: ''
};
}
}
},
'checksum-versions': {},
'dropbox-home-URL': {},
'email-address-set': {
async resp({ resource, ctx }) {
if (
resource === 'calendar' &&
(ctx?.state?.user?.email || ctx?.state?.user?.username)
) {
return {
[buildTag(cs, 'email-address-set')]:
ctx?.state?.user?.email || ctx?.state?.user?.username
};
}
}
},
getctag: {
// DEPRECATED
doc: 'https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-ctag.txt',
async resp({ resource, calendar }) {
if (resource === 'calendar') {
return {
[buildTag(cs, 'getctag')]: calendar.synctoken
};
}
}
},
'notification-URL': {
doc: 'https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-notifications.txt'
},
'push-transports': {
// https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-pubsubdiscovery.txt
doc: 'https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-pubsubdiscovery.txt',
async resp({ resource, calendar, ctx }) {
//
// Apple's caldav-pubsubdiscovery.txt places <CS:push-transports>
// on the principal AND on the calendar-home (see the spec's
// examples which target the calendar-home-set, and the Cyrus
// implementation in imap/http_caldav.c which advertises it on
// the home). In this adapter, the calendar-home resource is
// named `calCollection` (see routes/calendar/user/propfind.js),
// the per-collection resource is `calendar`, and the principal
// resource is `principal`.
//
// Previously this guard accepted only `principal` and `calendar`,
// which meant iOS Calendar (which queries push-transports on the
// calendar-home, NOT on individual collections) never saw the
// advertisement and consequently never POST'd to the subscription
// URL to register for push notifications. We now also accept
// `calCollection`.
//
if (
resource !== 'principal' &&
resource !== 'calendar' &&
resource !== 'calCollection'
)
return;
if (typeof options.pushTopicProvider !== 'function') return;
let topic;
try {
topic = await options.pushTopicProvider({
resource,
calendar,
ctx
});
} catch (err) {
log.warn('pushTopicProvider threw', err);
return;
}
if (!topic) return;
const subscriptionURL =
(typeof options.pushSubscriptionURL === 'string' &&
options.pushSubscriptionURL) ||
'/apns';
const pushEnv =
(typeof options.pushEnv === 'string' && options.pushEnv) ||
'PRODUCTION';
const refreshInterval =
(typeof options.pushRefreshInterval === 'string' &&
options.pushRefreshInterval) ||
'3600';
return {
[buildTag(cs, 'push-transports')]: {
[buildTag(cs, 'transport')]: {
'@type': 'APSD',
[buildTag(cs, 'subscription-url')]: {
[buildTag(dav, 'href')]: subscriptionURL
},
[buildTag(cs, 'apsbundleid')]: topic,
[buildTag(cs, 'env')]: pushEnv,
[buildTag(cs, 'refresh-interval')]: refreshInterval
}
}
};
}
},
pushkey: {
// https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-pubsubdiscovery.txt
doc: 'https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-pubsubdiscovery.txt',
async resp({ resource, calendar, ctx }) {
if (typeof options.pushTopicProvider !== 'function') return;
//
// Per Apple's caldav-pubsubdiscovery.txt the calendar-home itself
// also exposes a <CS:pushkey> covering home-level changes
// (calendar add/delete, principal property changes). iOS
// subscribes to that key so it can react to home-level mutations
// without having to subscribe to every individual calendar.
//
// We use the calendar identifier as the per-collection key and
// the principal id as the home-level key. These opaque values
// are echoed back to us by iOS in the /apns POST/GET, allowing
// us to map (device_token, key) -> (user, calendar|home).
//
let key;
if (resource === 'calendar' && calendar) {
key =
calendar.calendarId ||
(calendar._id &&
calendar._id.toString &&
calendar._id.toString()) ||
'';
} else if (resource === 'calCollection') {
key =
(ctx &&
ctx.state &&
ctx.state.params &&
ctx.state.params.principalId) ||
'';
} else {
return;
}
if (!key) return;
return {
[buildTag(cs, 'pushkey')]: key
};
}
}
},
[ical]: {
'calendar-color': {
async resp({ resource, ctx, calendar }) {
if (resource === 'calendar')
return {
[buildTag(ical, 'calendar-color')]: encodeXMLEntities(
calendar.color
)
};
if (resource === 'calendarProppatch')
return response(ctx.url, status[200], [
{
[buildTag(ical, 'calendar-color')]: encodeXMLEntities(
calendar.color
)
}
]);
}
},
'calendar-order': {
async resp({ resource, ctx, calendar }) {
if (resource === 'calendar')
return {
[buildTag(ical, 'calendar-order')]: calendar.order
};
if (resource === 'calendarProppatch')
return response(ctx.url, status[200], [
{
[buildTag(ical, 'calendar-order')]: calendar.order
}
]);
}
}
}
};
const getResponse = async ({ resource, child, ctx, calendar, event }) => {
if (!child.namespaceURI) {
return null;
}
if (!tags[child.namespaceURI]) {
log.debug(`Namespace miss: ${child.namespaceURI}`);
return null;
}
const tagAction = tags[child.namespaceURI][child.localName];
if (!tagAction) {
log.debug(`Tag miss: ${buildTag(child.namespaceURI, child.localName)}`);
return null;
}
if (!tagAction.resp) {
log.debug(
`Tag no response: ${buildTag(child.namespaceURI, child.localName)}`
);
return null;
}
return tagAction.resp({
resource,
ctx,
calendar,
event,
text: child.textContent
});
};
return { tags, getResponse };
};