caldav-adapter 9.3.5 → 9.3.6
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/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.6",
|
|
5
5
|
"author": "Sanders DeNardi and Forward Email LLC",
|
|
6
6
|
"contributors": [
|
|
7
7
|
"Sanders DeNardi <sedenardi@gmail.com> (http://www.sandersdenardi.com/)",
|
|
@@ -3,6 +3,181 @@ const moment = require('moment');
|
|
|
3
3
|
const xml = require('../../../common/xml');
|
|
4
4
|
const calEventResponse = require('./event-response');
|
|
5
5
|
|
|
6
|
+
//
|
|
7
|
+
// RFC 4791 Section 9.7.2 - CALDAV:prop-filter
|
|
8
|
+
// RFC 4791 Section 9.7.5 - CALDAV:text-match
|
|
9
|
+
//
|
|
10
|
+
// Extract an iCalendar property value from raw ICS text.
|
|
11
|
+
// Handles folded lines (RFC 5545 Section 3.1) and parameters
|
|
12
|
+
// (e.g. STATUS;VALUE=TEXT:COMPLETED).
|
|
13
|
+
//
|
|
14
|
+
// Returns the property value string, or null if the property
|
|
15
|
+
// is not present in the given component block.
|
|
16
|
+
//
|
|
17
|
+
function getICSPropertyValue(ical, componentType, propertyName) {
|
|
18
|
+
if (!ical || typeof ical !== 'string') return null;
|
|
19
|
+
|
|
20
|
+
// Unfold continuation lines (CRLF + whitespace)
|
|
21
|
+
const unfolded = ical.replaceAll(/\r?\n[ \t]/g, '');
|
|
22
|
+
|
|
23
|
+
//
|
|
24
|
+
// Find the component block (e.g. BEGIN:VTODO ... END:VTODO)
|
|
25
|
+
// to avoid matching properties from other components.
|
|
26
|
+
//
|
|
27
|
+
const compStart = new RegExp(`^BEGIN:${componentType}\\s*$`, 'mi');
|
|
28
|
+
const compEnd = new RegExp(`^END:${componentType}\\s*$`, 'mi');
|
|
29
|
+
|
|
30
|
+
const startMatch = compStart.exec(unfolded);
|
|
31
|
+
if (!startMatch) return null;
|
|
32
|
+
|
|
33
|
+
const endMatch = compEnd.exec(unfolded.slice(startMatch.index));
|
|
34
|
+
const block = endMatch
|
|
35
|
+
? unfolded.slice(startMatch.index, startMatch.index + endMatch.index)
|
|
36
|
+
: unfolded.slice(startMatch.index);
|
|
37
|
+
|
|
38
|
+
//
|
|
39
|
+
// Match the property line. Property names are case-insensitive per RFC 5545.
|
|
40
|
+
// The property may have parameters separated by semicolons before the colon.
|
|
41
|
+
// e.g. STATUS:COMPLETED or STATUS;VALUE=TEXT:COMPLETED
|
|
42
|
+
//
|
|
43
|
+
const propRegex = new RegExp(
|
|
44
|
+
`^${_.escapeRegExp(propertyName)}(?:;[^:]*)?:(.*)$`,
|
|
45
|
+
'mi'
|
|
46
|
+
);
|
|
47
|
+
const match = propRegex.exec(block);
|
|
48
|
+
return match ? match[1].trim() : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
//
|
|
52
|
+
// Parse prop-filter elements from a comp-filter node.
|
|
53
|
+
// Returns an array of { name, isNotDefined, textMatch } objects.
|
|
54
|
+
//
|
|
55
|
+
// RFC 4791 Section 9.7.2 defines prop-filter matching:
|
|
56
|
+
// - Empty prop-filter: property must exist
|
|
57
|
+
// - is-not-defined child: property must NOT exist
|
|
58
|
+
// - text-match child: property value must match the text
|
|
59
|
+
//
|
|
60
|
+
function parsePropFilters(compFilterNodes) {
|
|
61
|
+
const propFilters = [];
|
|
62
|
+
if (!compFilterNodes || compFilterNodes.length === 0) return propFilters;
|
|
63
|
+
|
|
64
|
+
const compFilterNode = compFilterNodes[0];
|
|
65
|
+
if (!compFilterNode || !compFilterNode.childNodes) return propFilters;
|
|
66
|
+
|
|
67
|
+
// eslint-disable-next-line unicorn/prefer-spread
|
|
68
|
+
for (const child of Array.from(compFilterNode.childNodes)) {
|
|
69
|
+
// Only process prop-filter elements in the CalDAV namespace
|
|
70
|
+
if (
|
|
71
|
+
child.localName !== 'prop-filter' ||
|
|
72
|
+
(child.namespaceURI &&
|
|
73
|
+
child.namespaceURI !== 'urn:ietf:params:xml:ns:caldav')
|
|
74
|
+
) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const nameAttr = _.find(
|
|
79
|
+
// eslint-disable-next-line unicorn/prefer-spread
|
|
80
|
+
Array.from(child.attributes || []),
|
|
81
|
+
(a) => a.localName === 'name'
|
|
82
|
+
);
|
|
83
|
+
if (!nameAttr) continue;
|
|
84
|
+
|
|
85
|
+
const filter = {
|
|
86
|
+
name: nameAttr.nodeValue,
|
|
87
|
+
isNotDefined: false,
|
|
88
|
+
textMatch: null
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Check for child elements (is-not-defined or text-match)
|
|
92
|
+
if (child.childNodes) {
|
|
93
|
+
// eslint-disable-next-line unicorn/prefer-spread
|
|
94
|
+
for (const grandChild of Array.from(child.childNodes)) {
|
|
95
|
+
if (grandChild.localName === 'is-not-defined') {
|
|
96
|
+
filter.isNotDefined = true;
|
|
97
|
+
} else if (grandChild.localName === 'text-match') {
|
|
98
|
+
const negateAttr = _.find(
|
|
99
|
+
// eslint-disable-next-line unicorn/prefer-spread
|
|
100
|
+
Array.from(grandChild.attributes || []),
|
|
101
|
+
(a) => a.localName === 'negate-condition'
|
|
102
|
+
);
|
|
103
|
+
const collationAttr = _.find(
|
|
104
|
+
// eslint-disable-next-line unicorn/prefer-spread
|
|
105
|
+
Array.from(grandChild.attributes || []),
|
|
106
|
+
(a) => a.localName === 'collation'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
filter.textMatch = {
|
|
110
|
+
value: grandChild.textContent || '',
|
|
111
|
+
negate: negateAttr ? negateAttr.nodeValue === 'yes' : false,
|
|
112
|
+
// Default collation is i;ascii-casemap (case-insensitive)
|
|
113
|
+
collation: collationAttr
|
|
114
|
+
? collationAttr.nodeValue
|
|
115
|
+
: 'i;ascii-casemap'
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
propFilters.push(filter);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return propFilters;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
//
|
|
128
|
+
// Apply prop-filters to a list of events.
|
|
129
|
+
// Each event must have an `ical` property containing the raw ICS text.
|
|
130
|
+
// Returns only events that match ALL prop-filter conditions (AND logic per RFC 4791).
|
|
131
|
+
//
|
|
132
|
+
function applyPropFilters(events, propFilters, componentType) {
|
|
133
|
+
if (!propFilters || propFilters.length === 0) return events;
|
|
134
|
+
|
|
135
|
+
return events.filter((event) => {
|
|
136
|
+
// Skip events without ICS data (e.g. etag-only responses)
|
|
137
|
+
if (!event.ical) return true;
|
|
138
|
+
|
|
139
|
+
for (const pf of propFilters) {
|
|
140
|
+
const value = getICSPropertyValue(event.ical, componentType, pf.name);
|
|
141
|
+
const propertyExists = value !== null;
|
|
142
|
+
|
|
143
|
+
// is-not-defined: property must NOT exist
|
|
144
|
+
if (pf.isNotDefined) {
|
|
145
|
+
if (propertyExists) return false;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Empty prop-filter (no text-match, no is-not-defined): property must exist
|
|
150
|
+
if (!pf.textMatch) {
|
|
151
|
+
if (!propertyExists) return false;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// text-match: property must exist AND value must match
|
|
156
|
+
if (!propertyExists) return false;
|
|
157
|
+
|
|
158
|
+
const caseInsensitive =
|
|
159
|
+
!pf.textMatch.collation || pf.textMatch.collation === 'i;ascii-casemap';
|
|
160
|
+
|
|
161
|
+
const eventValue = caseInsensitive ? value.toLowerCase() : value;
|
|
162
|
+
const matchValue = caseInsensitive
|
|
163
|
+
? pf.textMatch.value.toLowerCase()
|
|
164
|
+
: pf.textMatch.value;
|
|
165
|
+
|
|
166
|
+
// Substring match per RFC 4790 Section 4.2
|
|
167
|
+
const contains = eventValue.includes(matchValue);
|
|
168
|
+
|
|
169
|
+
// negate-condition inverts the result
|
|
170
|
+
if (pf.textMatch.negate) {
|
|
171
|
+
if (contains) return false;
|
|
172
|
+
} else if (!contains) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return true;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
6
181
|
module.exports = function (options) {
|
|
7
182
|
// const log = winston({ ...opts, label: 'calendar/report/calendar-query' });
|
|
8
183
|
const eventResponse = calEventResponse(options);
|
|
@@ -59,6 +234,25 @@ module.exports = function (options) {
|
|
|
59
234
|
componentType = 'VTODO';
|
|
60
235
|
}
|
|
61
236
|
|
|
237
|
+
//
|
|
238
|
+
// Step 1b: Parse prop-filter elements from the comp-filter.
|
|
239
|
+
//
|
|
240
|
+
// RFC 4791 Section 9.7.2 - prop-filter specifies search criteria
|
|
241
|
+
// on calendar properties within the matched component.
|
|
242
|
+
//
|
|
243
|
+
// Example (iOS Reminders - exclude completed tasks):
|
|
244
|
+
// <C:comp-filter name="VCALENDAR">
|
|
245
|
+
// <C:comp-filter name="VTODO">
|
|
246
|
+
// <C:prop-filter name="STATUS">
|
|
247
|
+
// <C:text-match negate-condition="yes">COMPLETED</C:text-match>
|
|
248
|
+
// </C:prop-filter>
|
|
249
|
+
// </C:comp-filter>
|
|
250
|
+
// </C:comp-filter>
|
|
251
|
+
//
|
|
252
|
+
const activeCompFilter =
|
|
253
|
+
componentType === 'VTODO' ? vtodoCompFilter : veventCompFilter;
|
|
254
|
+
const propFilters = parsePropFilters(activeCompFilter);
|
|
255
|
+
|
|
62
256
|
const { children } = xml.getWithChildren(
|
|
63
257
|
'/CAL:calendar-query/D:prop',
|
|
64
258
|
ctx.request.xml
|
|
@@ -67,38 +261,33 @@ module.exports = function (options) {
|
|
|
67
261
|
return child.localName === 'calendar-data';
|
|
68
262
|
});
|
|
69
263
|
|
|
264
|
+
//
|
|
265
|
+
// When prop-filters are present, we need the full ICS data to evaluate
|
|
266
|
+
// property values, even if the client only requested etags.
|
|
267
|
+
//
|
|
268
|
+
const needsFullData = fullData || propFilters.length > 0;
|
|
269
|
+
|
|
70
270
|
//
|
|
71
271
|
// Step 2: If no time-range filter, return all events for the
|
|
72
272
|
// requested component type (or all types if no comp-filter).
|
|
73
273
|
//
|
|
74
274
|
if (timeRangeFilters.length === 0) {
|
|
75
|
-
|
|
275
|
+
let events = await options.data.getEventsForCalendar(ctx, {
|
|
76
276
|
principalId: ctx.state.params.principalId,
|
|
77
277
|
calendarId: options.data.getCalendarId(ctx, calendar),
|
|
78
278
|
user: ctx.state.user,
|
|
79
|
-
fullData,
|
|
279
|
+
fullData: needsFullData,
|
|
80
280
|
componentType
|
|
81
281
|
});
|
|
82
282
|
|
|
283
|
+
// Apply prop-filter conditions (RFC 4791 Section 9.7.2)
|
|
284
|
+
if (propFilters.length > 0 && componentType) {
|
|
285
|
+
events = applyPropFilters(events, propFilters, componentType);
|
|
286
|
+
}
|
|
287
|
+
|
|
83
288
|
return eventResponse(ctx, events, calendar, children);
|
|
84
289
|
}
|
|
85
290
|
|
|
86
|
-
//
|
|
87
|
-
// TODO: support rest of calendar-query
|
|
88
|
-
// <https://datatracker.ietf.org/doc/html/rfc4791#section-7.8>
|
|
89
|
-
//
|
|
90
|
-
// TODO: support multiple filters and missing filters:
|
|
91
|
-
//
|
|
92
|
-
// <https://datatracker.ietf.org/doc/html/rfc4791#section-9.7>
|
|
93
|
-
// - [ ] 9.7.1. CALDAV:comp-filter XML Element . . . . . . . . . . . . 85
|
|
94
|
-
// - [ ] 9.7.2. CALDAV:prop-filter XML Element . . . . . . . . . . . . 86
|
|
95
|
-
// - [ ] 9.7.3. CALDAV:param-filter XML Element . . . . . . . . . . . 87
|
|
96
|
-
// - [ ] 9.7.4. CALDAV:is-not-defined XML Element . . . . . . . . . . 88
|
|
97
|
-
// - [ ] 9.7.5. CALDAV:text-match XML Element . . . . . . . . . . . . 88
|
|
98
|
-
//
|
|
99
|
-
// TODO: what else (?)
|
|
100
|
-
//
|
|
101
|
-
|
|
102
291
|
//
|
|
103
292
|
// Step 3: Parse time-range attributes and query by date.
|
|
104
293
|
//
|
|
@@ -122,15 +311,21 @@ module.exports = function (options) {
|
|
|
122
311
|
if (endAttr && endAttr.nodeValue && moment(endAttr.nodeValue).isValid())
|
|
123
312
|
end = moment(endAttr.nodeValue).toDate();
|
|
124
313
|
|
|
125
|
-
|
|
314
|
+
let events = await options.data.getEventsByDate(ctx, {
|
|
126
315
|
principalId: ctx.state.params.principalId,
|
|
127
316
|
calendarId: options.data.getCalendarId(ctx, calendar),
|
|
128
317
|
start,
|
|
129
318
|
end,
|
|
130
319
|
user: ctx.state.user,
|
|
131
|
-
fullData,
|
|
320
|
+
fullData: needsFullData,
|
|
132
321
|
componentType
|
|
133
322
|
});
|
|
323
|
+
|
|
324
|
+
// Apply prop-filter conditions (RFC 4791 Section 9.7.2)
|
|
325
|
+
if (propFilters.length > 0 && componentType) {
|
|
326
|
+
events = applyPropFilters(events, propFilters, componentType);
|
|
327
|
+
}
|
|
328
|
+
|
|
134
329
|
return eventResponse(ctx, events, calendar, children);
|
|
135
330
|
};
|
|
136
331
|
};
|