@dytsou/calendar-build 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/worker.js +661 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dytsou/calendar-build",
3
- "version": "2.0.1",
3
+ "version": "2.1.1",
4
4
  "description": "Build script for calendar HTML from template and environment variables",
5
5
  "main": "scripts/build.js",
6
6
  "bin": {
package/worker.js CHANGED
@@ -5,17 +5,22 @@
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
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
9
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)
16
+ * - USER_EMAILS: Optional Cloudflare Worker secret (comma-separated list of user emails for declined event filtering)
14
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
@@ -225,13 +230,624 @@ function injectConsoleFilter(html) {
225
230
  return consoleFilterScript + html;
226
231
  }
227
232
 
233
+ /**
234
+ * Normalize date to ISO string for comparison
235
+ */
236
+ function normalizeDate(dateValue) {
237
+ if (!dateValue) return null;
238
+ if (typeof dateValue === 'string') {
239
+ // Parse and return ISO string
240
+ const date = new Date(dateValue);
241
+ return isNaN(date.getTime()) ? null : date.toISOString();
242
+ }
243
+ if (dateValue instanceof Date) {
244
+ return dateValue.toISOString();
245
+ }
246
+ // Try to convert to date
247
+ const date = new Date(dateValue);
248
+ return isNaN(date.getTime()) ? null : date.toISOString();
249
+ }
250
+
251
+ /**
252
+ * Extract event time fields (handles various field name formats)
253
+ */
254
+ function getEventTime(event, field) {
255
+ // Try common field name variations (including iCal and calendar.js formats)
256
+ const variations = {
257
+ start: ['start', 'startDate', 'start_time', 'startTime', 'dtstart', 'start_date', 'dateStart', 'date_start'],
258
+ end: ['end', 'endDate', 'end_time', 'endTime', 'dtend', 'end_date', 'dateEnd', 'date_end']
259
+ };
260
+
261
+ const fieldNames = variations[field] || [field];
262
+ for (const name of fieldNames) {
263
+ if (event[name] !== undefined && event[name] !== null) {
264
+ return event[name];
265
+ }
266
+ }
267
+ return null;
268
+ }
269
+
270
+ /**
271
+ * Extract event title (handles various field name formats)
272
+ */
273
+ function getEventTitle(event) {
274
+ return event.title || event.summary || event.name || event.text || '';
275
+ }
276
+
277
+ /**
278
+ * Extract event description (handles various field name formats)
279
+ */
280
+ function getEventDescription(event) {
281
+ return event.description || event.desc || '';
282
+ }
283
+
284
+ /**
285
+ * Extract calendar identifier (handles various field name formats)
286
+ */
287
+ function getCalendarId(event) {
288
+ return event.calendar || event.calendarId || event.calendar_id || event.source || 'default';
289
+ }
290
+
291
+ /**
292
+ * Extract event CLASS property (handles various field name formats)
293
+ * Returns null if not found (missing CLASS defaults to PRIVATE for privacy)
294
+ */
295
+ function getEventClass(event) {
296
+ // Check various possible field names for CLASS property
297
+ // iCal format uses CLASS, but different parsers may use different field names
298
+ let classValue = event.class ||
299
+ event.CLASS ||
300
+ event.classification ||
301
+ event['CLASS'] ||
302
+ event['class'] ||
303
+ null;
304
+
305
+ // Check nested properties structure (common in some iCal parsers)
306
+ if (!classValue && event.properties) {
307
+ classValue = event.properties.CLASS ||
308
+ event.properties.class ||
309
+ event.properties.CLASS?.value ||
310
+ event.properties.class?.value ||
311
+ null;
312
+ }
313
+
314
+ // Check inside 'ical' field - open-web-calendar stores raw iCal data here
315
+ if (!classValue && event.ical) {
316
+ // ical might be a string containing raw iCal data or an object
317
+ if (typeof event.ical === 'string') {
318
+ // Parse CLASS from raw iCal string (e.g., "CLASS:PUBLIC" or "CLASS:PRIVATE")
319
+ const classMatch = event.ical.match(/CLASS[:\s]*([A-Za-z]+)/i);
320
+ if (classMatch) {
321
+ classValue = classMatch[1];
322
+ }
323
+ } else if (typeof event.ical === 'object') {
324
+ classValue = event.ical.CLASS || event.ical.class || null;
325
+ }
326
+ }
327
+
328
+ // If no CLASS property found, return null (will be treated as PRIVATE by default)
329
+ if (classValue === null || classValue === undefined || classValue === '') {
330
+ return null;
331
+ }
332
+
333
+ // Normalize to uppercase string for comparison
334
+ return String(classValue).toUpperCase();
335
+ }
336
+
337
+ /**
338
+ * Check if event is PUBLIC
339
+ * Only returns true if CLASS is explicitly set to 'PUBLIC'
340
+ * Missing CLASS (null) defaults to PRIVATE - only shows start/end time
341
+ * Any other value (PRIVATE, CONFIDENTIAL, etc.) also returns false
342
+ */
343
+ function isPublicEvent(event) {
344
+ const eventClass = getEventClass(event);
345
+ // Only return true if CLASS is explicitly PUBLIC
346
+ // null (missing CLASS) defaults to PRIVATE - only show time, no details
347
+ // Any other value (PRIVATE, CONFIDENTIAL, etc.) also means non-PUBLIC
348
+ return eventClass === 'PUBLIC';
349
+ }
350
+
351
+ /**
352
+ * Remove all identifying information from an event object
353
+ * This function is used to sanitize non-PUBLIC events
354
+ */
355
+ function removeAllEventInfo(event) {
356
+ // Remove ALL identifying information - only keep time fields
357
+ event.title = '';
358
+ event.summary = '';
359
+ event.name = '';
360
+ event.text = 'BUSY'; // open-web-calendar uses 'text' field for title
361
+ event.description = '';
362
+ event.desc = '';
363
+ event.location = '';
364
+ event.loc = '';
365
+ event.url = '';
366
+ event.link = '';
367
+ event.organizer = '';
368
+ event.attendees = '';
369
+ event.attendee = '';
370
+ event.participants = ''; // open-web-calendar uses 'participants'
371
+ event.label = '';
372
+ event.notes = '';
373
+ event.note = '';
374
+ event.comment = '';
375
+ event.comments = '';
376
+
377
+ // CRITICAL: Remove ical field which contains raw iCal data with all event details
378
+ // This prevents any sensitive information from being exposed in the response
379
+ event.ical = '';
380
+
381
+ // Remove other fields that might contain identifying information
382
+ event.uid = ''; // Remove unique identifier
383
+ event.id = ''; // Remove ID if present
384
+ event.categories = ''; // Remove categories
385
+ event.color = ''; // Remove color
386
+ event.css_classes = ''; // Remove CSS classes
387
+ event.owc = ''; // Remove open-web-calendar specific data
388
+ event.recurrence = ''; // Remove recurrence info
389
+ event.sequence = ''; // Remove sequence
390
+ event.type = ''; // Remove type
391
+
392
+ // Mark this event as sanitized
393
+ event._isNonPublic = true;
394
+
395
+ return event;
396
+ }
397
+
398
+ /**
399
+ * Sanitize non-PUBLIC events to only show start and end times
400
+ * Removes title, description, location, and other details
401
+ */
402
+ function sanitizeNonPublicEvent(event) {
403
+ const eventClass = getEventClass(event);
404
+ const isPublic = isPublicEvent(event);
405
+
406
+ // If CLASS is PUBLIC, return event as-is (no sanitization)
407
+ if (isPublic) {
408
+ return event;
409
+ }
410
+
411
+ // For non-PUBLIC events (PRIVATE, CONFIDENTIAL, missing CLASS, or any other value), sanitize
412
+ // Create a sanitized version with only time fields
413
+ // Start with a copy of the event to preserve structure
414
+ const sanitized = { ...event };
415
+
416
+ // Remove ALL identifying information - only keep time fields
417
+ removeAllEventInfo(sanitized);
418
+
419
+ // Only preserve time fields, calendar ID, and CLASS property
420
+ // All other fields have been removed to prevent information leakage
421
+
422
+ return sanitized;
423
+ }
424
+
425
+ /**
426
+ * Parse user emails from environment secret
427
+ * USER_EMAILS secret should be comma-separated list of emails
428
+ * e.g., "user1@gmail.com,user2@example.com,user3@domain.org"
429
+ */
430
+ function parseUserEmails(userEmailsSecret) {
431
+ if (!userEmailsSecret) {
432
+ return [];
433
+ }
434
+ return userEmailsSecret
435
+ .split(',')
436
+ .map(email => email.trim().toLowerCase())
437
+ .filter(email => email.length > 0);
438
+ }
439
+
440
+ /**
441
+ * Check if event is declined by the calendar owner (user responded "no")
442
+ * Returns true ONLY if the event is explicitly marked as declined by the calendar owner
443
+ *
444
+ * For calendar invitations:
445
+ * - When YOU decline an event, the event STATUS is still CONFIRMED
446
+ * - But YOUR PARTSTAT (participation status) becomes DECLINED
447
+ * - We need to check for PARTSTAT=DECLINED in YOUR ATTENDEE line specifically
448
+ * - Other attendees declining should NOT filter the event
449
+ *
450
+ * @param {Object} event - The event object to check
451
+ * @param {string[]} userEmails - Array of user email addresses to check for declined status
452
+ */
453
+ function isEventDeclined(event, userEmails = []) {
454
+ const title = event.text || event.title || event.summary || '(no title)';
455
+ const cssClasses = event['css-classes'] || event.css_classes || event.cssClasses || [];
456
+ const eventType = event.type || '';
457
+ const ical = event.ical || '';
458
+
459
+ // Check css-classes for declined indicator
460
+ // open-web-calendar uses an array of classes like:
461
+ // ['event', 'STATUS-CONFIRMED', 'CLASS-PUBLIC', etc.]
462
+ // We look for 'STATUS-DECLINED' or 'PARTSTAT-DECLINED' class
463
+ if (Array.isArray(cssClasses)) {
464
+ for (const cls of cssClasses) {
465
+ const lowerCls = String(cls).toLowerCase();
466
+ // Only match exact status classes, not just containing 'declined'
467
+ if (lowerCls === 'status-declined' ||
468
+ lowerCls === 'partstat-declined') {
469
+ console.log('[Declined Check] FILTERED - css-class declined:', title);
470
+ return true;
471
+ }
472
+ }
473
+ }
474
+
475
+ // Check the event's own type/status field
476
+ if (eventType) {
477
+ const upperType = String(eventType).toUpperCase();
478
+ if (upperType === 'DECLINED' || upperType === 'CANCELLED') {
479
+ console.log('[Declined Check] FILTERED - type declined/cancelled:', title);
480
+ return true;
481
+ }
482
+ }
483
+
484
+ // Check ical field for the USER's PARTSTAT=DECLINED
485
+ if (ical && typeof ical === 'string') {
486
+ // Check for STATUS:CANCELLED or STATUS:DECLINED (entire event cancelled)
487
+ const cancelledPattern = /^STATUS:(CANCELLED|DECLINED)/im;
488
+ if (cancelledPattern.test(ical)) {
489
+ console.log('[Declined Check] FILTERED - event STATUS cancelled:', title);
490
+ return true;
491
+ }
492
+
493
+ // Check for PARTSTAT=DECLINED in the user's ATTENDEE line
494
+ // Format: ATTENDEE;...;PARTSTAT=DECLINED;...:mailto:user@email.com
495
+ // We need to find ATTENDEE lines that contain both PARTSTAT=DECLINED AND a user email
496
+
497
+ // Split ical into lines and look for ATTENDEE lines
498
+ // Note: ATTENDEE lines can be folded (continuation lines start with space)
499
+ const icalLines = ical.replace(/\r\n /g, '').split(/\r?\n/);
500
+
501
+ for (const line of icalLines) {
502
+ if (line.startsWith('ATTENDEE')) {
503
+ // Check if this attendee line has PARTSTAT=DECLINED
504
+ if (/PARTSTAT=DECLINED/i.test(line)) {
505
+ // Check if this is for one of the user's emails
506
+ for (const userEmail of userEmails) {
507
+ if (line.toLowerCase().includes(userEmail.toLowerCase())) {
508
+ console.log('[Declined Check] FILTERED - user PARTSTAT=DECLINED:', title, '| email:', userEmail);
509
+ return true;
510
+ }
511
+ }
512
+ }
513
+ }
514
+ }
515
+ }
516
+
517
+ // Check if event has explicit status field indicating declined/cancelled
518
+ const eventStatus = event.event_status || event.status || null;
519
+ if (eventStatus) {
520
+ const normalizedStatus = String(eventStatus).toUpperCase();
521
+ if (normalizedStatus === 'CANCELLED' || normalizedStatus === 'DECLINED') {
522
+ console.log('[Declined Check] FILTERED - event_status:', title);
523
+ return true;
524
+ }
525
+ }
526
+
527
+ return false;
528
+ }
529
+
530
+ /**
531
+ * Filter out declined events (events where user responded "no")
532
+ * @param {Array} events - Array of events to filter
533
+ * @param {string[]} userEmails - Array of user email addresses to check for declined status
534
+ */
535
+ function filterDeclinedEvents(events, userEmails = []) {
536
+ if (!Array.isArray(events) || events.length === 0) {
537
+ return events;
538
+ }
539
+
540
+ console.log('[Filter Declined] Processing', events.length, 'events');
541
+
542
+ const filteredEvents = events.filter(event => !isEventDeclined(event, userEmails));
543
+
544
+ console.log('[Filter Declined] After filtering:', filteredEvents.length, 'events remain');
545
+
546
+ return filteredEvents;
547
+ }
548
+
549
+ /**
550
+ * Merge consecutive events within the same calendar
551
+ * Events are consecutive if event1.end === event2.start (exact match)
552
+ * @param {Array} events - Array of events to process
553
+ * @param {string[]} userEmails - Array of user email addresses to check for declined status
554
+ */
555
+ function mergeConsecutiveEvents(events, userEmails = []) {
556
+ if (!Array.isArray(events) || events.length === 0) {
557
+ return events;
558
+ }
559
+
560
+ // Filter out declined events first
561
+ const filteredEvents = filterDeclinedEvents(events, userEmails);
562
+
563
+ // Sanitize non-PUBLIC events
564
+ const sanitizedEvents = filteredEvents.map(event => sanitizeNonPublicEvent(event));
565
+
566
+ // Group events by calendar identifier
567
+ const eventsByCalendar = {};
568
+ for (const event of sanitizedEvents) {
569
+ const calendarId = getCalendarId(event);
570
+ if (!eventsByCalendar[calendarId]) {
571
+ eventsByCalendar[calendarId] = [];
572
+ }
573
+ eventsByCalendar[calendarId].push(event);
574
+ }
575
+
576
+ const mergedEvents = [];
577
+
578
+ // Process each calendar group separately
579
+ for (const calendarId in eventsByCalendar) {
580
+ const calendarEvents = eventsByCalendar[calendarId];
581
+
582
+ // Sort events by start time
583
+ calendarEvents.sort((a, b) => {
584
+ const startA = getEventTime(a, 'start');
585
+ const startB = getEventTime(b, 'start');
586
+ if (!startA || !startB) return 0;
587
+ return new Date(startA) - new Date(startB);
588
+ });
589
+
590
+ // Merge consecutive events
591
+ let currentMerge = null;
592
+
593
+ for (let i = 0; i < calendarEvents.length; i++) {
594
+ const event = calendarEvents[i];
595
+ // Event is already sanitized, but we need to preserve it during merge
596
+ const startTime = getEventTime(event, 'start');
597
+ const endTime = getEventTime(event, 'end');
598
+
599
+ if (!startTime || !endTime) {
600
+ // If event is missing time info, add as-is
601
+ if (currentMerge) {
602
+ mergedEvents.push(currentMerge);
603
+ currentMerge = null;
604
+ }
605
+ mergedEvents.push(event);
606
+ continue;
607
+ }
608
+
609
+ if (currentMerge === null) {
610
+ // Start a new merge group
611
+ currentMerge = { ...event };
612
+ } else {
613
+ // Check if this event is consecutive to the current merge
614
+ const currentEndTime = getEventTime(currentMerge, 'end');
615
+ // Normalize dates for comparison (handle different date formats)
616
+ const normalizedEndTime = normalizeDate(currentEndTime);
617
+ const normalizedStartTime = normalizeDate(startTime);
618
+
619
+ if (normalizedEndTime && normalizedStartTime && normalizedEndTime === normalizedStartTime) {
620
+ // Check if either event is non-PUBLIC - if so, keep titles empty
621
+ const currentIsNonPublic = currentMerge._isNonPublic || !isPublicEvent(currentMerge);
622
+ const eventIsNonPublic = event._isNonPublic || !isPublicEvent(event);
623
+
624
+ if (currentIsNonPublic || eventIsNonPublic) {
625
+ // At least one event is non-PUBLIC, remove ALL identifying information
626
+ removeAllEventInfo(currentMerge);
627
+ } else {
628
+ // Both are PUBLIC, merge titles normally
629
+ const currentTitle = getEventTitle(currentMerge);
630
+ const eventTitle = getEventTitle(event);
631
+ const combinedTitle = currentTitle && eventTitle
632
+ ? `${currentTitle} + ${eventTitle}`
633
+ : currentTitle || eventTitle;
634
+
635
+ // Update title field (try common variations)
636
+ if (currentMerge.title !== undefined) currentMerge.title = combinedTitle;
637
+ if (currentMerge.summary !== undefined) currentMerge.summary = combinedTitle;
638
+ if (currentMerge.name !== undefined) currentMerge.name = combinedTitle;
639
+ if (!currentMerge.title && !currentMerge.summary && !currentMerge.name) {
640
+ currentMerge.title = combinedTitle;
641
+ }
642
+
643
+ // Combine descriptions
644
+ const currentDesc = getEventDescription(currentMerge);
645
+ const eventDesc = getEventDescription(event);
646
+ if (currentDesc || eventDesc) {
647
+ const combinedDesc = currentDesc && eventDesc
648
+ ? `${currentDesc}\n\n${eventDesc}`
649
+ : currentDesc || eventDesc;
650
+
651
+ if (currentMerge.description !== undefined) currentMerge.description = combinedDesc;
652
+ if (currentMerge.desc !== undefined) currentMerge.desc = combinedDesc;
653
+ if (!currentMerge.description && !currentMerge.desc) {
654
+ currentMerge.description = combinedDesc;
655
+ }
656
+ }
657
+ }
658
+
659
+ // Update end time - preserve original field name format
660
+ const originalEndField = getEventTime(currentMerge, 'end') !== null
661
+ ? (currentMerge.end !== undefined ? 'end' :
662
+ currentMerge.endDate !== undefined ? 'endDate' :
663
+ currentMerge.end_time !== undefined ? 'end_time' :
664
+ currentMerge.endTime !== undefined ? 'endTime' :
665
+ currentMerge.dtend !== undefined ? 'dtend' :
666
+ currentMerge.end_date !== undefined ? 'end_date' :
667
+ currentMerge.dateEnd !== undefined ? 'dateEnd' :
668
+ currentMerge.date_end !== undefined ? 'date_end' : 'end')
669
+ : 'end';
670
+
671
+ // Update all possible end time fields to ensure compatibility
672
+ if (currentMerge.end !== undefined) currentMerge.end = endTime;
673
+ if (currentMerge.endDate !== undefined) currentMerge.endDate = endTime;
674
+ if (currentMerge.end_time !== undefined) currentMerge.end_time = endTime;
675
+ if (currentMerge.endTime !== undefined) currentMerge.endTime = endTime;
676
+ if (currentMerge.dtend !== undefined) currentMerge.dtend = endTime;
677
+ if (currentMerge.end_date !== undefined) currentMerge.end_date = endTime;
678
+ if (currentMerge.dateEnd !== undefined) currentMerge.dateEnd = endTime;
679
+ if (currentMerge.date_end !== undefined) currentMerge.date_end = endTime;
680
+ if (!currentMerge.end && !currentMerge.endDate && !currentMerge.end_time &&
681
+ !currentMerge.endTime && !currentMerge.dtend && !currentMerge.end_date &&
682
+ !currentMerge.dateEnd && !currentMerge.date_end) {
683
+ currentMerge.end = endTime;
684
+ }
685
+ } else {
686
+ // Not consecutive, save current merge and start new one
687
+ mergedEvents.push(currentMerge);
688
+ currentMerge = { ...event };
689
+ }
690
+ }
691
+ }
692
+
693
+ // Add the last merge group if any
694
+ if (currentMerge !== null) {
695
+ mergedEvents.push(currentMerge);
696
+ }
697
+ }
698
+
699
+ return mergedEvents;
700
+ }
701
+
702
+ /**
703
+ * Remove calendar name from calendar object
704
+ * @param {Object} calendar - The calendar object
705
+ */
706
+ function removeCalendarName(calendar) {
707
+ if (!calendar || typeof calendar !== 'object') {
708
+ return calendar;
709
+ }
710
+
711
+ // Create a copy of the calendar object without the name field
712
+ const sanitized = { ...calendar };
713
+ delete sanitized.name;
714
+ delete sanitized.calendarName;
715
+ delete sanitized.title;
716
+
717
+ return sanitized;
718
+ }
719
+
720
+ /**
721
+ * Process calendar events JSON and merge consecutive events
722
+ */
723
+ /**
724
+ * Process calendar events JSON and merge consecutive events
725
+ * @param {Object|Array} jsonData - The calendar events data
726
+ * @param {string[]} userEmails - Array of user email addresses to check for declined status
727
+ */
728
+ function processCalendarEventsJson(jsonData, userEmails = []) {
729
+ // Debug: Log first event structure to understand data format
730
+ // TODO: Remove or make conditional in production
731
+ try {
732
+ let firstEvent = null;
733
+ if (Array.isArray(jsonData) && jsonData.length > 0) {
734
+ firstEvent = jsonData[0];
735
+ } else if (jsonData && typeof jsonData === 'object') {
736
+ if (Array.isArray(jsonData.events) && jsonData.events.length > 0) {
737
+ firstEvent = jsonData.events[0];
738
+ } else if (Array.isArray(jsonData.calendars) && jsonData.calendars.length > 0) {
739
+ const firstCalendar = jsonData.calendars[0];
740
+ if (Array.isArray(firstCalendar.events) && firstCalendar.events.length > 0) {
741
+ firstEvent = firstCalendar.events[0];
742
+ }
743
+ }
744
+ }
745
+
746
+ // Debug logging removed - CLASS detection is working
747
+ } catch (e) {
748
+ // Ignore errors
749
+ }
750
+
751
+ if (Array.isArray(jsonData)) {
752
+ // Format: [{event1}, {event2}, ...]
753
+ return mergeConsecutiveEvents(jsonData, userEmails);
754
+ } else if (jsonData && typeof jsonData === 'object') {
755
+ // Check for nested structures
756
+ if (Array.isArray(jsonData.events)) {
757
+ // Format: {events: [...], ...}
758
+ // Remove calendar name from root level
759
+ const sanitizedRoot = removeCalendarName(jsonData);
760
+ return {
761
+ ...sanitizedRoot,
762
+ events: mergeConsecutiveEvents(jsonData.events, userEmails)
763
+ };
764
+ } else if (Array.isArray(jsonData.calendars)) {
765
+ // Format: {calendars: [{events: [...]}, ...], ...}
766
+ // Remove calendar name from root level as well
767
+ const sanitizedRoot = removeCalendarName(jsonData);
768
+
769
+ return {
770
+ ...sanitizedRoot,
771
+ calendars: jsonData.calendars.map(calendar => {
772
+ // Remove calendar name from calendar object
773
+ const sanitizedCalendar = removeCalendarName(calendar);
774
+
775
+ if (Array.isArray(sanitizedCalendar.events)) {
776
+ return {
777
+ ...sanitizedCalendar,
778
+ events: mergeConsecutiveEvents(sanitizedCalendar.events, userEmails)
779
+ };
780
+ }
781
+ return sanitizedCalendar;
782
+ })
783
+ };
784
+ } else if (Array.isArray(jsonData.data)) {
785
+ // Format: {data: [...], ...}
786
+ const sanitizedRoot = removeCalendarName(jsonData);
787
+ return {
788
+ ...sanitizedRoot,
789
+ data: mergeConsecutiveEvents(jsonData.data, userEmails)
790
+ };
791
+ } else if (Array.isArray(jsonData.items)) {
792
+ // Format: {items: [...], ...}
793
+ const sanitizedRoot = removeCalendarName(jsonData);
794
+ return {
795
+ ...sanitizedRoot,
796
+ items: mergeConsecutiveEvents(jsonData.items, userEmails)
797
+ };
798
+ }
799
+ // Unknown structure, try to find any array of events
800
+ for (const key in jsonData) {
801
+ if (Array.isArray(jsonData[key]) && jsonData[key].length > 0) {
802
+ // Check if it looks like events (has time fields)
803
+ const firstItem = jsonData[key][0];
804
+ if (firstItem && typeof firstItem === 'object') {
805
+ const hasTimeField = getEventTime(firstItem, 'start') !== null ||
806
+ getEventTime(firstItem, 'end') !== null;
807
+ if (hasTimeField) {
808
+ const sanitizedRoot = removeCalendarName(jsonData);
809
+ return {
810
+ ...sanitizedRoot,
811
+ [key]: mergeConsecutiveEvents(jsonData[key], userEmails)
812
+ };
813
+ }
814
+ }
815
+ }
816
+ }
817
+ }
818
+
819
+ // Unknown format, remove calendar name if present and return
820
+ if (jsonData && typeof jsonData === 'object') {
821
+ return removeCalendarName(jsonData);
822
+ }
823
+ return jsonData;
824
+ }
825
+
228
826
  /**
229
827
  * Sanitize response body to hide calendar URLs in error messages
230
828
  * Only sanitizes HTML and JSON responses to avoid breaking JavaScript code
829
+ * @param {Response} response - The response to sanitize
830
+ * @param {string} pathname - The request pathname
831
+ * @param {string[]} userEmails - Array of user email addresses to check for declined status
231
832
  */
