@dytsou/calendar-build 2.1.0 → 2.1.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/README.md +17 -4
- package/package.json +2 -3
- package/scripts/build.js +10 -7
- package/scripts/encrypt-urls.js +0 -1
- package/worker.js +595 -180
package/worker.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cloudflare Worker to decrypt fernet:// URLs and proxy to open-web-calendar
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* This worker:
|
|
5
5
|
* 1. Reads CALENDAR_URL from Cloudflare Worker secrets (comma-separated)
|
|
6
6
|
* 2. Decrypts fernet:// URLs using Fernet encryption (ENCRYPTION_METHOD is hardcoded to 'fernet')
|
|
7
7
|
* 3. Forwards the request to open-web-calendar with decrypted URLs
|
|
8
|
-
* 4.
|
|
9
|
-
*
|
|
8
|
+
* 4. Filters out events declined by user (based on USER_EMAILS secret)
|
|
9
|
+
* 5. Sanitizes non-PUBLIC events to only show busy time
|
|
10
|
+
* 6. Returns the calendar HTML
|
|
11
|
+
*
|
|
10
12
|
* Configuration:
|
|
11
13
|
* - ENCRYPTION_METHOD: Hardcoded to 'fernet' (not configurable)
|
|
12
14
|
* - ENCRYPTION_KEY: Required Cloudflare Worker secret (for decrypting fernet:// URLs)
|
|
13
15
|
* - CALENDAR_URL: Required Cloudflare Worker secret (comma-separated, can be plain or fernet:// encrypted)
|
|
14
|
-
*
|
|
16
|
+
* - USER_EMAILS: Optional Cloudflare Worker secret (comma-separated list of user emails for declined event filtering)
|
|
17
|
+
*
|
|
15
18
|
* Usage:
|
|
16
19
|
* 1. Set CALENDAR_URL secret: wrangler secret put CALENDAR_URL
|
|
17
20
|
* 2. Set ENCRYPTION_KEY secret: wrangler secret put ENCRYPTION_KEY
|
|
18
|
-
* 3.
|
|
21
|
+
* 3. Set USER_EMAILS secret: wrangler secret put USER_EMAILS
|
|
22
|
+
* (e.g., "user1@gmail.com,user2@example.com,user3@domain.org")
|
|
23
|
+
* 4. Deploy: wrangler deploy
|
|
19
24
|
*/
|
|
20
25
|
|
|
21
26
|
// Import Fernet library - we'll need to bundle this for Workers
|
|
@@ -48,22 +53,23 @@ async function decryptFernetToken(token, secretKey) {
|
|
|
48
53
|
try {
|
|
49
54
|
// Decode the token
|
|
50
55
|
const tokenBytes = base64UrlDecode(token);
|
|
51
|
-
|
|
52
|
-
if (tokenBytes.length < 57) {
|
|
56
|
+
|
|
57
|
+
if (tokenBytes.length < 57) {
|
|
58
|
+
// Minimum: 1 + 8 + 16 + 0 + 32 = 57 bytes
|
|
53
59
|
throw new Error('Invalid Fernet token: too short');
|
|
54
60
|
}
|
|
55
|
-
|
|
61
|
+
|
|
56
62
|
// Extract components
|
|
57
63
|
const version = tokenBytes[0];
|
|
58
64
|
if (version !== 0x80) {
|
|
59
65
|
throw new Error('Invalid Fernet token: unsupported version');
|
|
60
66
|
}
|
|
61
|
-
|
|
67
|
+
|
|
62
68
|
const timestamp = tokenBytes.slice(1, 9);
|
|
63
69
|
const iv = tokenBytes.slice(9, 25);
|
|
64
70
|
const hmac = tokenBytes.slice(-32);
|
|
65
71
|
const ciphertext = tokenBytes.slice(25, -32);
|
|
66
|
-
|
|
72
|
+
|
|
67
73
|
// Derive signing key and encryption key from secret
|
|
68
74
|
// Fernet secret is base64url-encoded 32-byte key
|
|
69
75
|
// The library splits it directly: first 16 bytes = signing key, last 16 bytes = encryption key
|
|
@@ -77,13 +83,13 @@ async function decryptFernetToken(token, secretKey) {
|
|
|
77
83
|
} catch (error) {
|
|
78
84
|
throw new Error(`Failed to decode secret key: ${error.message}`);
|
|
79
85
|
}
|
|
80
|
-
|
|
86
|
+
|
|
81
87
|
// Fernet splits the 32-byte secret directly:
|
|
82
88
|
// Signing key: first 16 bytes (128 bits)
|
|
83
89
|
// Encryption key: last 16 bytes (128 bits)
|
|
84
90
|
const signingKey = secretBytes.slice(0, 16);
|
|
85
91
|
const encryptionKey = secretBytes.slice(16, 32);
|
|
86
|
-
|
|
92
|
+
|
|
87
93
|
// Verify HMAC
|
|
88
94
|
// HMAC message is: version (1 byte) + timestamp (8 bytes) + IV (16 bytes) + ciphertext
|
|
89
95
|
const message = tokenBytes.slice(0, -32);
|
|
@@ -94,10 +100,10 @@ async function decryptFernetToken(token, secretKey) {
|
|
|
94
100
|
false,
|
|
95
101
|
['sign', 'verify']
|
|
96
102
|
);
|
|
97
|
-
|
|
103
|
+
|
|
98
104
|
const computedHmac = await crypto.subtle.sign('HMAC', hmacKey, message);
|
|
99
105
|
const computedHmacBytes = new Uint8Array(computedHmac);
|
|
100
|
-
|
|
106
|
+
|
|
101
107
|
// Constant-time comparison
|
|
102
108
|
let hmacValid = true;
|
|
103
109
|
if (computedHmacBytes.length !== hmac.length) {
|
|
@@ -109,11 +115,11 @@ async function decryptFernetToken(token, secretKey) {
|
|
|
109
115
|
}
|
|
110
116
|
}
|
|
111
117
|
}
|
|
112
|
-
|
|
118
|
+
|
|
113
119
|
if (!hmacValid) {
|
|
114
120
|
throw new Error('Invalid Fernet token: HMAC verification failed');
|
|
115
121
|
}
|
|
116
|
-
|
|
122
|
+
|
|
117
123
|
// Decrypt using AES-128-CBC
|
|
118
124
|
const cryptoKey = await crypto.subtle.importKey(
|
|
119
125
|
'raw',
|
|
@@ -122,13 +128,13 @@ async function decryptFernetToken(token, secretKey) {
|
|
|
122
128
|
false,
|
|
123
129
|
['decrypt']
|
|
124
130
|
);
|
|
125
|
-
|
|
131
|
+
|
|
126
132
|
const decrypted = await crypto.subtle.decrypt(
|
|
127
133
|
{ name: 'AES-CBC', iv: iv },
|
|
128
134
|
cryptoKey,
|
|
129
135
|
ciphertext
|
|
130
136
|
);
|
|
131
|
-
|
|
137
|
+
|
|
132
138
|
// Decode the decrypted message (PKCS7 padding will be removed automatically)
|
|
133
139
|
const decoder = new TextDecoder();
|
|
134
140
|
return decoder.decode(decrypted);
|
|
@@ -142,10 +148,8 @@ async function decryptFernetToken(token, secretKey) {
|
|
|
142
148
|
*/
|
|
143
149
|
async function decryptFernetUrl(fernetUrl, secretKey) {
|
|
144
150
|
// Remove fernet:// prefix
|
|
145
|
-
const token = fernetUrl.startsWith('fernet://')
|
|
146
|
-
|
|
147
|
-
: fernetUrl;
|
|
148
|
-
|
|
151
|
+
const token = fernetUrl.startsWith('fernet://') ? fernetUrl.substring(9) : fernetUrl;
|
|
152
|
+
|
|
149
153
|
return await decryptFernetToken(token, secretKey);
|
|
150
154
|
}
|
|
151
155
|
|
|
@@ -210,7 +214,7 @@ function injectConsoleFilter(html) {
|
|
|
210
214
|
};
|
|
211
215
|
})();
|
|
212
216
|
</script>`;
|
|
213
|
-
|
|
217
|
+
|
|
214
218
|
// Inject the script right after <head> or at the beginning of <body>
|
|
215
219
|
if (html.includes('</head>')) {
|
|
216
220
|
return html.replace('</head>', consoleFilterScript + '</head>');
|
|
@@ -220,7 +224,7 @@ function injectConsoleFilter(html) {
|
|
|
220
224
|
return html.replace(bodyMatch[0], bodyMatch[0] + consoleFilterScript);
|
|
221
225
|
}
|
|
222
226
|
}
|
|
223
|
-
|
|
227
|
+
|
|
224
228
|
// Fallback: inject at the very beginning
|
|
225
229
|
return consoleFilterScript + html;
|
|
226
230
|
}
|
|
@@ -249,10 +253,19 @@ function normalizeDate(dateValue) {
|
|
|
249
253
|
function getEventTime(event, field) {
|
|
250
254
|
// Try common field name variations (including iCal and calendar.js formats)
|
|
251
255
|
const variations = {
|
|
252
|
-
start: [
|
|
253
|
-
|
|
256
|
+
start: [
|
|
257
|
+
'start',
|
|
258
|
+
'startDate',
|
|
259
|
+
'start_time',
|
|
260
|
+
'startTime',
|
|
261
|
+
'dtstart',
|
|
262
|
+
'start_date',
|
|
263
|
+
'dateStart',
|
|
264
|
+
'date_start',
|
|
265
|
+
],
|
|
266
|
+
end: ['end', 'endDate', 'end_time', 'endTime', 'dtend', 'end_date', 'dateEnd', 'date_end'],
|
|
254
267
|
};
|
|
255
|
-
|
|
268
|
+
|
|
256
269
|
const fieldNames = variations[field] || [field];
|
|
257
270
|
for (const name of fieldNames) {
|
|
258
271
|
if (event[name] !== undefined && event[name] !== null) {
|
|
@@ -266,7 +279,7 @@ function getEventTime(event, field) {
|
|
|
266
279
|
* Extract event title (handles various field name formats)
|
|
267
280
|
*/
|
|
268
281
|
function getEventTitle(event) {
|
|
269
|
-
return event.title || event.summary || event.name || '';
|
|
282
|
+
return event.title || event.summary || event.name || event.text || '';
|
|
270
283
|
}
|
|
271
284
|
|
|
272
285
|
/**
|
|
@@ -283,31 +296,298 @@ function getCalendarId(event) {
|
|
|
283
296
|
return event.calendar || event.calendarId || event.calendar_id || event.source || 'default';
|
|
284
297
|
}
|
|
285
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Extract event CLASS property (handles various field name formats)
|
|
301
|
+
* Returns null if not found (missing CLASS defaults to PRIVATE for privacy)
|
|
302
|
+
*/
|
|
303
|
+
function getEventClass(event) {
|
|
304
|
+
// Check various possible field names for CLASS property
|
|
305
|
+
// iCal format uses CLASS, but different parsers may use different field names
|
|
306
|
+
let classValue =
|
|
307
|
+
event.class || event.CLASS || event.classification || event['CLASS'] || event['class'] || null;
|
|
308
|
+
|
|
309
|
+
// Check nested properties structure (common in some iCal parsers)
|
|
310
|
+
if (!classValue && event.properties) {
|
|
311
|
+
classValue =
|
|
312
|
+
event.properties.CLASS ||
|
|
313
|
+
event.properties.class ||
|
|
314
|
+
event.properties.CLASS?.value ||
|
|
315
|
+
event.properties.class?.value ||
|
|
316
|
+
null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Check inside 'ical' field - open-web-calendar stores raw iCal data here
|
|
320
|
+
if (!classValue && event.ical) {
|
|
321
|
+
// ical might be a string containing raw iCal data or an object
|
|
322
|
+
if (typeof event.ical === 'string') {
|
|
323
|
+
// Parse CLASS from raw iCal string (e.g., "CLASS:PUBLIC" or "CLASS:PRIVATE")
|
|
324
|
+
const classMatch = event.ical.match(/CLASS[:\s]*([A-Za-z]+)/i);
|
|
325
|
+
if (classMatch) {
|
|
326
|
+
classValue = classMatch[1];
|
|
327
|
+
}
|
|
328
|
+
} else if (typeof event.ical === 'object') {
|
|
329
|
+
classValue = event.ical.CLASS || event.ical.class || null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// If no CLASS property found, return null (will be treated as PRIVATE by default)
|
|
334
|
+
if (classValue === null || classValue === undefined || classValue === '') {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Normalize to uppercase string for comparison
|
|
339
|
+
return String(classValue).toUpperCase();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Check if event is PUBLIC
|
|
344
|
+
* Only returns true if CLASS is explicitly set to 'PUBLIC'
|
|
345
|
+
* Missing CLASS (null) defaults to PRIVATE - only shows start/end time
|
|
346
|
+
* Any other value (PRIVATE, CONFIDENTIAL, etc.) also returns false
|
|
347
|
+
*/
|
|
348
|
+
function isPublicEvent(event) {
|
|
349
|
+
const eventClass = getEventClass(event);
|
|
350
|
+
// Only return true if CLASS is explicitly PUBLIC
|
|
351
|
+
// null (missing CLASS) defaults to PRIVATE - only show time, no details
|
|
352
|
+
// Any other value (PRIVATE, CONFIDENTIAL, etc.) also means non-PUBLIC
|
|
353
|
+
return eventClass === 'PUBLIC';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Remove all identifying information from an event object
|
|
358
|
+
* This function is used to sanitize non-PUBLIC events
|
|
359
|
+
*/
|
|
360
|
+
function removeAllEventInfo(event) {
|
|
361
|
+
// Remove ALL identifying information - only keep time fields
|
|
362
|
+
event.title = '';
|
|
363
|
+
event.summary = '';
|
|
364
|
+
event.name = '';
|
|
365
|
+
event.text = 'BUSY'; // open-web-calendar uses 'text' field for title
|
|
366
|
+
event.description = '';
|
|
367
|
+
event.desc = '';
|
|
368
|
+
event.location = '';
|
|
369
|
+
event.loc = '';
|
|
370
|
+
event.url = '';
|
|
371
|
+
event.link = '';
|
|
372
|
+
event.organizer = '';
|
|
373
|
+
event.attendees = '';
|
|
374
|
+
event.attendee = '';
|
|
375
|
+
event.participants = ''; // open-web-calendar uses 'participants'
|
|
376
|
+
event.label = '';
|
|
377
|
+
event.notes = '';
|
|
378
|
+
event.note = '';
|
|
379
|
+
event.comment = '';
|
|
380
|
+
event.comments = '';
|
|
381
|
+
|
|
382
|
+
// CRITICAL: Remove ical field which contains raw iCal data with all event details
|
|
383
|
+
// This prevents any sensitive information from being exposed in the response
|
|
384
|
+
event.ical = '';
|
|
385
|
+
|
|
386
|
+
// Remove other fields that might contain identifying information
|
|
387
|
+
event.uid = ''; // Remove unique identifier
|
|
388
|
+
event.id = ''; // Remove ID if present
|
|
389
|
+
event.categories = ''; // Remove categories
|
|
390
|
+
event.color = ''; // Remove color
|
|
391
|
+
event.css_classes = ''; // Remove CSS classes
|
|
392
|
+
event.owc = ''; // Remove open-web-calendar specific data
|
|
393
|
+
event.recurrence = ''; // Remove recurrence info
|
|
394
|
+
event.sequence = ''; // Remove sequence
|
|
395
|
+
event.type = ''; // Remove type
|
|
396
|
+
|
|
397
|
+
// Mark this event as sanitized
|
|
398
|
+
event._isNonPublic = true;
|
|
399
|
+
|
|
400
|
+
return event;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Sanitize non-PUBLIC events to only show start and end times
|
|
405
|
+
* Removes title, description, location, and other details
|
|
406
|
+
*/
|
|
407
|
+
function sanitizeNonPublicEvent(event) {
|
|
408
|
+
const eventClass = getEventClass(event);
|
|
409
|
+
const isPublic = isPublicEvent(event);
|
|
410
|
+
|
|
411
|
+
// If CLASS is PUBLIC, return event as-is (no sanitization)
|
|
412
|
+
if (isPublic) {
|
|
413
|
+
return event;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// For non-PUBLIC events (PRIVATE, CONFIDENTIAL, missing CLASS, or any other value), sanitize
|
|
417
|
+
// Create a sanitized version with only time fields
|
|
418
|
+
// Start with a copy of the event to preserve structure
|
|
419
|
+
const sanitized = { ...event };
|
|
420
|
+
|
|
421
|
+
// Remove ALL identifying information - only keep time fields
|
|
422
|
+
removeAllEventInfo(sanitized);
|
|
423
|
+
|
|
424
|
+
// Only preserve time fields, calendar ID, and CLASS property
|
|
425
|
+
// All other fields have been removed to prevent information leakage
|
|
426
|
+
|
|
427
|
+
return sanitized;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Parse user emails from environment secret
|
|
432
|
+
* USER_EMAILS secret should be comma-separated list of emails
|
|
433
|
+
* e.g., "user1@gmail.com,user2@example.com,user3@domain.org"
|
|
434
|
+
*/
|
|
435
|
+
function parseUserEmails(userEmailsSecret) {
|
|
436
|
+
if (!userEmailsSecret) {
|
|
437
|
+
return [];
|
|
438
|
+
}
|
|
439
|
+
return userEmailsSecret
|
|
440
|
+
.split(',')
|
|
441
|
+
.map(email => email.trim().toLowerCase())
|
|
442
|
+
.filter(email => email.length > 0);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Check if event is declined by the calendar owner (user responded "no")
|
|
447
|
+
* Returns true ONLY if the event is explicitly marked as declined by the calendar owner
|
|
448
|
+
*
|
|
449
|
+
* For calendar invitations:
|
|
450
|
+
* - When YOU decline an event, the event STATUS is still CONFIRMED
|
|
451
|
+
* - But YOUR PARTSTAT (participation status) becomes DECLINED
|
|
452
|
+
* - We need to check for PARTSTAT=DECLINED in YOUR ATTENDEE line specifically
|
|
453
|
+
* - Other attendees declining should NOT filter the event
|
|
454
|
+
*
|
|
455
|
+
* @param {Object} event - The event object to check
|
|
456
|
+
* @param {string[]} userEmails - Array of user email addresses to check for declined status
|
|
457
|
+
*/
|
|
458
|
+
function isEventDeclined(event, userEmails = []) {
|
|
459
|
+
const title = event.text || event.title || event.summary || '(no title)';
|
|
460
|
+
const cssClasses = event['css-classes'] || event.css_classes || event.cssClasses || [];
|
|
461
|
+
const eventType = event.type || '';
|
|
462
|
+
const ical = event.ical || '';
|
|
463
|
+
|
|
464
|
+
// Check css-classes for declined indicator
|
|
465
|
+
// open-web-calendar uses an array of classes like:
|
|
466
|
+
// ['event', 'STATUS-CONFIRMED', 'CLASS-PUBLIC', etc.]
|
|
467
|
+
// We look for 'STATUS-DECLINED' or 'PARTSTAT-DECLINED' class
|
|
468
|
+
if (Array.isArray(cssClasses)) {
|
|
469
|
+
for (const cls of cssClasses) {
|
|
470
|
+
const lowerCls = String(cls).toLowerCase();
|
|
471
|
+
// Only match exact status classes, not just containing 'declined'
|
|
472
|
+
if (lowerCls === 'status-declined' || lowerCls === 'partstat-declined') {
|
|
473
|
+
console.log('[Declined Check] FILTERED - css-class declined:', title);
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Check the event's own type/status field
|
|
480
|
+
if (eventType) {
|
|
481
|
+
const upperType = String(eventType).toUpperCase();
|
|
482
|
+
if (upperType === 'DECLINED' || upperType === 'CANCELLED') {
|
|
483
|
+
console.log('[Declined Check] FILTERED - type declined/cancelled:', title);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Check ical field for the USER's PARTSTAT=DECLINED
|
|
489
|
+
if (ical && typeof ical === 'string') {
|
|
490
|
+
// Check for STATUS:CANCELLED or STATUS:DECLINED (entire event cancelled)
|
|
491
|
+
const cancelledPattern = /^STATUS:(CANCELLED|DECLINED)/im;
|
|
492
|
+
if (cancelledPattern.test(ical)) {
|
|
493
|
+
console.log('[Declined Check] FILTERED - event STATUS cancelled:', title);
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Check for PARTSTAT=DECLINED in the user's ATTENDEE line
|
|
498
|
+
// Format: ATTENDEE;...;PARTSTAT=DECLINED;...:mailto:user@email.com
|
|
499
|
+
// We need to find ATTENDEE lines that contain both PARTSTAT=DECLINED AND a user email
|
|
500
|
+
|
|
501
|
+
// Split ical into lines and look for ATTENDEE lines
|
|
502
|
+
// Note: ATTENDEE lines can be folded (continuation lines start with space)
|
|
503
|
+
const icalLines = ical.replace(/\r\n /g, '').split(/\r?\n/);
|
|
504
|
+
|
|
505
|
+
for (const line of icalLines) {
|
|
506
|
+
if (line.startsWith('ATTENDEE')) {
|
|
507
|
+
// Check if this attendee line has PARTSTAT=DECLINED
|
|
508
|
+
if (/PARTSTAT=DECLINED/i.test(line)) {
|
|
509
|
+
// Check if this is for one of the user's emails
|
|
510
|
+
for (const userEmail of userEmails) {
|
|
511
|
+
if (line.toLowerCase().includes(userEmail.toLowerCase())) {
|
|
512
|
+
console.log(
|
|
513
|
+
'[Declined Check] FILTERED - user PARTSTAT=DECLINED:',
|
|
514
|
+
title,
|
|
515
|
+
'| email:',
|
|
516
|
+
userEmail
|
|
517
|
+
);
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Check if event has explicit status field indicating declined/cancelled
|
|
527
|
+
const eventStatus = event.event_status || event.status || null;
|
|
528
|
+
if (eventStatus) {
|
|
529
|
+
const normalizedStatus = String(eventStatus).toUpperCase();
|
|
530
|
+
if (normalizedStatus === 'CANCELLED' || normalizedStatus === 'DECLINED') {
|
|
531
|
+
console.log('[Declined Check] FILTERED - event_status:', title);
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Filter out declined events (events where user responded "no")
|
|
541
|
+
* @param {Array} events - Array of events to filter
|
|
542
|
+
* @param {string[]} userEmails - Array of user email addresses to check for declined status
|
|
543
|
+
*/
|
|
544
|
+
function filterDeclinedEvents(events, userEmails = []) {
|
|
545
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
546
|
+
return events;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
console.log('[Filter Declined] Processing', events.length, 'events');
|
|
550
|
+
|
|
551
|
+
const filteredEvents = events.filter(event => !isEventDeclined(event, userEmails));
|
|
552
|
+
|
|
553
|
+
console.log('[Filter Declined] After filtering:', filteredEvents.length, 'events remain');
|
|
554
|
+
|
|
555
|
+
return filteredEvents;
|
|
556
|
+
}
|
|
557
|
+
|
|
286
558
|
/**
|
|
287
559
|
* Merge consecutive events within the same calendar
|
|
288
560
|
* Events are consecutive if event1.end === event2.start (exact match)
|
|
561
|
+
* @param {Array} events - Array of events to process
|
|
562
|
+
* @param {string[]} userEmails - Array of user email addresses to check for declined status
|
|
289
563
|
*/
|
|
290
|
-
function mergeConsecutiveEvents(events) {
|
|
564
|
+
function mergeConsecutiveEvents(events, userEmails = []) {
|
|
291
565
|
if (!Array.isArray(events) || events.length === 0) {
|
|
292
566
|
return events;
|
|
293
567
|
}
|
|
294
|
-
|
|
568
|
+
|
|
569
|
+
// Filter out declined events first
|
|
570
|
+
const filteredEvents = filterDeclinedEvents(events, userEmails);
|
|
571
|
+
|
|
572
|
+
// Sanitize non-PUBLIC events
|
|
573
|
+
const sanitizedEvents = filteredEvents.map(event => sanitizeNonPublicEvent(event));
|
|
574
|
+
|
|
295
575
|
// Group events by calendar identifier
|
|
296
576
|
const eventsByCalendar = {};
|
|
297
|
-
for (const event of
|
|
577
|
+
for (const event of sanitizedEvents) {
|
|
298
578
|
const calendarId = getCalendarId(event);
|
|
299
579
|
if (!eventsByCalendar[calendarId]) {
|
|
300
580
|
eventsByCalendar[calendarId] = [];
|
|
301
581
|
}
|
|
302
582
|
eventsByCalendar[calendarId].push(event);
|
|
303
583
|
}
|
|
304
|
-
|
|
584
|
+
|
|
305
585
|
const mergedEvents = [];
|
|
306
|
-
|
|
586
|
+
|
|
307
587
|
// Process each calendar group separately
|
|
308
588
|
for (const calendarId in eventsByCalendar) {
|
|
309
589
|
const calendarEvents = eventsByCalendar[calendarId];
|
|
310
|
-
|
|
590
|
+
|
|
311
591
|
// Sort events by start time
|
|
312
592
|
calendarEvents.sort((a, b) => {
|
|
313
593
|
const startA = getEventTime(a, 'start');
|
|
@@ -315,15 +595,16 @@ function mergeConsecutiveEvents(events) {
|
|
|
315
595
|
if (!startA || !startB) return 0;
|
|
316
596
|
return new Date(startA) - new Date(startB);
|
|
317
597
|
});
|
|
318
|
-
|
|
598
|
+
|
|
319
599
|
// Merge consecutive events
|
|
320
600
|
let currentMerge = null;
|
|
321
|
-
|
|
601
|
+
|
|
322
602
|
for (let i = 0; i < calendarEvents.length; i++) {
|
|
323
603
|
const event = calendarEvents[i];
|
|
604
|
+
// Event is already sanitized, but we need to preserve it during merge
|
|
324
605
|
const startTime = getEventTime(event, 'start');
|
|
325
606
|
const endTime = getEventTime(event, 'end');
|
|
326
|
-
|
|
607
|
+
|
|
327
608
|
if (!startTime || !endTime) {
|
|
328
609
|
// If event is missing time info, add as-is
|
|
329
610
|
if (currentMerge) {
|
|
@@ -333,7 +614,7 @@ function mergeConsecutiveEvents(events) {
|
|
|
333
614
|
mergedEvents.push(event);
|
|
334
615
|
continue;
|
|
335
616
|
}
|
|
336
|
-
|
|
617
|
+
|
|
337
618
|
if (currentMerge === null) {
|
|
338
619
|
// Start a new merge group
|
|
339
620
|
currentMerge = { ...event };
|
|
@@ -343,50 +624,71 @@ function mergeConsecutiveEvents(events) {
|
|
|
343
624
|
// Normalize dates for comparison (handle different date formats)
|
|
344
625
|
const normalizedEndTime = normalizeDate(currentEndTime);
|
|
345
626
|
const normalizedStartTime = normalizeDate(startTime);
|
|
346
|
-
|
|
627
|
+
|
|
347
628
|
if (normalizedEndTime && normalizedStartTime && normalizedEndTime === normalizedStartTime) {
|
|
348
|
-
//
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
629
|
+
// Check if either event is non-PUBLIC - if so, keep titles empty
|
|
630
|
+
const currentIsNonPublic = currentMerge._isNonPublic || !isPublicEvent(currentMerge);
|
|
631
|
+
const eventIsNonPublic = event._isNonPublic || !isPublicEvent(event);
|
|
632
|
+
|
|
633
|
+
if (currentIsNonPublic || eventIsNonPublic) {
|
|
634
|
+
// At least one event is non-PUBLIC, remove ALL identifying information
|
|
635
|
+
removeAllEventInfo(currentMerge);
|
|
636
|
+
} else {
|
|
637
|
+
// Both are PUBLIC, merge titles normally
|
|
638
|
+
const currentTitle = getEventTitle(currentMerge);
|
|
639
|
+
const eventTitle = getEventTitle(event);
|
|
640
|
+
const combinedTitle =
|
|
641
|
+
currentTitle && eventTitle
|
|
642
|
+
? `${currentTitle} + ${eventTitle}`
|
|
643
|
+
: currentTitle || eventTitle;
|
|
644
|
+
|
|
645
|
+
// Update title field (try common variations)
|
|
646
|
+
if (currentMerge.title !== undefined) currentMerge.title = combinedTitle;
|
|
647
|
+
if (currentMerge.summary !== undefined) currentMerge.summary = combinedTitle;
|
|
648
|
+
if (currentMerge.name !== undefined) currentMerge.name = combinedTitle;
|
|
649
|
+
if (!currentMerge.title && !currentMerge.summary && !currentMerge.name) {
|
|
650
|
+
currentMerge.title = combinedTitle;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Combine descriptions
|
|
654
|
+
const currentDesc = getEventDescription(currentMerge);
|
|
655
|
+
const eventDesc = getEventDescription(event);
|
|
656
|
+
if (currentDesc || eventDesc) {
|
|
657
|
+
const combinedDesc =
|
|
658
|
+
currentDesc && eventDesc
|
|
659
|
+
? `${currentDesc}\n\n${eventDesc}`
|
|
660
|
+
: currentDesc || eventDesc;
|
|
661
|
+
|
|
662
|
+
if (currentMerge.description !== undefined) currentMerge.description = combinedDesc;
|
|
663
|
+
if (currentMerge.desc !== undefined) currentMerge.desc = combinedDesc;
|
|
664
|
+
if (!currentMerge.description && !currentMerge.desc) {
|
|
665
|
+
currentMerge.description = combinedDesc;
|
|
666
|
+
}
|
|
375
667
|
}
|
|
376
668
|
}
|
|
377
|
-
|
|
669
|
+
|
|
378
670
|
// Update end time - preserve original field name format
|
|
379
|
-
const originalEndField =
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
671
|
+
const originalEndField =
|
|
672
|
+
getEventTime(currentMerge, 'end') !== null
|
|
673
|
+
? currentMerge.end !== undefined
|
|
674
|
+
? 'end'
|
|
675
|
+
: currentMerge.endDate !== undefined
|
|
676
|
+
? 'endDate'
|
|
677
|
+
: currentMerge.end_time !== undefined
|
|
678
|
+
? 'end_time'
|
|
679
|
+
: currentMerge.endTime !== undefined
|
|
680
|
+
? 'endTime'
|
|
681
|
+
: currentMerge.dtend !== undefined
|
|
682
|
+
? 'dtend'
|
|
683
|
+
: currentMerge.end_date !== undefined
|
|
684
|
+
? 'end_date'
|
|
685
|
+
: currentMerge.dateEnd !== undefined
|
|
686
|
+
? 'dateEnd'
|
|
687
|
+
: currentMerge.date_end !== undefined
|
|
688
|
+
? 'date_end'
|
|
689
|
+
: 'end'
|
|
690
|
+
: 'end';
|
|
691
|
+
|
|
390
692
|
// Update all possible end time fields to ensure compatibility
|
|
391
693
|
if (currentMerge.end !== undefined) currentMerge.end = endTime;
|
|
392
694
|
if (currentMerge.endDate !== undefined) currentMerge.endDate = endTime;
|
|
@@ -396,9 +698,16 @@ function mergeConsecutiveEvents(events) {
|
|
|
396
698
|
if (currentMerge.end_date !== undefined) currentMerge.end_date = endTime;
|
|
397
699
|
if (currentMerge.dateEnd !== undefined) currentMerge.dateEnd = endTime;
|
|
398
700
|
if (currentMerge.date_end !== undefined) currentMerge.date_end = endTime;
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
701
|
+
if (
|
|
702
|
+
!currentMerge.end &&
|
|
703
|
+
!currentMerge.endDate &&
|
|
704
|
+
!currentMerge.end_time &&
|
|
705
|
+
!currentMerge.endTime &&
|
|
706
|
+
!currentMerge.dtend &&
|
|
707
|
+
!currentMerge.end_date &&
|
|
708
|
+
!currentMerge.dateEnd &&
|
|
709
|
+
!currentMerge.date_end
|
|
710
|
+
) {
|
|
402
711
|
currentMerge.end = endTime;
|
|
403
712
|
}
|
|
404
713
|
} else {
|
|
@@ -408,56 +717,111 @@ function mergeConsecutiveEvents(events) {
|
|
|
408
717
|
}
|
|
409
718
|
}
|
|
410
719
|
}
|
|
411
|
-
|
|
720
|
+
|
|
412
721
|
// Add the last merge group if any
|
|
413
722
|
if (currentMerge !== null) {
|
|
414
723
|
mergedEvents.push(currentMerge);
|
|
415
724
|
}
|
|
416
725
|
}
|
|
417
|
-
|
|
726
|
+
|
|
418
727
|
return mergedEvents;
|
|
419
728
|
}
|
|
420
729
|
|
|
730
|
+
/**
|
|
731
|
+
* Remove calendar name from calendar object
|
|
732
|
+
* @param {Object} calendar - The calendar object
|
|
733
|
+
*/
|
|
734
|
+
function removeCalendarName(calendar) {
|
|
735
|
+
if (!calendar || typeof calendar !== 'object') {
|
|
736
|
+
return calendar;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Create a copy of the calendar object without the name field
|
|
740
|
+
const sanitized = { ...calendar };
|
|
741
|
+
delete sanitized.name;
|
|
742
|
+
delete sanitized.calendarName;
|
|
743
|
+
delete sanitized.title;
|
|
744
|
+
|
|
745
|
+
return sanitized;
|
|
746
|
+
}
|
|
747
|
+
|
|
421
748
|
/**
|
|
422
749
|
* Process calendar events JSON and merge consecutive events
|
|
423
750
|
*/
|
|
424
|
-
|
|
751
|
+
/**
|
|
752
|
+
* Process calendar events JSON and merge consecutive events
|
|
753
|
+
* @param {Object|Array} jsonData - The calendar events data
|
|
754
|
+
* @param {string[]} userEmails - Array of user email addresses to check for declined status
|
|
755
|
+
*/
|
|
756
|
+
function processCalendarEventsJson(jsonData, userEmails = []) {
|
|
757
|
+
// Debug: Log first event structure to understand data format
|
|
758
|
+
// TODO: Remove or make conditional in production
|
|
759
|
+
try {
|
|
760
|
+
let firstEvent = null;
|
|
761
|
+
if (Array.isArray(jsonData) && jsonData.length > 0) {
|
|
762
|
+
firstEvent = jsonData[0];
|
|
763
|
+
} else if (jsonData && typeof jsonData === 'object') {
|
|
764
|
+
if (Array.isArray(jsonData.events) && jsonData.events.length > 0) {
|
|
765
|
+
firstEvent = jsonData.events[0];
|
|
766
|
+
} else if (Array.isArray(jsonData.calendars) && jsonData.calendars.length > 0) {
|
|
767
|
+
const firstCalendar = jsonData.calendars[0];
|
|
768
|
+
if (Array.isArray(firstCalendar.events) && firstCalendar.events.length > 0) {
|
|
769
|
+
firstEvent = firstCalendar.events[0];
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Debug logging removed - CLASS detection is working
|
|
775
|
+
} catch (e) {
|
|
776
|
+
// Ignore errors
|
|
777
|
+
}
|
|
778
|
+
|
|
425
779
|
if (Array.isArray(jsonData)) {
|
|
426
780
|
// Format: [{event1}, {event2}, ...]
|
|
427
|
-
return mergeConsecutiveEvents(jsonData);
|
|
781
|
+
return mergeConsecutiveEvents(jsonData, userEmails);
|
|
428
782
|
} else if (jsonData && typeof jsonData === 'object') {
|
|
429
783
|
// Check for nested structures
|
|
430
784
|
if (Array.isArray(jsonData.events)) {
|
|
431
785
|
// Format: {events: [...], ...}
|
|
786
|
+
// Remove calendar name from root level
|
|
787
|
+
const sanitizedRoot = removeCalendarName(jsonData);
|
|
432
788
|
return {
|
|
433
|
-
...
|
|
434
|
-
events: mergeConsecutiveEvents(jsonData.events)
|
|
789
|
+
...sanitizedRoot,
|
|
790
|
+
events: mergeConsecutiveEvents(jsonData.events, userEmails),
|
|
435
791
|
};
|
|
436
792
|
} else if (Array.isArray(jsonData.calendars)) {
|
|
437
793
|
// Format: {calendars: [{events: [...]}, ...], ...}
|
|
794
|
+
// Remove calendar name from root level as well
|
|
795
|
+
const sanitizedRoot = removeCalendarName(jsonData);
|
|
796
|
+
|
|
438
797
|
return {
|
|
439
|
-
...
|
|
798
|
+
...sanitizedRoot,
|
|
440
799
|
calendars: jsonData.calendars.map(calendar => {
|
|
441
|
-
|
|
800
|
+
// Remove calendar name from calendar object
|
|
801
|
+
const sanitizedCalendar = removeCalendarName(calendar);
|
|
802
|
+
|
|
803
|
+
if (Array.isArray(sanitizedCalendar.events)) {
|
|
442
804
|
return {
|
|
443
|
-
...
|
|
444
|
-
events: mergeConsecutiveEvents(
|
|
805
|
+
...sanitizedCalendar,
|
|
806
|
+
events: mergeConsecutiveEvents(sanitizedCalendar.events, userEmails),
|
|
445
807
|
};
|
|
446
808
|
}
|
|
447
|
-
return
|
|
448
|
-
})
|
|
809
|
+
return sanitizedCalendar;
|
|
810
|
+
}),
|
|
449
811
|
};
|
|
450
812
|
} else if (Array.isArray(jsonData.data)) {
|
|
451
813
|
// Format: {data: [...], ...}
|
|
814
|
+
const sanitizedRoot = removeCalendarName(jsonData);
|
|
452
815
|
return {
|
|
453
|
-
...
|
|
454
|
-
data: mergeConsecutiveEvents(jsonData.data)
|
|
816
|
+
...sanitizedRoot,
|
|
817
|
+
data: mergeConsecutiveEvents(jsonData.data, userEmails),
|
|
455
818
|
};
|
|
456
819
|
} else if (Array.isArray(jsonData.items)) {
|
|
457
820
|
// Format: {items: [...], ...}
|
|
821
|
+
const sanitizedRoot = removeCalendarName(jsonData);
|
|
458
822
|
return {
|
|
459
|
-
...
|
|
460
|
-
items: mergeConsecutiveEvents(jsonData.items)
|
|
823
|
+
...sanitizedRoot,
|
|
824
|
+
items: mergeConsecutiveEvents(jsonData.items, userEmails),
|
|
461
825
|
};
|
|
462
826
|
}
|
|
463
827
|
// Unknown structure, try to find any array of events
|
|
@@ -466,34 +830,57 @@ function processCalendarEventsJson(jsonData) {
|
|
|
466
830
|
// Check if it looks like events (has time fields)
|
|
467
831
|
const firstItem = jsonData[key][0];
|
|
468
832
|
if (firstItem && typeof firstItem === 'object') {
|
|
469
|
-
const hasTimeField =
|
|
470
|
-
|
|
833
|
+
const hasTimeField =
|
|
834
|
+
getEventTime(firstItem, 'start') !== null || getEventTime(firstItem, 'end') !== null;
|
|
471
835
|
if (hasTimeField) {
|
|
836
|
+
const sanitizedRoot = removeCalendarName(jsonData);
|
|
472
837
|
return {
|
|
473
|
-
...
|
|
474
|
-
[key]: mergeConsecutiveEvents(jsonData[key])
|
|
838
|
+
...sanitizedRoot,
|
|
839
|
+
[key]: mergeConsecutiveEvents(jsonData[key], userEmails),
|
|
475
840
|
};
|
|
476
841
|
}
|
|
477
842
|
}
|
|
478
843
|
}
|
|
479
844
|
}
|
|
480
845
|
}
|
|
481
|
-
|
|
482
|
-
// Unknown format, return
|
|
846
|
+
|
|
847
|
+
// Unknown format, remove calendar name if present and return
|
|
848
|
+
if (jsonData && typeof jsonData === 'object') {
|
|
849
|
+
return removeCalendarName(jsonData);
|
|
850
|
+
}
|
|
483
851
|
return jsonData;
|
|
484
852
|
}
|
|
485
853
|
|
|
486
854
|
/**
|
|
487
855
|
* Sanitize response body to hide calendar URLs in error messages
|
|
488
856
|
* Only sanitizes HTML and JSON responses to avoid breaking JavaScript code
|
|
857
|
+
* @param {Response} response - The response to sanitize
|
|
858
|
+
* @param {string} pathname - The request pathname
|
|
859
|
+
* @param {string[]} userEmails - Array of user email addresses to check for declined status
|
|
489
860
|
*/
|
|
490
|
-
async function sanitizeResponse(response, pathname) {
|
|
861
|
+
async function sanitizeResponse(response, pathname, userEmails = []) {
|
|
491
862
|
const contentType = response.headers.get('content-type') || '';
|
|
492
|
-
|
|
863
|
+
|
|
864
|
+
// Block ICS/calendar file downloads - check content type and pathname
|
|
865
|
+
if (
|
|
866
|
+
contentType.includes('text/calendar') ||
|
|
867
|
+
contentType.includes('application/ics') ||
|
|
868
|
+
pathname.endsWith('.ics') ||
|
|
869
|
+
pathname.endsWith('.ICAL') ||
|
|
870
|
+
pathname.endsWith('.iCal')
|
|
871
|
+
) {
|
|
872
|
+
return new Response('Calendar file download is not allowed', {
|
|
873
|
+
status: 403,
|
|
874
|
+
headers: {
|
|
875
|
+
'Content-Type': 'text/plain',
|
|
876
|
+
'Access-Control-Allow-Origin': '*',
|
|
877
|
+
},
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
493
881
|
// Only sanitize HTML and JSON responses (error messages)
|
|
494
882
|
// Skip JavaScript files to avoid breaking code
|
|
495
|
-
if (!contentType.includes('text/html') &&
|
|
496
|
-
!contentType.includes('application/json')) {
|
|
883
|
+
if (!contentType.includes('text/html') && !contentType.includes('application/json')) {
|
|
497
884
|
// For non-HTML/JSON responses (including JavaScript), just add CORS header and return
|
|
498
885
|
const responseHeaders = new Headers(response.headers);
|
|
499
886
|
responseHeaders.set('Access-Control-Allow-Origin', '*');
|
|
@@ -503,24 +890,25 @@ async function sanitizeResponse(response, pathname) {
|
|
|
503
890
|
headers: responseHeaders,
|
|
504
891
|
});
|
|
505
892
|
}
|
|
506
|
-
|
|
893
|
+
|
|
507
894
|
const body = await response.text();
|
|
508
|
-
|
|
895
|
+
|
|
509
896
|
let sanitizedBody = body;
|
|
510
|
-
|
|
897
|
+
|
|
511
898
|
// For JSON responses, check if it's a calendar events endpoint
|
|
512
899
|
if (contentType.includes('application/json')) {
|
|
513
900
|
// Check for calendar events endpoints - open-web-calendar uses /calendar.json
|
|
514
901
|
// Also check for any .json file that might contain calendar events
|
|
515
|
-
const isCalendarEventsEndpoint =
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
902
|
+
const isCalendarEventsEndpoint =
|
|
903
|
+
pathname === '/calendar.events.json' ||
|
|
904
|
+
pathname === '/calendar.json' ||
|
|
905
|
+
pathname.endsWith('.events.json') ||
|
|
906
|
+
pathname.endsWith('.json');
|
|
907
|
+
|
|
520
908
|
if (isCalendarEventsEndpoint) {
|
|
521
909
|
try {
|
|
522
910
|
const jsonData = JSON.parse(body);
|
|
523
|
-
const processedData = processCalendarEventsJson(jsonData);
|
|
911
|
+
const processedData = processCalendarEventsJson(jsonData, userEmails);
|
|
524
912
|
sanitizedBody = JSON.stringify(processedData);
|
|
525
913
|
} catch (error) {
|
|
526
914
|
// If JSON parsing fails, continue with original body
|
|
@@ -528,12 +916,12 @@ async function sanitizeResponse(response, pathname) {
|
|
|
528
916
|
}
|
|
529
917
|
}
|
|
530
918
|
}
|
|
531
|
-
|
|
919
|
+
|
|
532
920
|
// For HTML responses, inject console filtering
|
|
533
921
|
if (contentType.includes('text/html')) {
|
|
534
922
|
sanitizedBody = injectConsoleFilter(sanitizedBody);
|
|
535
923
|
}
|
|
536
|
-
|
|
924
|
+
|
|
537
925
|
// Patterns to detect and sanitize calendar URLs
|
|
538
926
|
// Only match complete URLs, not code fragments
|
|
539
927
|
const urlPatterns = [
|
|
@@ -542,14 +930,14 @@ async function sanitizeResponse(response, pathname) {
|
|
|
542
930
|
// Only match %40 when it's part of a URL (followed by domain-like pattern)
|
|
543
931
|
/%40[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}[^\s"']*/gi,
|
|
544
932
|
];
|
|
545
|
-
|
|
933
|
+
|
|
546
934
|
urlPatterns.forEach(pattern => {
|
|
547
935
|
sanitizedBody = sanitizedBody.replace(pattern, '[Calendar URL hidden]');
|
|
548
936
|
});
|
|
549
|
-
|
|
937
|
+
|
|
550
938
|
const responseHeaders = new Headers(response.headers);
|
|
551
939
|
responseHeaders.set('Access-Control-Allow-Origin', '*');
|
|
552
|
-
|
|
940
|
+
|
|
553
941
|
return new Response(sanitizedBody, {
|
|
554
942
|
status: response.status,
|
|
555
943
|
statusText: response.statusText,
|
|
@@ -561,88 +949,105 @@ export default {
|
|
|
561
949
|
async fetch(request, env) {
|
|
562
950
|
try {
|
|
563
951
|
const url = new URL(request.url);
|
|
564
|
-
|
|
952
|
+
|
|
565
953
|
// ENCRYPTION_METHOD is hardcoded to 'fernet'
|
|
566
954
|
const ENCRYPTION_METHOD = 'fernet';
|
|
567
|
-
|
|
955
|
+
|
|
568
956
|
const encryptionKey = env.ENCRYPTION_KEY;
|
|
569
957
|
const calendarUrlSecret = env.CALENDAR_URL; // Read from Cloudflare Worker secret
|
|
570
|
-
|
|
958
|
+
|
|
959
|
+
// Parse user emails from secret for declined event filtering
|
|
960
|
+
// USER_EMAILS secret should be comma-separated list of emails
|
|
961
|
+
const userEmails = parseUserEmails(env.USER_EMAILS);
|
|
962
|
+
|
|
571
963
|
if (!calendarUrlSecret) {
|
|
572
|
-
return new Response('CALENDAR_URL not configured in Cloudflare Worker secrets', {
|
|
964
|
+
return new Response('CALENDAR_URL not configured in Cloudflare Worker secrets', {
|
|
573
965
|
status: 500,
|
|
574
|
-
headers: { 'Content-Type': 'text/plain' }
|
|
966
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
575
967
|
});
|
|
576
968
|
}
|
|
577
|
-
|
|
969
|
+
|
|
578
970
|
// Parse calendar URLs from secret (comma-separated, can be plain or fernet://)
|
|
579
971
|
const calendarUrlsFromSecret = calendarUrlSecret
|
|
580
972
|
.split(',')
|
|
581
973
|
.map(s => s.trim())
|
|
582
974
|
.filter(s => s);
|
|
583
|
-
|
|
975
|
+
|
|
584
976
|
// Use calendar URLs from secret (they may be fernet:// encrypted or plain)
|
|
585
977
|
const calendarUrls = calendarUrlsFromSecret;
|
|
586
|
-
|
|
978
|
+
|
|
587
979
|
// Check if any of the URLs are fernet:// encrypted
|
|
588
980
|
const hasFernetUrls = calendarUrls.some(url => url.startsWith('fernet://'));
|
|
589
|
-
|
|
981
|
+
|
|
590
982
|
// If we have fernet:// URLs, we need ENCRYPTION_KEY
|
|
591
983
|
if (hasFernetUrls && !encryptionKey) {
|
|
592
|
-
return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
|
|
984
|
+
return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
|
|
593
985
|
status: 500,
|
|
594
|
-
headers: { 'Content-Type': 'text/plain' }
|
|
986
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
595
987
|
});
|
|
596
988
|
}
|
|
597
|
-
|
|
989
|
+
|
|
598
990
|
// Get the pathname to determine request type
|
|
599
991
|
const pathname = url.pathname;
|
|
600
|
-
|
|
992
|
+
|
|
993
|
+
// Block ICS file downloads - prevent access to raw calendar files
|
|
994
|
+
if (pathname.endsWith('.ics') || pathname.endsWith('.ICAL') || pathname.endsWith('.iCal')) {
|
|
995
|
+
return new Response('Calendar file download is not allowed', {
|
|
996
|
+
status: 403,
|
|
997
|
+
headers: {
|
|
998
|
+
'Content-Type': 'text/plain',
|
|
999
|
+
'Access-Control-Allow-Origin': '*',
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
601
1004
|
// Check if this is the main calendar page request
|
|
602
|
-
const isMainCalendarPage =
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1005
|
+
const isMainCalendarPage =
|
|
1006
|
+
pathname === '/' ||
|
|
1007
|
+
pathname === '/calendar.html' ||
|
|
1008
|
+
pathname.endsWith('/calendar.html') ||
|
|
1009
|
+
pathname === '';
|
|
1010
|
+
|
|
607
1011
|
// Check if this is an API endpoint that needs calendar URLs
|
|
608
1012
|
// This includes /srcdoc, /calendar.events.json, /calendar.json, etc.
|
|
609
|
-
const isApiEndpoint =
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1013
|
+
const isApiEndpoint =
|
|
1014
|
+
pathname === '/srcdoc' ||
|
|
1015
|
+
pathname.startsWith('/srcdoc') ||
|
|
1016
|
+
pathname === '/calendar.events.json' ||
|
|
1017
|
+
pathname === '/calendar.json' ||
|
|
1018
|
+
pathname.endsWith('.events.json') ||
|
|
1019
|
+
pathname.endsWith('.json');
|
|
1020
|
+
|
|
616
1021
|
// For API endpoints like /srcdoc, always use calendar URLs from secret
|
|
617
1022
|
if (isApiEndpoint) {
|
|
618
1023
|
// Build the target URL
|
|
619
1024
|
const targetUrl = new URL(`https://open-web-calendar.hosted.quelltext.eu${pathname}`);
|
|
620
|
-
|
|
1025
|
+
|
|
621
1026
|
// Copy all query parameters except 'url'
|
|
622
1027
|
for (const [key, value] of url.searchParams.entries()) {
|
|
623
1028
|
if (key !== 'url') {
|
|
624
1029
|
targetUrl.searchParams.append(key, value);
|
|
625
1030
|
}
|
|
626
1031
|
}
|
|
627
|
-
|
|
1032
|
+
|
|
628
1033
|
// Decrypt and add calendar URLs from secret
|
|
629
1034
|
// ENCRYPTION_METHOD is hardcoded to 'fernet'
|
|
630
1035
|
const decryptedUrls = [];
|
|
631
1036
|
for (const urlParam of calendarUrls) {
|
|
632
1037
|
if (urlParam.startsWith('fernet://')) {
|
|
633
1038
|
if (!encryptionKey) {
|
|
634
|
-
return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
|
|
1039
|
+
return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
|
|
635
1040
|
status: 500,
|
|
636
|
-
headers: { 'Content-Type': 'text/plain' }
|
|
1041
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
637
1042
|
});
|
|
638
1043
|
}
|
|
639
1044
|
try {
|
|
640
1045
|
const decrypted = await decryptFernetUrl(urlParam, encryptionKey);
|
|
641
1046
|
decryptedUrls.push(decrypted);
|
|
642
1047
|
} catch (error) {
|
|
643
|
-
return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
|
|
1048
|
+
return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
|
|
644
1049
|
status: 500,
|
|
645
|
-
headers: { 'Content-Type': 'text/plain' }
|
|
1050
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
646
1051
|
});
|
|
647
1052
|
}
|
|
648
1053
|
} else {
|
|
@@ -650,29 +1055,33 @@ export default {
|
|
|
650
1055
|
decryptedUrls.push(urlParam);
|
|
651
1056
|
}
|
|
652
1057
|
}
|
|
653
|
-
|
|
1058
|
+
|
|
654
1059
|
// Add decrypted URLs
|
|
655
1060
|
for (const decryptedUrl of decryptedUrls) {
|
|
656
1061
|
targetUrl.searchParams.append('url', decryptedUrl);
|
|
657
1062
|
}
|
|
658
|
-
|
|
1063
|
+
|
|
659
1064
|
// Forward all headers from the original request
|
|
660
1065
|
const requestHeaders = new Headers();
|
|
661
1066
|
request.headers.forEach((value, key) => {
|
|
662
|
-
if (
|
|
1067
|
+
if (
|
|
1068
|
+
key.toLowerCase() !== 'host' &&
|
|
1069
|
+
key.toLowerCase() !== 'cf-ray' &&
|
|
1070
|
+
key.toLowerCase() !== 'cf-connecting-ip'
|
|
1071
|
+
) {
|
|
663
1072
|
requestHeaders.set(key, value);
|
|
664
1073
|
}
|
|
665
1074
|
});
|
|
666
|
-
|
|
1075
|
+
|
|
667
1076
|
const response = await fetch(targetUrl.toString(), {
|
|
668
1077
|
headers: requestHeaders,
|
|
669
1078
|
});
|
|
670
|
-
|
|
1079
|
+
|
|
671
1080
|
// Sanitize response to hide calendar URLs in error messages
|
|
672
|
-
// Pass pathname for calendar events processing
|
|
673
|
-
return await sanitizeResponse(response, pathname);
|
|
1081
|
+
// Pass pathname for calendar events processing and user emails for declined filtering
|
|
1082
|
+
return await sanitizeResponse(response, pathname, userEmails);
|
|
674
1083
|
}
|
|
675
|
-
|
|
1084
|
+
|
|
676
1085
|
// Handle main calendar page requests - always add calendar URLs from secret
|
|
677
1086
|
if (isMainCalendarPage) {
|
|
678
1087
|
// Decrypt calendar URLs from secret
|
|
@@ -681,18 +1090,18 @@ export default {
|
|
|
681
1090
|
for (const urlParam of calendarUrls) {
|
|
682
1091
|
if (urlParam.startsWith('fernet://')) {
|
|
683
1092
|
if (!encryptionKey) {
|
|
684
|
-
return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
|
|
1093
|
+
return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
|
|
685
1094
|
status: 500,
|
|
686
|
-
headers: { 'Content-Type': 'text/plain' }
|
|
1095
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
687
1096
|
});
|
|
688
1097
|
}
|
|
689
1098
|
try {
|
|
690
1099
|
const decrypted = await decryptFernetUrl(urlParam, encryptionKey);
|
|
691
1100
|
decryptedUrls.push(decrypted);
|
|
692
1101
|
} catch (error) {
|
|
693
|
-
return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
|
|
1102
|
+
return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
|
|
694
1103
|
status: 500,
|
|
695
|
-
headers: { 'Content-Type': 'text/plain' }
|
|
1104
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
696
1105
|
});
|
|
697
1106
|
}
|
|
698
1107
|
} else {
|
|
@@ -700,62 +1109,68 @@ export default {
|
|
|
700
1109
|
decryptedUrls.push(urlParam);
|
|
701
1110
|
}
|
|
702
1111
|
}
|
|
703
|
-
|
|
1112
|
+
|
|
704
1113
|
// Build the open-web-calendar URL with decrypted URLs
|
|
705
1114
|
const calendarUrl = new URL('https://open-web-calendar.hosted.quelltext.eu/calendar.html');
|
|
706
|
-
|
|
1115
|
+
|
|
707
1116
|
// Copy all query parameters except 'url' (calendar URLs come from secret)
|
|
708
1117
|
for (const [key, value] of url.searchParams.entries()) {
|
|
709
1118
|
if (key !== 'url') {
|
|
710
1119
|
calendarUrl.searchParams.append(key, value);
|
|
711
1120
|
}
|
|
712
1121
|
}
|
|
713
|
-
|
|
1122
|
+
|
|
714
1123
|
// Add decrypted URLs from secret
|
|
715
1124
|
for (const decryptedUrl of decryptedUrls) {
|
|
716
1125
|
calendarUrl.searchParams.append('url', decryptedUrl);
|
|
717
1126
|
}
|
|
718
|
-
|
|
1127
|
+
|
|
719
1128
|
// Fetch from open-web-calendar
|
|
720
1129
|
const response = await fetch(calendarUrl.toString(), {
|
|
721
1130
|
headers: {
|
|
722
1131
|
'User-Agent': request.headers.get('User-Agent') || 'Cloudflare-Worker',
|
|
723
1132
|
},
|
|
724
1133
|
});
|
|
725
|
-
|
|
1134
|
+
|
|
726
1135
|
// Sanitize response to hide calendar URLs in error messages
|
|
727
1136
|
// Pass pathname for calendar events processing
|
|
728
|
-
return await sanitizeResponse(response, pathname);
|
|
1137
|
+
return await sanitizeResponse(response, pathname, userEmails);
|
|
729
1138
|
}
|
|
730
|
-
|
|
1139
|
+
|
|
731
1140
|
// For all other requests (static resources, etc.), proxy directly
|
|
732
1141
|
let targetPath = pathname;
|
|
733
1142
|
if (pathname === '/' || pathname === '') {
|
|
734
1143
|
targetPath = '/calendar.html';
|
|
735
1144
|
}
|
|
736
|
-
|
|
737
|
-
const targetUrl = new URL(
|
|
738
|
-
|
|
1145
|
+
|
|
1146
|
+
const targetUrl = new URL(
|
|
1147
|
+
`https://open-web-calendar.hosted.quelltext.eu${targetPath}${url.search}`
|
|
1148
|
+
);
|
|
1149
|
+
|
|
739
1150
|
// Forward all headers from the original request
|
|
740
1151
|
const requestHeaders = new Headers();
|
|
741
1152
|
request.headers.forEach((value, key) => {
|
|
742
1153
|
// Skip certain headers that shouldn't be forwarded
|
|
743
|
-
if (
|
|
1154
|
+
if (
|
|
1155
|
+
key.toLowerCase() !== 'host' &&
|
|
1156
|
+
key.toLowerCase() !== 'cf-ray' &&
|
|
1157
|
+
key.toLowerCase() !== 'cf-connecting-ip'
|
|
1158
|
+
) {
|
|
744
1159
|
requestHeaders.set(key, value);
|
|
745
1160
|
}
|
|
746
1161
|
});
|
|
747
|
-
|
|
1162
|
+
|
|
748
1163
|
const response = await fetch(targetUrl.toString(), {
|
|
749
1164
|
headers: requestHeaders,
|
|
750
1165
|
});
|
|
751
|
-
|
|
1166
|
+
|
|
752
1167
|
// Sanitize response to hide calendar URLs in error messages
|
|
753
1168
|
// Pass pathname for calendar events processing
|
|
754
|
-
return await sanitizeResponse(response, pathname);
|
|
1169
|
+
return await sanitizeResponse(response, pathname, userEmails);
|
|
755
1170
|
} catch (error) {
|
|
756
|
-
return new Response(`Error: ${error.message}`, {
|
|
1171
|
+
return new Response(`Error: ${error.message}`, {
|
|
757
1172
|
status: 500,
|
|
758
|
-
headers: { 'Content-Type': 'text/plain' }
|
|
1173
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
759
1174
|
});
|
|
760
1175
|
}
|
|
761
1176
|
},
|