UNPKG

caldav-adapter

Version:

CalDAV server for Node.js and Koa. Modernized and maintained for Forward Email.

255 lines (226 loc) 7.86 kB
const path = require('node:path'); const { pathToRegexp } = require('path-to-regexp'); const basicAuth = require('basic-auth'); const parseBody = require('./common/parse-body'); const winston = require('./common/winston'); const { setMultistatusResponse } = require('./common/response'); const { build, buildTag, href, multistatus, response, status } = require('./common/x-build'); const cal = require('./routes/calendar/calendar'); const pri = require('./routes/principal/principal'); const defaults = { caldavRoot: '/', calendarRoot: 'cal', principalRoot: 'p', logEnabled: false }; module.exports = function (options) { // avoid mutating shared `defaults` object options = { ...defaults, ...options }; const log = winston({ ...options, label: 'index' }); const rootRoute = path.join('/', options.caldavRoot); const calendarRoute = path.join(rootRoute, options.calendarRoot); const principalRoute = path.join(rootRoute, options.principalRoot, '/'); const rootRegexp = pathToRegexp(path.join(rootRoute, '/:params*')); const calendarRegex = { keys: [] }; calendarRegex.regexp = pathToRegexp( path.join(calendarRoute, '/:principalId/:calendarId?/:eventId*'), calendarRegex.keys ); const principalRegex = { keys: [] }; principalRegex.regexp = pathToRegexp( path.join(principalRoute, '/:principalId?'), principalRegex.keys ); // // IMPORTANT: forward the *entire* options object (not just `data`, // `logEnabled`, and `logLevel`) so that downstream tag handlers // (common/tags.js) receive `pushTopicProvider`, `pushSubscriptionURL`, // `pushEnv`, and `pushRefreshInterval`. Without this, iOS Calendar // never sees the <CS:push-transports> advertisement on the calendar // home and never POSTs to /apns to register for silent push (the // tags handler short-circuits via `typeof options.pushTopicProvider // !== 'function'`). See Apple's caldav-pubsubdiscovery.txt: // https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-pubsubdiscovery.txt // const calendarRoutes = cal(options); const principalRoutes = pri(options); const fillParameters = function (ctx) { ctx.state.params = {}; // use ctx.path instead of ctx.url to avoid query string matching let regex; if (calendarRegex.regexp.test(ctx.path)) { regex = calendarRegex; } else if (principalRegex.regexp.test(ctx.path)) { regex = principalRegex; } if (!regex) { return; } const captures = ctx.path.match(regex.regexp); for (let i = 0; i < regex.keys.length; i++) { let captured = captures[i + 1]; if (typeof captured === 'string') { captured = decodeURIComponent(captured); } ctx.state.params[regex.keys[i].name] = captured; // // NOTE: We no longer strip .ics extension here. // The caldav-server.js now handles flexible lookup for both // eventId with and without .ics extension for backwards compatibility. // This preserves the original eventId as sent by the client. // } }; const auth = async function (ctx) { const creds = basicAuth(ctx); if (!creds) { ctx.status = 401; ctx.response.set( 'WWW-Authenticate', `Basic realm="${options.authRealm}"` ); return false; } ctx.state.user = await options.authenticate(ctx, { username: creds.name, password: creds.pass, principalId: ctx.state.params.principalId }); if (!ctx.state.user) { ctx.status = 401; ctx.response.set( 'WWW-Authenticate', `Basic realm="${options.authRealm}"` ); return false; } if (!ctx.state.params.principalId) { ctx.state.params.principalId = ctx.state.user.principalId; } return true; }; const fillRoutes = function (ctx) { ctx.state.principalRootUrl = principalRoute; if (ctx.state.params.principalId) { // Encode @ as %40 in principalId for URL construction. // iOS/macOS URL parser treats bare @ in path segments as a userinfo // separator (RFC 3986 §3.2.1), which corrupts the request URL and // causes events to silently not appear in Apple Calendar. // All URLs returned by the server (calendar-home-set, principal-URL, // schedule-inbox-URL, event hrefs, etc.) must use %40 so that Apple // clients send subsequent requests with the encoded form. const encodedPrincipalId = ctx.state.params.principalId.replaceAll( '@', '%40' ); ctx.state.calendarHomeUrl = path.join( calendarRoute, encodedPrincipalId, '/' ); ctx.state.principalUrl = path.join( principalRoute, encodedPrincipalId, '/' ); if (ctx.state.params.calendarId) { ctx.state.calendarUrl = path.join( calendarRoute, encodedPrincipalId, ctx.state.params.calendarId, '/' ); } } }; // // RFC 4918 Section 9.1 / RFC 6764 Section 5: // When a PROPFIND hits the root (caldavRoot), return a 207 multistatus // with current-user-principal so clients can discover the principal URL // without following redirects. This is the standard CalDAV discovery // flow: clients PROPFIND the root, read current-user-principal, then // PROPFIND the principal URL for calendar-home-set. // // Previously the adapter returned a 302 redirect to /principals/, // which some clients (notably iOS/macOS Calendar) do not follow // correctly for PROPFIND requests. // const handleRootPropfind = function (ctx) { const dav = 'DAV:'; const calNs = 'urn:ietf:params:xml:ns:caldav'; const props = [ { [buildTag(dav, 'current-user-principal')]: href(ctx.state.principalUrl) }, { [buildTag(dav, 'resourcetype')]: { [buildTag(dav, 'collection')]: '' } } ]; // If the client also asked for calendar-home-set or principal-URL, // include them so the client can skip the principal PROPFIND entirely if (ctx.state.calendarHomeUrl) { props.push({ [buildTag(calNs, 'calendar-home-set')]: href(ctx.state.calendarHomeUrl) }); } const resps = response(ctx.url, status[200], props); const ms = multistatus([resps]); setMultistatusResponse(ctx); ctx.body = build(ms); }; return async function (ctx, next) { // use 301 permanent redirect per RFC 6764 Section 5 if ( ctx.path.toLowerCase() === '/.well-known/caldav' && !options.disableWellKnown ) { ctx.status = 301; ctx.redirect(rootRoute); return; } // use ctx.path instead of ctx.url if (!rootRegexp.test(ctx.path)) { await next(); return; } ctx.state.caldav = true; fillParameters(ctx); const authed = await auth(ctx); if (!authed) { return; } fillRoutes(ctx); await parseBody(ctx); log.verbose('REQUEST BODY', ctx?.request?.body || '<empty>'); // use ctx.path instead of ctx.url if (calendarRegex.regexp.test(ctx.path)) { await calendarRoutes(ctx); } else if (principalRegex.regexp.test(ctx.path)) { await principalRoutes(ctx); } else if (ctx.method.toLowerCase() === 'propfind') { // // Handle PROPFIND at the root URL (caldavRoot). // Return 207 with current-user-principal so CalDAV clients // can discover the principal URL without following redirects. // handleRootPropfind(ctx); } else { // // For non-PROPFIND methods at the root (e.g. OPTIONS handled // upstream, or unexpected methods), redirect to the principal URL. // ctx.redirect(principalRoute); return; } log.verbose('RESPONSE BODY', ctx.body || '<empty>'); }; };