232
- async function sanitizeResponse(response) {
833
+ async function sanitizeResponse(response, pathname, userEmails = []) {
233
834
  const contentType = response.headers.get('content-type') || '';
234
835
 
836
+ // Block ICS/calendar file downloads - check content type and pathname
837
+ if (contentType.includes('text/calendar') ||
838
+ contentType.includes('application/ics') ||
839
+ pathname.endsWith('.ics') ||
840
+ pathname.endsWith('.ICAL') ||
841
+ pathname.endsWith('.iCal')) {
842
+ return new Response('Calendar file download is not allowed', {
843
+ status: 403,
844
+ headers: {
845
+ 'Content-Type': 'text/plain',
846
+ 'Access-Control-Allow-Origin': '*'
847
+ }
848
+ });
849
+ }
850
+
235
851
  // Only sanitize HTML and JSON responses (error messages)
236
852
  // Skip JavaScript files to avoid breaking code
237
853
  if (!contentType.includes('text/html') &&
@@ -250,6 +866,27 @@ async function sanitizeResponse(response) {
250
866
 
251
867
  let sanitizedBody = body;
252
868
 
869
+ // For JSON responses, check if it's a calendar events endpoint
870
+ if (contentType.includes('application/json')) {
871
+ // Check for calendar events endpoints - open-web-calendar uses /calendar.json
872
+ // Also check for any .json file that might contain calendar events
873
+ const isCalendarEventsEndpoint = pathname === '/calendar.events.json' ||
874
+ pathname === '/calendar.json' ||
875
+ pathname.endsWith('.events.json') ||
876
+ pathname.endsWith('.json');
877
+
878
+ if (isCalendarEventsEndpoint) {
879
+ try {
880
+ const jsonData = JSON.parse(body);
881
+ const processedData = processCalendarEventsJson(jsonData, userEmails);
882
+ sanitizedBody = JSON.stringify(processedData);
883
+ } catch (error) {
884
+ // If JSON parsing fails, continue with original body
885
+ console.error('Failed to parse calendar events JSON:', error);
886
+ }
887
+ }
888
+ }
889
+
253
890
  // For HTML responses, inject console filtering
254
891
  if (contentType.includes('text/html')) {
255
892
  sanitizedBody = injectConsoleFilter(sanitizedBody);
@@ -289,6 +926,10 @@ export default {
289
926
  const encryptionKey = env.ENCRYPTION_KEY;
290
927
  const calendarUrlSecret = env.CALENDAR_URL; // Read from Cloudflare Worker secret
291
928
 
929
+ // Parse user emails from secret for declined event filtering
930
+ // USER_EMAILS secret should be comma-separated list of emails
931
+ const userEmails = parseUserEmails(env.USER_EMAILS);
932
+
292
933
  if (!calendarUrlSecret) {
293
934
  return new Response('CALENDAR_URL not configured in Cloudflare Worker secrets', {
294
935
  status: 500,
@@ -319,6 +960,17 @@ export default {
319
960
  // Get the pathname to determine request type
320
961
  const pathname = url.pathname;
321
962
 
963
+ // Block ICS file downloads - prevent access to raw calendar files
964
+ if (pathname.endsWith('.ics') || pathname.endsWith('.ICAL') || pathname.endsWith('.iCal')) {
965
+ return new Response('Calendar file download is not allowed', {
966
+ status: 403,
967
+ headers: {
968
+ 'Content-Type': 'text/plain',
969
+ 'Access-Control-Allow-Origin': '*'
970
+ }
971
+ });
972
+ }
973
+
322
974
  // Check if this is the main calendar page request
323
975
  const isMainCalendarPage = pathname === '/' ||
324
976
  pathname === '/calendar.html' ||
@@ -390,7 +1042,8 @@ export default {
390
1042
  });
391
1043
 
392
1044
  // Sanitize response to hide calendar URLs in error messages
393
- return await sanitizeResponse(response);
1045
+ // Pass pathname for calendar events processing and user emails for declined filtering
1046
+ return await sanitizeResponse(response, pathname, userEmails);
394
1047
  }
395
1048
 
396
1049
  // Handle main calendar page requests - always add calendar URLs from secret
@@ -444,7 +1097,8 @@ export default {
444
1097
  });
445
1098
 
446
1099
  // Sanitize response to hide calendar URLs in error messages
447
- return await sanitizeResponse(response);
1100
+ // Pass pathname for calendar events processing
1101
+ return await sanitizeResponse(response, pathname, userEmails);
448
1102
  }
449
1103
 
450
1104
  // For all other requests (static resources, etc.), proxy directly
@@ -469,7 +1123,8 @@ export default {
469
1123
  });
470
1124
 
471
1125
  // Sanitize response to hide calendar URLs in error messages
472
- return await sanitizeResponse(response);
1126
+ // Pass pathname for calendar events processing
1127
+ return await sanitizeResponse(response, pathname, userEmails);
473
1128
  } catch (error) {
474
1129
  return new Response(`Error: ${error.message}`, {
475
1130
  status: 500,