caldav-adapter 9.3.3 → 9.3.4

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.
package/common/tags.js CHANGED
@@ -182,14 +182,14 @@ module.exports = function (options) {
182
182
  doc: 'https://tools.ietf.org/html/rfc3253#section-3.1.5',
183
183
  async resp({ resource }) {
184
184
  if (resource === 'calCollection') {
185
+ //
186
+ // The calendar home collection itself does not support
187
+ // sync-collection or calendar-query — those are per-calendar.
188
+ // Return an empty supported-report-set to avoid confusing
189
+ // clients that check the home's capabilities.
190
+ //
185
191
  return {
186
- [buildTag(dav, 'supported-report-set')]: {
187
- [buildTag(dav, 'supported-report')]: {
188
- [buildTag(dav, 'report')]: {
189
- [buildTag(cal, 'sync-collection')]: ''
190
- }
191
- }
192
- }
192
+ [buildTag(dav, 'supported-report-set')]: ''
193
193
  };
194
194
  }
195
195
 
@@ -372,14 +372,29 @@ module.exports = function (options) {
372
372
  },
373
373
  'supported-calendar-component-set': {
374
374
  doc: 'https://tools.ietf.org/html/rfc4791#section-5.2.3',
375
- async resp({ resource }) {
375
+ async resp({ resource, calendar }) {
376
376
  if (resource === 'calendar') {
377
+ //
378
+ // RFC 4791 Section 5.2.3: report only the component types
379
+ // that this calendar actually supports. The calendar model
380
+ // exposes `has_vevent` and `has_vtodo` booleans; when both
381
+ // are absent/undefined we fall back to advertising both
382
+ // types for backward compatibility.
383
+ //
384
+ const hasVevent = calendar?.has_vevent !== false;
385
+ const hasVtodo = calendar?.has_vtodo !== false;
386
+ const comps = [];
387
+ if (hasVevent) comps.push({ '@name': 'VEVENT' });
388
+ if (hasVtodo) comps.push({ '@name': 'VTODO' });
389
+
390
+ // Safety: if somehow both are false, advertise both
391
+ if (comps.length === 0) {
392
+ comps.push({ '@name': 'VEVENT' }, { '@name': 'VTODO' });
393
+ }
394
+
377
395
  return {
378
396
  [buildTag(cal, 'supported-calendar-component-set')]: {
379
- [buildTag(cal, 'comp')]: [
380
- { '@name': 'VEVENT' },
381
- { '@name': 'VTODO' }
382
- ]
397
+ [buildTag(cal, 'comp')]: comps
383
398
  }
384
399
  };
385
400
  }
package/index.js CHANGED
@@ -3,6 +3,15 @@ const { pathToRegexp } = require('path-to-regexp');
3
3
  const basicAuth = require('basic-auth');
4
4
  const parseBody = require('./common/parse-body');
5
5
  const winston = require('./common/winston');
6
+ const { setMultistatusResponse } = require('./common/response');
7
+ const {
8
+ build,
9
+ buildTag,
10
+ href,
11
+ multistatus,
12
+ response,
13
+ status
14
+ } = require('./common/x-build');
6
15
  const cal = require('./routes/calendar/calendar');
7
16
  const pri = require('./routes/principal/principal');
8
17
 
@@ -135,6 +144,47 @@ module.exports = function (options) {
135
144
  }
136
145
  };
137
146
 
147
+ //
148
+ // RFC 4918 Section 9.1 / RFC 6764 Section 5:
149
+ // When a PROPFIND hits the root (caldavRoot), return a 207 multistatus
150
+ // with current-user-principal so clients can discover the principal URL
151
+ // without following redirects. This is the standard CalDAV discovery
152
+ // flow: clients PROPFIND the root, read current-user-principal, then
153
+ // PROPFIND the principal URL for calendar-home-set.
154
+ //
155
+ // Previously the adapter returned a 302 redirect to /principals/,
156
+ // which some clients (notably iOS/macOS Calendar) do not follow
157
+ // correctly for PROPFIND requests.
158
+ //
159
+ const handleRootPropfind = function (ctx) {
160
+ const dav = 'DAV:';
161
+ const calNs = 'urn:ietf:params:xml:ns:caldav';
162
+
163
+ const props = [
164
+ {
165
+ [buildTag(dav, 'current-user-principal')]: href(ctx.state.principalUrl)
166
+ },
167
+ {
168
+ [buildTag(dav, 'resourcetype')]: {
169
+ [buildTag(dav, 'collection')]: ''
170
+ }
171
+ }
172
+ ];
173
+
174
+ // If the client also asked for calendar-home-set or principal-URL,
175
+ // include them so the client can skip the principal PROPFIND entirely
176
+ if (ctx.state.calendarHomeUrl) {
177
+ props.push({
178
+ [buildTag(calNs, 'calendar-home-set')]: href(ctx.state.calendarHomeUrl)
179
+ });
180
+ }
181
+
182
+ const resps = response(ctx.url, status[200], props);
183
+ const ms = multistatus([resps]);
184
+ setMultistatusResponse(ctx);
185
+ ctx.body = build(ms);
186
+ };
187
+
138
188
  return async function (ctx, next) {
139
189
  // use 301 permanent redirect per RFC 6764 Section 5
140
190
  if (
@@ -169,7 +219,18 @@ module.exports = function (options) {
169
219
  await calendarRoutes(ctx);
170
220
  } else if (principalRegex.regexp.test(ctx.path)) {
171
221
  await principalRoutes(ctx);
222
+ } else if (ctx.method.toLowerCase() === 'propfind') {
223
+ //
224
+ // Handle PROPFIND at the root URL (caldavRoot).
225
+ // Return 207 with current-user-principal so CalDAV clients
226
+ // can discover the principal URL without following redirects.
227
+ //
228
+ handleRootPropfind(ctx);
172
229
  } else {
230
+ //
231
+ // For non-PROPFIND methods at the root (e.g. OPTIONS handled
232
+ // upstream, or unexpected methods), redirect to the principal URL.
233
+ //
173
234
  ctx.redirect(principalRoute);
174
235
  return;
175
236
  }
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.3.3",
4
+ "version": "9.3.4",
5
5
  "author": "Sanders DeNardi and Forward Email LLC",
6
6
  "contributors": [
7
7
  "Sanders DeNardi <sedenardi@gmail.com> (http://www.sandersdenardi.com/)",
@@ -8,22 +8,57 @@ module.exports = function (options) {
8
8
  const eventResponse = calEventResponse(options);
9
9
  return async function (ctx, calendar) {
10
10
  /* https://tools.ietf.org/html/rfc4791#section-9.9 */
11
- const veventFilters = xml.get(
11
+
12
+ //
13
+ // Step 1: Detect which component type the client is filtering on.
14
+ //
15
+ // Clients may send comp-filter with or without a nested time-range.
16
+ // We first check for time-range filters (used for date-bounded queries),
17
+ // then fall back to bare comp-filter elements (used for type-only queries).
18
+ //
19
+ // Example with time-range (Fantastical, iOS date-range sync):
20
+ // <C:comp-filter name="VCALENDAR">
21
+ // <C:comp-filter name="VEVENT">
22
+ // <C:time-range start="..." end="..."/>
23
+ // </C:comp-filter>
24
+ // </C:comp-filter>
25
+ //
26
+ // Example without time-range (iOS initial sync, etag-only fetch):
27
+ // <C:comp-filter name="VCALENDAR">
28
+ // <C:comp-filter name="VEVENT"/>
29
+ // </C:comp-filter>
30
+ //
31
+ const veventTimeRange = xml.get(
12
32
  "/CAL:calendar-query/CAL:filter/CAL:comp-filter[@name='VCALENDAR']/CAL:comp-filter[@name='VEVENT']/CAL:time-range",
13
33
  ctx.request.xml
14
34
  );
15
- const vtodoFilters = xml.get(
35
+ const vtodoTimeRange = xml.get(
16
36
  "/CAL:calendar-query/CAL:filter/CAL:comp-filter[@name='VCALENDAR']/CAL:comp-filter[@name='VTODO']/CAL:time-range",
17
37
  ctx.request.xml
18
38
  );
19
39
 
20
- const filters = veventFilters.length > 0 ? veventFilters : vtodoFilters;
21
- const componentType =
22
- veventFilters.length > 0
23
- ? 'VEVENT'
24
- : vtodoFilters.length > 0
25
- ? 'VTODO'
26
- : null;
40
+ // Also detect bare comp-filter (no time-range child) for component type
41
+ const veventCompFilter = xml.get(
42
+ "/CAL:calendar-query/CAL:filter/CAL:comp-filter[@name='VCALENDAR']/CAL:comp-filter[@name='VEVENT']",
43
+ ctx.request.xml
44
+ );
45
+ const vtodoCompFilter = xml.get(
46
+ "/CAL:calendar-query/CAL:filter/CAL:comp-filter[@name='VCALENDAR']/CAL:comp-filter[@name='VTODO']",
47
+ ctx.request.xml
48
+ );
49
+
50
+ // Time-range filters take priority (date-bounded query)
51
+ const timeRangeFilters =
52
+ veventTimeRange.length > 0 ? veventTimeRange : vtodoTimeRange;
53
+
54
+ // Determine component type from either time-range or bare comp-filter
55
+ let componentType = null;
56
+ if (veventTimeRange.length > 0 || veventCompFilter.length > 0) {
57
+ componentType = 'VEVENT';
58
+ } else if (vtodoTimeRange.length > 0 || vtodoCompFilter.length > 0) {
59
+ componentType = 'VTODO';
60
+ }
61
+
27
62
  const { children } = xml.getWithChildren(
28
63
  '/CAL:calendar-query/D:prop',
29
64
  ctx.request.xml
@@ -32,7 +67,11 @@ module.exports = function (options) {
32
67
  return child.localName === 'calendar-data';
33
68
  });
34
69
 
35
- if (!filters?.[0]) {
70
+ //
71
+ // Step 2: If no time-range filter, return all events for the
72
+ // requested component type (or all types if no comp-filter).
73
+ //
74
+ if (timeRangeFilters.length === 0) {
36
75
  const events = await options.data.getEventsForCalendar(ctx, {
37
76
  principalId: ctx.state.params.principalId,
38
77
  calendarId: options.data.getCalendarId(ctx, calendar),
@@ -60,7 +99,10 @@ module.exports = function (options) {
60
99
  // TODO: what else (?)
61
100
  //
62
101
 
63
- const filter = filters[0];
102
+ //
103
+ // Step 3: Parse time-range attributes and query by date.
104
+ //
105
+ const filter = timeRangeFilters[0];
64
106
  const startAttr = _.find(filter.attributes, { localName: 'start' });
65
107
  const endAttr = _.find(filter.attributes, { localName: 'end' });
66
108
 
@@ -16,19 +16,36 @@ module.exports = function (options) {
16
16
  const tags = commonTags(options);
17
17
 
18
18
  const exec = async function (ctx) {
19
- // Handle missing or invalid XML body gracefully
20
- // This can happen when the client connection is interrupted
21
- // or the body was not received properly
22
- if (!ctx.request.xml) {
19
+ // RFC 4918 Section 9.1: an empty PROPFIND body
20
+ // MUST be treated as an allprop request
21
+ let children = [];
22
+ if (ctx.request.xml) {
23
+ ({ children } = xml.getWithChildren(
24
+ '/D:propfind/D:prop',
25
+ ctx.request.xml
26
+ ));
27
+ } else {
23
28
  log.warn('PROPFIND request received with missing or invalid XML body');
24
- // Return a minimal valid response for allprop
25
- // RFC 4918 Section 9.1: If no body is included, the request MUST be treated as allprop
26
29
  }
27
30
 
28
- const { children } = xml.getWithChildren(
29
- '/D:propfind/D:prop',
30
- ctx.request.xml
31
- );
31
+ //
32
+ // If no properties were requested (allprop or empty body), return
33
+ // the default set of properties that CalDAV clients need for
34
+ // calendar home discovery.
35
+ //
36
+ const dav = 'DAV:';
37
+ const cs = 'http://calendarserver.org/ns/';
38
+ if (children.length === 0) {
39
+ children = [
40
+ { namespaceURI: dav, localName: 'displayname' },
41
+ { namespaceURI: dav, localName: 'resourcetype' },
42
+ { namespaceURI: dav, localName: 'current-user-principal' },
43
+ { namespaceURI: dav, localName: 'current-user-privilege-set' },
44
+ { namespaceURI: dav, localName: 'supported-report-set' },
45
+ { namespaceURI: cs, localName: 'getctag' }
46
+ ];
47
+ }
48
+
32
49
  const checksum = _.some(
33
50
  children,
34
51
  (child) => child.localName === 'checksum-versions'
@@ -6,16 +6,22 @@ const {
6
6
  } = require('../../common/response');
7
7
  const winston = require('../../common/winston');
8
8
  const routePropfind = require('./propfind');
9
- const routeGet = require('./get'); // New GET handler
9
+ const routeGet = require('./get');
10
10
  const routeMkCalendar = require('./mkcalendar');
11
- // const routeReport = require('./report');
11
+ const routeReport = require('./report');
12
12
 
13
13
  module.exports = function (options) {
14
14
  const log = winston({ ...options, label: 'principal' });
15
15
  const methods = {
16
16
  propfind: routePropfind(options),
17
- get: routeGet(options), // Use proper GET handler instead of reusing PROPFIND
18
- // report: reportReport(opts)
17
+ get: routeGet(options),
18
+ //
19
+ // RFC 3744 Section 9.4/9.5: principal-property-search and
20
+ // principal-search-property-set are REPORT methods on the
21
+ // principal resource. Some clients (including Apple Calendar)
22
+ // use these during discovery.
23
+ //
24
+ report: routeReport(options),
19
25
  //
20
26
  // TODO: proppatch
21
27
  // NOTE: fennel implements this with 403 forbidden
@@ -28,7 +34,7 @@ module.exports = function (options) {
28
34
  const method = ctx.method.toLowerCase();
29
35
 
30
36
  if (method === 'options') {
31
- setOptions(ctx, ['OPTIONS', 'PROPFIND', 'GET']);
37
+ setOptions(ctx, ['OPTIONS', 'PROPFIND', 'REPORT', 'GET']);
32
38
  return;
33
39
  }
34
40