caldav-adapter 8.3.0 → 8.3.1
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/parse-body.js +12 -1
- package/common/x-build.js +4 -1
- package/common/xml.js +3 -1
- package/package.json +1 -1
- package/routes/calendar/calendar/proppatch.js +388 -45
- package/routes/calendar/calendar/put.js +7 -4
- package/routes/calendar/user/propfind.js +11 -0
- package/test/proppatch.test.js +1254 -0
package/common/parse-body.js
CHANGED
|
@@ -12,9 +12,19 @@ module.exports = async function (ctx) {
|
|
|
12
12
|
if (ctx.logger) ctx.logger.warn(err);
|
|
13
13
|
else if (ctx?.app?.emit) ctx.app.emit('error', err, ctx);
|
|
14
14
|
else console.warn(err);
|
|
15
|
+
// Set body to empty string on error to prevent undefined issues
|
|
16
|
+
ctx.request.body = '';
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
// Initialize xml to null by default
|
|
20
|
+
ctx.request.xml = null;
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
ctx.request.type.includes('xml') && // Only attempt to parse if we have a non-empty body
|
|
24
|
+
ctx.request.body &&
|
|
25
|
+
typeof ctx.request.body === 'string' &&
|
|
26
|
+
ctx.request.body.trim()
|
|
27
|
+
) {
|
|
18
28
|
try {
|
|
19
29
|
ctx.request.xml = new DOMParser().parseFromString(ctx.request.body);
|
|
20
30
|
// Ensure we have a valid document, otherwise set to null
|
|
@@ -25,6 +35,7 @@ module.exports = async function (ctx) {
|
|
|
25
35
|
if (ctx.logger) ctx.logger.warn(err);
|
|
26
36
|
else if (ctx?.app?.emit) ctx.app.emit('error', err, ctx);
|
|
27
37
|
else console.warn(err);
|
|
38
|
+
ctx.request.xml = null;
|
|
28
39
|
}
|
|
29
40
|
}
|
|
30
41
|
};
|
package/common/x-build.js
CHANGED
|
@@ -58,7 +58,10 @@ module.exports.multistatus = multistatus;
|
|
|
58
58
|
const status = {
|
|
59
59
|
200: 'HTTP/1.1 200 OK',
|
|
60
60
|
403: 'HTTP/1.1 403 Forbidden',
|
|
61
|
-
404: 'HTTP/1.1 404 Not Found'
|
|
61
|
+
404: 'HTTP/1.1 404 Not Found',
|
|
62
|
+
409: 'HTTP/1.1 409 Conflict',
|
|
63
|
+
424: 'HTTP/1.1 424 Failed Dependency',
|
|
64
|
+
500: 'HTTP/1.1 500 Internal Server Error'
|
|
62
65
|
};
|
|
63
66
|
module.exports.status = status;
|
|
64
67
|
|
package/common/xml.js
CHANGED
|
@@ -22,8 +22,10 @@ const select = xpath.useNamespaces(namespaces);
|
|
|
22
22
|
|
|
23
23
|
function get(path, doc) {
|
|
24
24
|
// Validate that doc is a proper XML document
|
|
25
|
+
// Return empty array for null/invalid documents instead of throwing
|
|
26
|
+
// This handles cases where the request body was not received or parsed
|
|
25
27
|
if (!doc || typeof doc !== 'object') {
|
|
26
|
-
|
|
28
|
+
return [];
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
return select(path, doc);
|
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": "8.3.
|
|
4
|
+
"version": "8.3.1",
|
|
5
5
|
"author": "Sanders DeNardi and Forward Email LLC",
|
|
6
6
|
"contributors": [
|
|
7
7
|
"Sanders DeNardi <sedenardi@gmail.com> (http://www.sandersdenardi.com/)",
|
|
@@ -1,9 +1,282 @@
|
|
|
1
1
|
const _ = require('lodash');
|
|
2
2
|
const xml = require('../../../common/xml');
|
|
3
|
-
const {
|
|
3
|
+
const {
|
|
4
|
+
build,
|
|
5
|
+
multistatus,
|
|
6
|
+
response,
|
|
7
|
+
status
|
|
8
|
+
} = require('../../../common/x-build');
|
|
4
9
|
const { setMissingMethod } = require('../../../common/response');
|
|
5
10
|
const commonTags = require('../../../common/tags');
|
|
6
11
|
|
|
12
|
+
// Protected properties that cannot be modified via PROPPATCH
|
|
13
|
+
// Per RFC 4791 Section 5.2.3 and related sections
|
|
14
|
+
const PROTECTED_PROPERTIES = new Set([
|
|
15
|
+
'supported-calendar-component-set',
|
|
16
|
+
'supported-calendar-data',
|
|
17
|
+
'max-resource-size',
|
|
18
|
+
'min-date-time',
|
|
19
|
+
'max-date-time',
|
|
20
|
+
'max-instances',
|
|
21
|
+
'max-attendees-per-instance',
|
|
22
|
+
'getctag', // CalendarServer extension, typically read-only
|
|
23
|
+
'getetag',
|
|
24
|
+
'getcontenttype',
|
|
25
|
+
'getcontentlength',
|
|
26
|
+
'getlastmodified',
|
|
27
|
+
'creationdate',
|
|
28
|
+
'resourcetype',
|
|
29
|
+
'sync-token',
|
|
30
|
+
'current-user-privilege-set',
|
|
31
|
+
'owner',
|
|
32
|
+
'supported-report-set'
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// Properties that can be modified via PROPPATCH
|
|
36
|
+
const MODIFIABLE_PROPERTIES = new Set([
|
|
37
|
+
'displayname',
|
|
38
|
+
'calendar-description',
|
|
39
|
+
'calendar-timezone',
|
|
40
|
+
'calendar-color',
|
|
41
|
+
'calendar-order'
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate VTIMEZONE content per RFC 4791 Section 5.2.2
|
|
46
|
+
* The calendar-timezone property MUST be a valid iCalendar object
|
|
47
|
+
* containing exactly one valid VTIMEZONE component.
|
|
48
|
+
* @param {string} value - The timezone value to validate
|
|
49
|
+
* @returns {{valid: boolean, error: string|null}} - Validation result
|
|
50
|
+
*/
|
|
51
|
+
function validateCalendarTimezone(value) {
|
|
52
|
+
// Empty value is valid (clearing the timezone)
|
|
53
|
+
if (!value || value.trim() === '') {
|
|
54
|
+
return { valid: true, error: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Must contain VCALENDAR wrapper
|
|
58
|
+
if (!value.includes('BEGIN:VCALENDAR') || !value.includes('END:VCALENDAR')) {
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
error:
|
|
62
|
+
'calendar-timezone must be a valid iCalendar object (missing VCALENDAR)'
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Must contain exactly one VTIMEZONE component
|
|
67
|
+
const vtimezoneCount = (value.match(/BEGIN:VTIMEZONE/g) || []).length;
|
|
68
|
+
if (vtimezoneCount === 0) {
|
|
69
|
+
return {
|
|
70
|
+
valid: false,
|
|
71
|
+
error: 'calendar-timezone must contain a VTIMEZONE component'
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (vtimezoneCount > 1) {
|
|
76
|
+
return {
|
|
77
|
+
valid: false,
|
|
78
|
+
error: 'calendar-timezone must contain exactly one VTIMEZONE component'
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Must have matching END:VTIMEZONE
|
|
83
|
+
const endVtimezoneCount = (value.match(/END:VTIMEZONE/g) || []).length;
|
|
84
|
+
if (vtimezoneCount !== endVtimezoneCount) {
|
|
85
|
+
return {
|
|
86
|
+
valid: false,
|
|
87
|
+
error: 'calendar-timezone has malformed VTIMEZONE component'
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Must have TZID property
|
|
92
|
+
if (!value.includes('TZID:') && !value.includes('TZID;')) {
|
|
93
|
+
return {
|
|
94
|
+
valid: false,
|
|
95
|
+
error: 'VTIMEZONE component must have a TZID property'
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { valid: true, error: null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract xml:lang attribute from an element
|
|
104
|
+
* Per RFC 4791 Section 5.2.1: Language tagging information appearing
|
|
105
|
+
* in the scope of the 'prop' element MUST be persistently stored
|
|
106
|
+
* @param {Element} child - XML element node
|
|
107
|
+
* @returns {string|null} - The xml:lang value or null
|
|
108
|
+
*/
|
|
109
|
+
function extractXmlLang(child) {
|
|
110
|
+
// Check for xml:lang attribute on the element itself
|
|
111
|
+
if (child.getAttributeNS) {
|
|
112
|
+
const lang = child.getAttributeNS(
|
|
113
|
+
'http://www.w3.org/XML/1998/namespace',
|
|
114
|
+
'lang'
|
|
115
|
+
);
|
|
116
|
+
if (lang) return lang;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check for xml:lang attribute using getAttribute
|
|
120
|
+
if (child.getAttribute) {
|
|
121
|
+
const lang = child.getAttribute('xml:lang');
|
|
122
|
+
if (lang) return lang;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Walk up the tree to find inherited xml:lang
|
|
126
|
+
let parent = child.parentNode;
|
|
127
|
+
while (parent && parent.getAttribute) {
|
|
128
|
+
const lang = parent.getAttribute('xml:lang');
|
|
129
|
+
if (lang) return lang;
|
|
130
|
+
parent = parent.parentNode;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Process a 'set' property and return the update object
|
|
138
|
+
* @param {Element} child - XML element node
|
|
139
|
+
* @param {Array} validationErrors - Array to collect validation errors
|
|
140
|
+
* @returns {Object|null} - Update object or null if not handled
|
|
141
|
+
*/
|
|
142
|
+
function processSetProperty(child, validationErrors) {
|
|
143
|
+
const xmlLang = extractXmlLang(child);
|
|
144
|
+
|
|
145
|
+
switch (child.localName) {
|
|
146
|
+
case 'displayname': {
|
|
147
|
+
// Allow empty string to clear the display name
|
|
148
|
+
const result = { name: child.textContent };
|
|
149
|
+
if (xmlLang) result.nameXmlLang = xmlLang;
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
case 'calendar-description': {
|
|
154
|
+
// Allow empty string to clear the description
|
|
155
|
+
// Per RFC 4791 Section 5.2.1, calendar-description is (#PCDATA)
|
|
156
|
+
const result = { description: child.textContent };
|
|
157
|
+
if (xmlLang) result.descriptionXmlLang = xmlLang;
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case 'calendar-timezone': {
|
|
162
|
+
// Validate timezone per RFC 4791 Section 5.2.2
|
|
163
|
+
const validation = validateCalendarTimezone(child.textContent);
|
|
164
|
+
if (!validation.valid) {
|
|
165
|
+
validationErrors.push({
|
|
166
|
+
child,
|
|
167
|
+
error: validation.error,
|
|
168
|
+
precondition: 'valid-calendar-data'
|
|
169
|
+
});
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { timezone: child.textContent };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case 'calendar-color': {
|
|
177
|
+
// Allow empty string to clear the color
|
|
178
|
+
return { color: child.textContent };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case 'calendar-order': {
|
|
182
|
+
// For numeric values, parse if content exists, otherwise set to null
|
|
183
|
+
return {
|
|
184
|
+
order: child.textContent ? Number.parseInt(child.textContent, 10) : null
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
default: {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Process a 'remove' property and return the update object
|
|
196
|
+
* Per RFC 4918 Section 14.23: removing sets property to empty/null
|
|
197
|
+
* @param {Element} child - XML element node
|
|
198
|
+
* @returns {Object|null} - Update object or null if not handled
|
|
199
|
+
*/
|
|
200
|
+
function processRemoveProperty(child) {
|
|
201
|
+
switch (child.localName) {
|
|
202
|
+
case 'displayname': {
|
|
203
|
+
return { name: '', nameXmlLang: null };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case 'calendar-description': {
|
|
207
|
+
return { description: '', descriptionXmlLang: null };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case 'calendar-timezone': {
|
|
211
|
+
return { timezone: '' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
case 'calendar-color': {
|
|
215
|
+
return { color: '' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
case 'calendar-order': {
|
|
219
|
+
return { order: null };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
default: {
|
|
223
|
+
// Per RFC 4918: removing non-existent property is not an error
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Build error response for protected property modification attempt
|
|
231
|
+
* Per RFC 4918 Section 9.2: atomicity requires all or nothing
|
|
232
|
+
* @param {string} url - Request URL
|
|
233
|
+
* @param {Array} allChildren - All property children
|
|
234
|
+
* @param {Array} protectedErrors - Protected property elements
|
|
235
|
+
* @returns {string} - XML response
|
|
236
|
+
*/
|
|
237
|
+
function buildProtectedPropertyErrorResponse(
|
|
238
|
+
url,
|
|
239
|
+
allChildren,
|
|
240
|
+
protectedErrors
|
|
241
|
+
) {
|
|
242
|
+
const responses = allChildren.map(({ child }) => {
|
|
243
|
+
const propName = `${child.prefix || 'D'}:${child.localName}`;
|
|
244
|
+
if (protectedErrors.includes(child)) {
|
|
245
|
+
// Return 403 for protected properties
|
|
246
|
+
return response(url, status[403], [{ [propName]: '' }]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Return 424 Failed Dependency for other properties
|
|
250
|
+
return response(url, status[424], [{ [propName]: '' }]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return build(multistatus(_.compact(responses)));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Build error response for validation failures
|
|
258
|
+
* Per RFC 4918 Section 9.2: atomicity requires all or nothing
|
|
259
|
+
* @param {string} url - Request URL
|
|
260
|
+
* @param {Array} allChildren - All property children
|
|
261
|
+
* @param {Array} validationErrors - Validation error objects
|
|
262
|
+
* @returns {string} - XML response
|
|
263
|
+
*/
|
|
264
|
+
function buildValidationErrorResponse(url, allChildren, validationErrors) {
|
|
265
|
+
const errorChildren = new Set(validationErrors.map((e) => e.child));
|
|
266
|
+
const responses = allChildren.map(({ child }) => {
|
|
267
|
+
const propName = `${child.prefix || 'D'}:${child.localName}`;
|
|
268
|
+
if (errorChildren.has(child)) {
|
|
269
|
+
// Return 409 Conflict for validation errors (per RFC 4791 Section 5.3.2.1)
|
|
270
|
+
return response(url, status[409], [{ [propName]: '' }]);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Return 424 Failed Dependency for other properties
|
|
274
|
+
return response(url, status[424], [{ [propName]: '' }]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return build(multistatus(_.compact(responses)));
|
|
278
|
+
}
|
|
279
|
+
|
|
7
280
|
module.exports = function (options) {
|
|
8
281
|
const tags = commonTags(options);
|
|
9
282
|
|
|
@@ -13,73 +286,143 @@ module.exports = function (options) {
|
|
|
13
286
|
return;
|
|
14
287
|
}
|
|
15
288
|
|
|
16
|
-
|
|
289
|
+
//
|
|
290
|
+
// Per RFC 4918 Section 9.2:
|
|
291
|
+
// - Servers MUST process PROPPATCH instructions in document order
|
|
292
|
+
// - Instructions MUST either all be executed or none executed (atomicity)
|
|
293
|
+
// - The propertyupdate element can contain both 'set' and 'remove' instructions
|
|
294
|
+
//
|
|
295
|
+
const { children: setChildren } = xml.getWithChildren(
|
|
17
296
|
'/D:propertyupdate/D:set/D:prop',
|
|
18
297
|
ctx.request.xml
|
|
19
298
|
);
|
|
20
299
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
case 'displayname': {
|
|
26
|
-
updates.name = child.textContent;
|
|
27
|
-
|
|
28
|
-
break;
|
|
29
|
-
}
|
|
300
|
+
const { children: removeChildren } = xml.getWithChildren(
|
|
301
|
+
'/D:propertyupdate/D:remove/D:prop',
|
|
302
|
+
ctx.request.xml
|
|
303
|
+
);
|
|
30
304
|
|
|
31
|
-
|
|
32
|
-
|
|
305
|
+
const updates = {};
|
|
306
|
+
const protectedErrors = [];
|
|
307
|
+
const validationErrors = [];
|
|
308
|
+
const allChildren = [];
|
|
33
309
|
|
|
34
|
-
|
|
35
|
-
|
|
310
|
+
// Process 'set' instructions
|
|
311
|
+
for (const child of setChildren) {
|
|
312
|
+
if (!child.localName) continue;
|
|
313
|
+
allChildren.push({ child, action: 'set' });
|
|
36
314
|
|
|
37
|
-
|
|
38
|
-
|
|
315
|
+
if (PROTECTED_PROPERTIES.has(child.localName)) {
|
|
316
|
+
protectedErrors.push(child);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
39
319
|
|
|
40
|
-
|
|
41
|
-
|
|
320
|
+
const update = processSetProperty(child, validationErrors);
|
|
321
|
+
if (update) {
|
|
322
|
+
Object.assign(updates, update);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
42
325
|
|
|
43
|
-
|
|
44
|
-
|
|
326
|
+
// Process 'remove' instructions
|
|
327
|
+
for (const child of removeChildren) {
|
|
328
|
+
if (!child.localName) continue;
|
|
329
|
+
allChildren.push({ child, action: 'remove' });
|
|
45
330
|
|
|
46
|
-
|
|
47
|
-
|
|
331
|
+
if (PROTECTED_PROPERTIES.has(child.localName)) {
|
|
332
|
+
protectedErrors.push(child);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
48
335
|
|
|
49
|
-
|
|
50
|
-
|
|
336
|
+
const update = processRemoveProperty(child);
|
|
337
|
+
if (update) {
|
|
338
|
+
Object.assign(updates, update);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
51
341
|
|
|
52
|
-
|
|
53
|
-
|
|
342
|
+
//
|
|
343
|
+
// Atomicity check: If any errors, fail entire request
|
|
344
|
+
// Per RFC 4918 Section 9.2: "Instructions MUST either all be executed or none executed"
|
|
345
|
+
//
|
|
54
346
|
|
|
55
|
-
|
|
347
|
+
// Check for protected property errors first
|
|
348
|
+
if (protectedErrors.length > 0) {
|
|
349
|
+
return buildProtectedPropertyErrorResponse(
|
|
350
|
+
ctx.url,
|
|
351
|
+
allChildren,
|
|
352
|
+
protectedErrors
|
|
353
|
+
);
|
|
354
|
+
}
|
|
56
355
|
|
|
57
|
-
|
|
356
|
+
// Check for validation errors (e.g., invalid timezone)
|
|
357
|
+
if (validationErrors.length > 0) {
|
|
358
|
+
// Log validation errors for debugging
|
|
359
|
+
for (const { child, error } of validationErrors) {
|
|
360
|
+
const err = new Error(
|
|
361
|
+
`CalDAV PROPPATCH validation error for ${child.localName}: ${error}`
|
|
362
|
+
);
|
|
363
|
+
err.isCodeBug = false;
|
|
364
|
+
console.warn(err.message);
|
|
365
|
+
if (ctx.logger) ctx.logger.warn(err.message);
|
|
58
366
|
}
|
|
367
|
+
|
|
368
|
+
return buildValidationErrorResponse(
|
|
369
|
+
ctx.url,
|
|
370
|
+
allChildren,
|
|
371
|
+
validationErrors
|
|
372
|
+
);
|
|
59
373
|
}
|
|
60
374
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
375
|
+
// Log warning for unhandled properties
|
|
376
|
+
const unhandledProps = allChildren
|
|
377
|
+
.filter(
|
|
378
|
+
({ child }) =>
|
|
379
|
+
child.localName &&
|
|
380
|
+
!MODIFIABLE_PROPERTIES.has(child.localName) &&
|
|
381
|
+
!PROTECTED_PROPERTIES.has(child.localName)
|
|
382
|
+
)
|
|
383
|
+
.map(({ child }) => child.localName);
|
|
384
|
+
|
|
385
|
+
if (unhandledProps.length > 0) {
|
|
386
|
+
const err = new TypeError(
|
|
387
|
+
`CalDAV PROPPATCH unhandled properties: ${unhandledProps.join(', ')}`
|
|
388
|
+
);
|
|
389
|
+
err.isCodeBug = true;
|
|
390
|
+
err.str = ctx.request.body;
|
|
70
391
|
err.xml = ctx.request.xml;
|
|
71
392
|
console.error(err);
|
|
72
393
|
if (ctx.logger) ctx.logger.error(err);
|
|
73
394
|
}
|
|
74
395
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
396
|
+
//
|
|
397
|
+
// Apply updates atomically
|
|
398
|
+
// The updateCalendar function should handle rollback on failure
|
|
399
|
+
//
|
|
400
|
+
let updatedCalendar = calendar;
|
|
401
|
+
if (!_.isEmpty(updates)) {
|
|
402
|
+
try {
|
|
403
|
+
updatedCalendar = await options.data.updateCalendar(ctx, {
|
|
404
|
+
principalId: ctx.state.params.principalId,
|
|
405
|
+
calendarId: ctx.state.params.calendarId,
|
|
406
|
+
user: ctx.state.user,
|
|
407
|
+
updates
|
|
408
|
+
});
|
|
409
|
+
} catch (err) {
|
|
410
|
+
// If update fails, return 500 for all properties (atomicity)
|
|
411
|
+
console.error('CalDAV PROPPATCH update failed:', err);
|
|
412
|
+
if (ctx.logger)
|
|
413
|
+
ctx.logger.error('CalDAV PROPPATCH update failed:', err);
|
|
414
|
+
|
|
415
|
+
const responses = allChildren.map(({ child }) => {
|
|
416
|
+
const propName = `${child.prefix || 'D'}:${child.localName}`;
|
|
417
|
+
return response(ctx.url, status[500], [{ [propName]: '' }]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
return build(multistatus(_.compact(responses)));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
81
423
|
|
|
82
|
-
|
|
424
|
+
// Build response for each property
|
|
425
|
+
const actions = allChildren.map(async ({ child }) => {
|
|
83
426
|
return tags.getResponse({
|
|
84
427
|
resource: 'calendarProppatch',
|
|
85
428
|
child,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
const { notFound } = require('../../../common/x-build');
|
|
2
|
-
// const { notFound, preconditionFail } = require('../../../common/x-build');
|
|
1
|
+
const { notFound, preconditionFail } = require('../../../common/x-build');
|
|
3
2
|
const { setMissingMethod } = require('../../../common/response');
|
|
4
3
|
const winston = require('../../../common/winston');
|
|
5
4
|
|
|
@@ -57,14 +56,18 @@ module.exports = function (options) {
|
|
|
57
56
|
log.debug(`existing event${existing ? '' : ' not'} found`);
|
|
58
57
|
|
|
59
58
|
if (existing) {
|
|
60
|
-
|
|
59
|
+
//
|
|
60
|
+
// RFC 7232 Section 3.2: If-None-Match
|
|
61
|
+
// When a client sends "If-None-Match: *", the server MUST NOT perform
|
|
62
|
+
// the requested method if the target resource exists.
|
|
63
|
+
// This is used by clients to prevent overwriting existing resources.
|
|
64
|
+
//
|
|
61
65
|
if (ctx.get('if-none-match') === '*') {
|
|
62
66
|
log.warn('if-none-match: * header present, precondition failed');
|
|
63
67
|
ctx.status = 412;
|
|
64
68
|
ctx.body = preconditionFail(ctx.url, 'no-uid-conflict');
|
|
65
69
|
return;
|
|
66
70
|
}
|
|
67
|
-
*/
|
|
68
71
|
|
|
69
72
|
const updateObject = await options.data.updateEvent(ctx, {
|
|
70
73
|
eventId: ctx.state.params.eventId,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const _ = require('lodash');
|
|
2
2
|
const xml = require('../../../common/xml');
|
|
3
|
+
const winston = require('../../../common/winston');
|
|
3
4
|
const {
|
|
4
5
|
build,
|
|
5
6
|
multistatus,
|
|
@@ -10,10 +11,20 @@ const calPropfind = require('../calendar/propfind');
|
|
|
10
11
|
const commonTags = require('../../../common/tags');
|
|
11
12
|
|
|
12
13
|
module.exports = function (options) {
|
|
14
|
+
const log = winston({ ...options, label: 'user/propfind' });
|
|
13
15
|
const { calendarResponse } = calPropfind(options);
|
|
14
16
|
const tags = commonTags(options);
|
|
15
17
|
|
|
16
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) {
|
|
23
|
+
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
|
+
}
|
|
27
|
+
|
|
17
28
|
const { children } = xml.getWithChildren(
|
|
18
29
|
'/D:propfind/D:prop',
|
|
19
30
|
ctx.request.xml
|