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.5",
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
- const events = await options.data.getEventsForCalendar(ctx, {
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
- const events = await options.data.getEventsByDate(ctx, {
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
  };