@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/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. Returns the calendar HTML
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. Deploy: wrangler deploy
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) { // Minimum: 1 + 8 + 16 + 0 + 32 = 57 bytes
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
- ? fernetUrl.substring(9)
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: ['start', 'startDate', 'start_time', 'startTime', 'dtstart', 'start_date', 'dateStart', 'date_start'],
253
- end: ['end', 'endDate', 'end_time', 'endTime', 'dtend', 'end_date', 'dateEnd', 'date_end']
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 events) {
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
- // Merge: combine titles and extend end time
349
- const currentTitle = getEventTitle(currentMerge);
350
- const eventTitle = getEventTitle(event);
351
- const combinedTitle = currentTitle && eventTitle
352
- ? `${currentTitle} + ${eventTitle}`
353
- : currentTitle || eventTitle;
354
-
355
- // Update title field (try common variations)
356
- if (currentMerge.title !== undefined) currentMerge.title = combinedTitle;
357
- if (currentMerge.summary !== undefined) currentMerge.summary = combinedTitle;
358
- if (currentMerge.name !== undefined) currentMerge.name = combinedTitle;
359
- if (!currentMerge.title && !currentMerge.summary && !currentMerge.name) {
360
- currentMerge.title = combinedTitle;
361
- }
362
-
363
- // Combine descriptions
364
- const currentDesc = getEventDescription(currentMerge);
365
- const eventDesc = getEventDescription(event);
366
- if (currentDesc || eventDesc) {
367
- const combinedDesc = currentDesc && eventDesc
368
- ? `${currentDesc}\n\n${eventDesc}`
369
- : currentDesc || eventDesc;
370
-
371
- if (currentMerge.description !== undefined) currentMerge.description = combinedDesc;
372
- if (currentMerge.desc !== undefined) currentMerge.desc = combinedDesc;
373
- if (!currentMerge.description && !currentMerge.desc) {
374
- currentMerge.description = combinedDesc;
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 = getEventTime(currentMerge, 'end') !== null
380
- ? (currentMerge.end !== undefined ? 'end' :
381
- currentMerge.endDate !== undefined ? 'endDate' :
382
- currentMerge.end_time !== undefined ? 'end_time' :
383
- currentMerge.endTime !== undefined ? 'endTime' :
384
- currentMerge.dtend !== undefined ? 'dtend' :
385
- currentMerge.end_date !== undefined ? 'end_date' :
386
- currentMerge.dateEnd !== undefined ? 'dateEnd' :
387
- currentMerge.date_end !== undefined ? 'date_end' : 'end')
388
- : 'end';
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 (!currentMerge.end && !currentMerge.endDate && !currentMerge.end_time &&
400
- !currentMerge.endTime && !currentMerge.dtend && !currentMerge.end_date &&
401
- !currentMerge.dateEnd && !currentMerge.date_end) {
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
- function processCalendarEventsJson(jsonData) {
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
- ...jsonData,
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
- ...jsonData,
798
+ ...sanitizedRoot,
440
799
  calendars: jsonData.calendars.map(calendar => {
441
- if (Array.isArray(calendar.events)) {
800
+ // Remove calendar name from calendar object
801
+ const sanitizedCalendar = removeCalendarName(calendar);
802
+
803
+ if (Array.isArray(sanitizedCalendar.events)) {
442
804
  return {
443
- ...calendar,
444
- events: mergeConsecutiveEvents(calendar.events)
805
+ ...sanitizedCalendar,
806
+ events: mergeConsecutiveEvents(sanitizedCalendar.events, userEmails),
445
807
  };
446
808
  }
447
- return calendar;
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
- ...jsonData,
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
- ...jsonData,
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 = getEventTime(firstItem, 'start') !== null ||
470
- getEventTime(firstItem, 'end') !== null;
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
- ...jsonData,
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 as-is
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 = pathname === '/calendar.events.json' ||
516
- pathname === '/calendar.json' ||
517
- pathname.endsWith('.events.json') ||
518
- pathname.endsWith('.json');
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 = pathname === '/' ||
603
- pathname === '/calendar.html' ||
604
- pathname.endsWith('/calendar.html') ||
605
- pathname === '';
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 = pathname === '/srcdoc' ||
610
- pathname.startsWith('/srcdoc') ||
611
- pathname === '/calendar.events.json' ||
612
- pathname === '/calendar.json' ||
613
- pathname.endsWith('.events.json') ||
614
- pathname.endsWith('.json');
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 (key.toLowerCase() !== 'host' && key.toLowerCase() !== 'cf-ray' && key.toLowerCase() !== 'cf-connecting-ip') {
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(`https://open-web-calendar.hosted.quelltext.eu${targetPath}${url.search}`);
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 (key.toLowerCase() !== 'host' && key.toLowerCase() !== 'cf-ray' && key.toLowerCase() !== 'cf-connecting-ip') {
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
  },