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 +27 -12
- package/index.js +61 -0
- package/package.json +1 -1
- package/routes/calendar/calendar/calendar-query.js +53 -11
- package/routes/calendar/user/propfind.js +27 -10
- package/routes/principal/principal.js +11 -5
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
if (
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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');
|
|
9
|
+
const routeGet = require('./get');
|
|
10
10
|
const routeMkCalendar = require('./mkcalendar');
|
|
11
|
-
|
|
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),
|
|
18
|
-
//
|
|
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
|
|