caldav-adapter 9.2.0 → 9.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared XML entity encoding utility
3
+ * Centralizes the XML encoding logic to avoid duplication across handlers.
4
+ */
5
+
6
+ /**
7
+ * Encode special characters for XML content to prevent parsing errors
8
+ * @param {string} str - String to encode
9
+ * @returns {string} - XML-safe encoded string
10
+ */
11
+ function encodeXMLEntities(str) {
12
+ if (typeof str !== 'string') {
13
+ return str;
14
+ }
15
+
16
+ return str
17
+ .replaceAll('&', '&') // Must be first to avoid double-encoding
18
+ .replaceAll('<', '&lt;')
19
+ .replaceAll('>', '&gt;')
20
+ .replaceAll('"', '&quot;')
21
+ .replaceAll("'", '&#39;');
22
+ }
23
+
24
+ module.exports = { encodeXMLEntities };
package/index.js CHANGED
@@ -14,7 +14,8 @@ const defaults = {
14
14
  };
15
15
 
16
16
  module.exports = function (options) {
17
- options = Object.assign(defaults, options);
17
+ // avoid mutating shared `defaults` object
18
+ options = { ...defaults, ...options };
18
19
 
19
20
  const log = winston({ ...options, label: 'index' });
20
21
 
@@ -49,10 +50,11 @@ module.exports = function (options) {
49
50
  const fillParameters = function (ctx) {
50
51
  ctx.state.params = {};
51
52
 
53
+ // use ctx.path instead of ctx.url to avoid query string matching
52
54
  let regex;
53
- if (calendarRegex.regexp.test(ctx.url)) {
55
+ if (calendarRegex.regexp.test(ctx.path)) {
54
56
  regex = calendarRegex;
55
- } else if (principalRegex.regexp.test(ctx.url)) {
57
+ } else if (principalRegex.regexp.test(ctx.path)) {
56
58
  regex = principalRegex;
57
59
  }
58
60
 
@@ -60,7 +62,7 @@ module.exports = function (options) {
60
62
  return;
61
63
  }
62
64
 
63
- const captures = ctx.url.match(regex.regexp);
65
+ const captures = ctx.path.match(regex.regexp);
64
66
  for (let i = 0; i < regex.keys.length; i++) {
65
67
  let captured = captures[i + 1];
66
68
  if (typeof captured === 'string') {
@@ -134,13 +136,18 @@ module.exports = function (options) {
134
136
  };
135
137
 
136
138
  return async function (ctx, next) {
139
+ // use 301 permanent redirect per RFC 6764 Section 5
137
140
  if (
138
- ctx.url.toLowerCase() === '/.well-known/caldav' &&
141
+ ctx.path.toLowerCase() === '/.well-known/caldav' &&
139
142
  !options.disableWellKnown
140
- )
141
- return ctx.redirect(rootRoute); // TODO: should be 302?
143
+ ) {
144
+ ctx.status = 301;
145
+ ctx.redirect(rootRoute);
146
+ return;
147
+ }
142
148
 
143
- if (!rootRegexp.test(ctx.url)) {
149
+ // use ctx.path instead of ctx.url
150
+ if (!rootRegexp.test(ctx.path)) {
144
151
  await next();
145
152
  return;
146
153
  }
@@ -157,9 +164,10 @@ module.exports = function (options) {
157
164
  await parseBody(ctx);
158
165
  log.verbose('REQUEST BODY', ctx?.request?.body || '<empty>');
159
166
 
160
- if (calendarRegex.regexp.test(ctx.url)) {
167
+ // use ctx.path instead of ctx.url
168
+ if (calendarRegex.regexp.test(ctx.path)) {
161
169
  await calendarRoutes(ctx);
162
- } else if (principalRegex.regexp.test(ctx.url)) {
170
+ } else if (principalRegex.regexp.test(ctx.path)) {
163
171
  await principalRoutes(ctx);
164
172
  } else {
165
173
  ctx.redirect(principalRoute);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "caldav-adapter",
3
3
  "description": "CalDAV server for Node.js and Koa. Modernized and maintained for Forward Email.",
4
- "version": "9.2.0",
4
+ "version": "9.3.0",
5
5
  "author": "Sanders DeNardi and Forward Email LLC",
6
6
  "contributors": [
7
7
  "Sanders DeNardi <sedenardi@gmail.com> (http://www.sandersdenardi.com/)",
@@ -41,6 +41,11 @@
41
41
  "engines": {
42
42
  "node": ">=18"
43
43
  },
44
+ "files": [
45
+ "index.js",
46
+ "common",
47
+ "routes"
48
+ ],
44
49
  "homepage": "https://github.com/forwardemail/caldav-adapter",
45
50
  "keywords": [
46
51
  "caldav",
@@ -63,7 +68,7 @@
63
68
  },
64
69
  "scripts": {
65
70
  "lint": "xo --fix && remark . -qfo && fixpack",
66
- "prepare": "husky install",
71
+ "prepare": "husky install > /dev/null 2>&1 || true",
67
72
  "pretest": "npm run lint",
68
73
  "test": "npm run test-coverage",
69
74
  "test-coverage": "cross-env NODE_ENV=test nyc ava"
@@ -36,7 +36,11 @@ module.exports = function (options) {
36
36
  }
37
37
 
38
38
  const hrefParts = href.split('/');
39
- const eventId = hrefParts.at(-1).slice(0, -4);
39
+ const lastPart = hrefParts.at(-1);
40
+ // Only strip .ics extension if present, to avoid corrupting eventIds
41
+ const eventId = lastPart.endsWith('.ics')
42
+ ? lastPart.slice(0, -4)
43
+ : lastPart;
40
44
  const event = await options.data.getEvent(ctx, {
41
45
  eventId,
42
46
  principalId: ctx.state.params.principalId,
@@ -33,7 +33,7 @@ module.exports = function (options) {
33
33
  // (e.g. since we call `setMultistatusResponse` before exec())
34
34
  ctx.set('Content-Type', 'text/html; charset="utf-8"');
35
35
  ctx.status = 204; // no content
36
- ctx.body = '';
36
+ ctx.body = null;
37
37
  };
38
38
 
39
39
  return {
@@ -46,6 +46,17 @@ module.exports = function (options) {
46
46
  const resp = await calendarResponse(ctx, calendar);
47
47
  const resps = [resp];
48
48
 
49
+ //
50
+ // Performance: check Depth header — if Depth:0 the client only
51
+ // wants calendar-level properties, not the event listing.
52
+ // This avoids loading any events from the database.
53
+ //
54
+ const depth = ctx.get('depth') || 'infinity';
55
+ if (depth === '0') {
56
+ const ms = multistatus(resps);
57
+ return build(ms);
58
+ }
59
+
49
60
  const { children } = xml.getWithChildren(
50
61
  '/D:propfind/D:prop',
51
62
  ctx.request.xml
@@ -69,6 +69,23 @@ module.exports = function (options) {
69
69
  return;
70
70
  }
71
71
 
72
+ //
73
+ // RFC 7232 Section 3.1: If-Match
74
+ // Validate ETag to prevent silent concurrent overwrites.
75
+ //
76
+ const ifMatch = ctx.get('if-match');
77
+ if (ifMatch && ifMatch !== '*') {
78
+ const currentETag = options.data.getETag(ctx, existing);
79
+ const clientETag = ifMatch.replace(/^"/, '').replace(/"$/, '');
80
+ const serverETag = currentETag.replace(/^"/, '').replace(/"$/, '');
81
+ if (clientETag !== serverETag) {
82
+ log.warn('if-match ETag mismatch, precondition failed');
83
+ ctx.status = 412;
84
+ ctx.body = preconditionFail(ctx.url, 'if-match');
85
+ return;
86
+ }
87
+ }
88
+
72
89
  const updateObject = await options.data.updateEvent(ctx, {
73
90
  eventId: ctx.state.params.eventId,
74
91
  principalId: ctx.state.params.principalId,
@@ -78,7 +95,7 @@ module.exports = function (options) {
78
95
  log.debug('event updated');
79
96
 
80
97
  /* https://tools.ietf.org/html/rfc4791#section-5.3.2 */
81
- ctx.status = 201;
98
+ ctx.status = 204;
82
99
  ctx.set('ETag', options.data.getETag(ctx, updateObject));
83
100
  } else {
84
101
  const newObject = await options.data.createEvent(ctx, {
@@ -91,6 +108,7 @@ module.exports = function (options) {
91
108
  /* https://tools.ietf.org/html/rfc4791#section-5.3.2 */
92
109
  ctx.status = 201;
93
110
  ctx.set('ETag', options.data.getETag(ctx, newObject));
111
+ ctx.set('Location', ctx.url);
94
112
  }
95
113
  };
96
114
 
@@ -40,7 +40,10 @@ module.exports = function (options) {
40
40
  const rootAction = rootActions[rootTag];
41
41
  log.debug(`report ${rootAction ? 'hit' : 'miss'}: ${rootTag}`);
42
42
  if (!rootAction) {
43
- return notFound(ctx.url);
43
+ // RFC 3253 Section 3.6: unsupported report type should return 403
44
+ ctx.status = 403;
45
+ ctx.body = notFound(ctx.url);
46
+ return;
44
47
  }
45
48
 
46
49
  const { responses, other } = await rootAction(ctx, calendar);
@@ -12,6 +12,20 @@ module.exports = function (options) {
12
12
  };
13
13
 
14
14
  return async function (ctx, calendar) {
15
+ // RFC 6578 Section 3.2 - parse the client's sync-token
16
+ // to enable incremental sync instead of always returning all events
17
+ const syncTokenNodes = xml.get(
18
+ '/D:sync-collection/D:sync-token',
19
+ ctx.request.xml
20
+ );
21
+ let clientSyncToken = null;
22
+ if (syncTokenNodes && syncTokenNodes.length > 0) {
23
+ const tokenText = syncTokenNodes[0].textContent;
24
+ if (tokenText && tokenText.trim() !== '') {
25
+ clientSyncToken = tokenText.trim();
26
+ }
27
+ }
28
+
15
29
  const { children } = xml.getWithChildren(
16
30
  '/D:sync-collection/D:prop',
17
31
  ctx.request.xml
@@ -20,12 +34,15 @@ module.exports = function (options) {
20
34
  return child.localName === 'calendar-data';
21
35
  });
22
36
 
37
+ // Pass the client's sync-token to the data layer so it can
38
+ // return only events changed since that token
23
39
  const events = await options.data.getEventsForCalendar(ctx, {
24
40
  principalId: ctx.state.params.principalId,
25
41
  calendarId: options.data.getCalendarId(ctx, calendar),
26
42
  user: ctx.state.user,
27
43
  fullData,
28
- showDeleted: true
44
+ showDeleted: true,
45
+ syncToken: clientSyncToken
29
46
  });
30
47
  const { responses } = await eventResponse(ctx, events, calendar, children);
31
48
 
@@ -39,10 +39,13 @@ module.exports = function (options) {
39
39
  const method = ctx.method.toLowerCase();
40
40
  const { calendarId } = ctx.state.params;
41
41
 
42
- // Check for scheduling inbox/outbox routes
43
- // These are special endpoints at /cal/:principalId/inbox/ and /cal/:principalId/outbox/
44
- const urlLower = ctx.url.toLowerCase();
45
- if (urlLower.includes('/inbox') || urlLower.includes('/outbox')) {
42
+ // use exact calendarId match instead of URL substring
43
+ // to prevent routing collisions with calendars named "inbox" or "outbox"
44
+ if (
45
+ calendarId &&
46
+ (calendarId.toLowerCase() === 'inbox' ||
47
+ calendarId.toLowerCase() === 'outbox')
48
+ ) {
46
49
  log.debug('Routing to scheduling handler', { url: ctx.url, method });
47
50
  return scheduling.route(ctx);
48
51
  }
@@ -80,9 +83,13 @@ module.exports = function (options) {
80
83
  try {
81
84
  if (typeof calMethods[method].exec === 'function') {
82
85
  setMultistatusResponse(ctx);
86
+ // pass calendar object via ctx.state to avoid
87
+ // redundant getCalendar() calls inside handlers
88
+ ctx.state.calendar = calendar;
83
89
  ctx.body = await calMethods[method].exec(ctx, calendar);
84
90
  } else if (typeof calMethods[method] === 'function') {
85
91
  setMultistatusResponse(ctx);
92
+ ctx.state.calendar = calendar;
86
93
  ctx.body = await calMethods[method](ctx, calendar);
87
94
  } else {
88
95
  log.warn(`method handler not found: ${method}`);
@@ -152,13 +152,17 @@ module.exports = function (options) {
152
152
  async function handleItipRequest(ctx, body) {
153
153
  log.debug('Processing iTIP request');
154
154
 
155
+ // RFC 5545 Section 3.1: unfold long content lines before parsing
156
+ // Folded lines start with CRLF followed by a single whitespace character
157
+ const unfolded = body.replaceAll(/\r\n[ \t]/g, '');
158
+
155
159
  // Extract METHOD from the iCalendar data
156
- const methodMatch = body.match(/method:([a-z]+)/i);
160
+ const methodMatch = unfolded.match(/method:([a-z]+)/i);
157
161
  const method = methodMatch ? methodMatch[1].toUpperCase() : 'REQUEST';
158
162
 
159
163
  // Extract attendees
160
164
  const attendeeMatches =
161
- body.match(/attendee[^:]*:mailto:([^\r\n]+)/gi) || [];
165
+ unfolded.match(/attendee[^:]*:mailto:([^\r\n]+)/gi) || [];
162
166
  const attendees = attendeeMatches
163
167
  .map((match) => {
164
168
  const email = match.match(/mailto:([^\r\n]+)/i);
@@ -327,11 +331,15 @@ module.exports = function (options) {
327
331
  */
328
332
  async route(ctx) {
329
333
  const method = ctx.method.toLowerCase();
330
- const url = ctx.url.toLowerCase();
331
334
 
332
- // Determine if this is inbox or outbox
333
- const isInbox = url.includes('/inbox');
334
- const isOutbox = url.includes('/outbox');
335
+ // Use calendarId from route params for reliable inbox/outbox detection
336
+ // instead of URL substring matching which can collide with calendar names
337
+ const calId = (
338
+ (ctx.state.params && ctx.state.params.calendarId) ||
339
+ ''
340
+ ).toLowerCase();
341
+ const isInbox = calId === 'inbox';
342
+ const isOutbox = calId === 'outbox';
335
343
 
336
344
  if (method === 'options') {
337
345
  if (isOutbox) {
@@ -47,6 +47,16 @@ module.exports = function (options) {
47
47
  response(ctx.url, props.length > 0 ? status[200] : status[404], props)
48
48
  ];
49
49
 
50
+ //
51
+ // Performance: Depth:0 means the client only wants the collection
52
+ // itself, not its children (individual calendars).
53
+ //
54
+ const depth = ctx.get('depth') || 'infinity';
55
+ if (depth === '0') {
56
+ const ms = multistatus(responses);
57
+ return build(ms);
58
+ }
59
+
50
60
  const calendars = await options.data.getCalendarsForPrincipal(ctx, {
51
61
  principalId: ctx.state.params.principalId,
52
62
  user: ctx.state.user
@@ -1,57 +1,80 @@
1
- const _ = require('lodash');
2
1
  const xml = require('../../common/xml');
3
2
 
4
3
  // TODO: need to implement tests for MKCALENDAR
5
4
  // <https://github.com/sabre-io/dav/blob/da8c1f226f1c053849540a189262274ef6809d1c/tests/Sabre/CalDAV/PluginTest.php#L142-L428>
5
+
6
+ function parseSupportedComponents(node) {
7
+ const comps = [];
8
+ if (!node.childNodes) return comps;
9
+ for (const comp of node.childNodes) {
10
+ if (comp.localName !== 'comp') continue;
11
+ const name = comp.getAttribute ? comp.getAttribute('name') : null;
12
+ if (name) comps.push(name.toUpperCase());
13
+ }
14
+
15
+ return comps;
16
+ }
17
+
6
18
  module.exports = function (options) {
7
19
  return async function (ctx) {
8
- const { children } = xml.getWithChildren(
9
- '/CAL:mkcalendar/D:set/D:prop',
10
- ctx.request.xml
11
- );
12
-
13
20
  const calendar = {};
14
- for (const child of children) {
15
- if (!child.localName || !child.textContent) continue;
16
- switch (child.localName) {
17
- case 'displayname': {
18
- calendar.name = child.textContent;
19
21
 
20
- break;
21
- }
22
+ // RFC 4791 Section 5.3.1: the request body is optional
23
+ if (ctx.request.xml) {
24
+ const { children } = xml.getWithChildren(
25
+ '/CAL:mkcalendar/D:set/D:prop',
26
+ ctx.request.xml
27
+ );
22
28
 
23
- case 'calendar-description': {
24
- calendar.description = child.textContent;
29
+ for (const child of children) {
30
+ if (!child.localName || !child.textContent) continue;
31
+ switch (child.localName) {
32
+ case 'displayname': {
33
+ calendar.name = child.textContent;
25
34
 
26
- break;
27
- }
35
+ break;
36
+ }
28
37
 
29
- case 'calendar-timezone': {
30
- calendar.timezone = child.textContent;
38
+ case 'calendar-description': {
39
+ calendar.description = child.textContent;
31
40
 
32
- break;
33
- }
41
+ break;
42
+ }
34
43
 
35
- case 'calendar-color': {
36
- calendar.color = child.textContent;
44
+ case 'calendar-timezone': {
45
+ calendar.timezone = child.textContent;
37
46
 
38
- break;
39
- }
47
+ break;
48
+ }
49
+
50
+ case 'calendar-color': {
51
+ calendar.color = child.textContent;
52
+
53
+ break;
54
+ }
55
+
56
+ case 'calendar-order': {
57
+ calendar.order = Number.parseInt(child.textContent, 10);
58
+
59
+ break;
60
+ }
40
61
 
41
- case 'calendar-order': {
42
- calendar.order = Number.parseInt(child.textContent, 10);
62
+ case 'supported-calendar-component-set': {
63
+ const comps = parseSupportedComponents(child);
64
+ if (comps.length > 0) {
65
+ calendar.supportedComponents = comps;
66
+ }
43
67
 
44
- break;
68
+ break;
69
+ }
70
+ // No default
45
71
  }
46
- // No default
47
72
  }
48
73
  }
49
74
 
50
- // TODO: better error handling
51
- if (_.isEmpty(calendar)) {
52
- const err = new TypeError('Calendar update was empty');
53
- err.xml = ctx.request.body;
54
- throw err;
75
+ // Extract calendarId from URL if available
76
+ if (ctx.state.params && ctx.state.params.calendarId) {
77
+ calendar.calendarId = ctx.state.params.calendarId;
55
78
  }
56
79
 
57
80
  // TODO: we may need to implement this similar workaround
@@ -11,15 +11,32 @@ const commonTags = require('../../common/tags');
11
11
  module.exports = function (options) {
12
12
  const tags = commonTags(options);
13
13
  return async function (ctx) {
14
- // Validate XML document before processing
15
- if (!ctx.request.xml) {
16
- ctx.throw(400, 'Invalid or missing XML in PROPFIND request');
14
+ // RFC 4918 Section 9.1 says an empty PROPFIND body
15
+ // MUST be treated as an allprop request, not rejected with 400
16
+ let children = [];
17
+ if (ctx.request.xml) {
18
+ ({ children } = xml.getWithChildren(
19
+ '/D:propfind/D:prop',
20
+ ctx.request.xml
21
+ ));
17
22
  }
18
23
 
19
- const { children } = xml.getWithChildren(
20
- '/D:propfind/D:prop',
21
- ctx.request.xml
22
- );
24
+ // If no properties requested (allprop), return a default set
25
+ // Each entry must include namespaceURI so getResponse() can resolve the tag handler
26
+ const dav = 'DAV:';
27
+ const cal = 'urn:ietf:params:xml:ns:caldav';
28
+ if (children.length === 0) {
29
+ children = [
30
+ { namespaceURI: dav, localName: 'displayname' },
31
+ { namespaceURI: dav, localName: 'resourcetype' },
32
+ { namespaceURI: dav, localName: 'current-user-principal' },
33
+ { namespaceURI: dav, localName: 'current-user-privilege-set' },
34
+ { namespaceURI: cal, localName: 'calendar-home-set' },
35
+ { namespaceURI: cal, localName: 'calendar-user-address-set' },
36
+ { namespaceURI: cal, localName: 'schedule-inbox-URL' },
37
+ { namespaceURI: cal, localName: 'schedule-outbox-URL' }
38
+ ];
39
+ }
23
40
 
24
41
  const actions = _.map(children, async (child) => {
25
42
  return tags.getResponse({
@@ -4,6 +4,12 @@ const winston = require('../../common/winston');
4
4
  module.exports = function (options) {
5
5
  const log = winston({ ...options, label: 'principal/report' });
6
6
  return async function (ctx) {
7
+ // guard against null/missing XML body
8
+ if (!ctx.request.xml || !ctx.request.xml.documentElement) {
9
+ ctx.status = 400;
10
+ return;
11
+ }
12
+
7
13
  const rootTag = ctx.request.xml.documentElement.localName;
8
14
  if (rootTag === 'principal-search-property-set') {
9
15
  log.debug('principal-search-property-set');
package/.commitlintrc.js DELETED
@@ -1,3 +0,0 @@
1
- module.exports = {
2
- extends: ['@commitlint/config-conventional']
3
- };
package/.editorconfig DELETED
@@ -1,9 +0,0 @@
1
- root = true
2
-
3
- [*]
4
- indent_style = space
5
- indent_size = 2
6
- end_of_line = lf
7
- charset = utf-8
8
- trim_trailing_whitespace = true
9
- insert_final_newline = true
package/.gitattributes DELETED
@@ -1 +0,0 @@
1
- * text=auto eol=lf
@@ -1,24 +0,0 @@
1
- name: CI
2
- on:
3
- - push
4
- - pull_request
5
- jobs:
6
- build:
7
- runs-on: ${{ matrix.os }}
8
- strategy:
9
- matrix:
10
- os:
11
- - ubuntu-latest
12
- node_version:
13
- - 18
14
- name: Node ${{ matrix.node_version }} on ${{ matrix.os }}
15
- steps:
16
- - uses: actions/checkout@v4
17
- - name: Setup node
18
- uses: actions/setup-node@v4
19
- with:
20
- node-version: ${{ matrix.node_version }}
21
- - name: Install dependencies
22
- run: npm install
23
- - name: Run tests
24
- run: npm run test
package/.husky/commit-msg DELETED
@@ -1,4 +0,0 @@
1
- #!/bin/sh
2
- . "$(dirname "$0")/_/husky.sh"
3
-
4
- npx --no-install commitlint --edit $1
package/.husky/pre-commit DELETED
@@ -1,4 +0,0 @@
1
- #!/bin/sh
2
- . "$(dirname "$0")/_/husky.sh"
3
-
4
- npx --no-install lint-staged && npm test
package/.lintstagedrc.js DELETED
@@ -1,5 +0,0 @@
1
- module.exports = {
2
- "*.md": filenames => filenames.map(filename => `remark ${filename} -qfo`),
3
- 'package.json': 'fixpack',
4
- '*.js': 'xo --fix'
5
- };
package/.prettierrc.js DELETED
@@ -1,5 +0,0 @@
1
- module.exports = {
2
- singleQuote: true,
3
- bracketSpacing: true,
4
- trailingComma: 'none'
5
- };
package/.remarkignore DELETED
@@ -1 +0,0 @@
1
- test/snapshots/**/*.md
package/.remarkrc.js DELETED
@@ -1,3 +0,0 @@
1
- module.exports = {
2
- plugins: ['preset-github']
3
- };
package/.xo-config.js DELETED
@@ -1,8 +0,0 @@
1
- module.exports = {
2
- prettier: true,
3
- space: true,
4
- extends: ['xo-lass'],
5
- rules: {
6
- 'no-warning-comments': 'off'
7
- }
8
- };