@dytsou/calendar-build 2.1.0 → 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 +425 -52
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dytsou/calendar-build",
3
- "version": "2.1.0",
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
@@ -266,7 +271,7 @@ function getEventTime(event, field) {
266
271
  * Extract event title (handles various field name formats)
267
272
  */
268
273
  function getEventTitle(event) {
269
- return event.title || event.summary || event.name || '';
274
+ return event.title || event.summary || event.name || event.text || '';
270
275
  }
271
276
 
272
277
  /**
@@ -283,18 +288,284 @@ function getCalendarId(event) {
283
288
  return event.calendar || event.calendarId || event.calendar_id || event.source || 'default';
284
289
  }
285
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
+
286
549
  /**
287
550
  * Merge consecutive events within the same calendar
288
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
289
554
  */
290
- function mergeConsecutiveEvents(events) {
555
+ function mergeConsecutiveEvents(events, userEmails = []) {
291
556
  if (!Array.isArray(events) || events.length === 0) {
292
557
  return events;
293
558
  }
294
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
+
295
566
  // Group events by calendar identifier
296
567
  const eventsByCalendar = {};
297
- for (const event of events) {
568
+ for (const event of sanitizedEvents) {
298
569
  const calendarId = getCalendarId(event);
299
570
  if (!eventsByCalendar[calendarId]) {
300
571
  eventsByCalendar[calendarId] = [];
@@ -321,6 +592,7 @@ function mergeConsecutiveEvents(events) {
321
592
 
322
593
  for (let i = 0; i < calendarEvents.length; i++) {
323
594
  const event = calendarEvents[i];
595
+ // Event is already sanitized, but we need to preserve it during merge
324
596
  const startTime = getEventTime(event, 'start');
325
597
  const endTime = getEventTime(event, 'end');
326
598
 
@@ -345,33 +617,42 @@ function mergeConsecutiveEvents(events) {
345
617
  const normalizedStartTime = normalizeDate(startTime);
346
618
 
347
619
  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
- }
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);
362
623
 
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;
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;
370
634
 
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;
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
+ }
375
656
  }
376
657
  }
377
658
 
@@ -418,46 +699,101 @@ function mergeConsecutiveEvents(events) {
418
699
  return mergedEvents;
419
700
  }
420
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
+
421
720
  /**
422
721
  * Process calendar events JSON and merge consecutive events
423
722
  */
424
- function processCalendarEventsJson(jsonData) {
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
+
425
751
  if (Array.isArray(jsonData)) {
426
752
  // Format: [{event1}, {event2}, ...]
427
- return mergeConsecutiveEvents(jsonData);
753
+ return mergeConsecutiveEvents(jsonData, userEmails);
428
754
  } else if (jsonData && typeof jsonData === 'object') {
429
755
  // Check for nested structures
430
756
  if (Array.isArray(jsonData.events)) {
431
757
  // Format: {events: [...], ...}
758
+ // Remove calendar name from root level
759
+ const sanitizedRoot = removeCalendarName(jsonData);
432
760
  return {
433
- ...jsonData,
434
- events: mergeConsecutiveEvents(jsonData.events)
761
+ ...sanitizedRoot,
762
+ events: mergeConsecutiveEvents(jsonData.events, userEmails)
435
763
  };
436
764
  } else if (Array.isArray(jsonData.calendars)) {
437
765
  // Format: {calendars: [{events: [...]}, ...], ...}
766
+ // Remove calendar name from root level as well
767
+ const sanitizedRoot = removeCalendarName(jsonData);
768
+
438
769
  return {
439
- ...jsonData,
770
+ ...sanitizedRoot,
440
771
  calendars: jsonData.calendars.map(calendar => {
441
- if (Array.isArray(calendar.events)) {
772
+ // Remove calendar name from calendar object
773
+ const sanitizedCalendar = removeCalendarName(calendar);
774
+
775
+ if (Array.isArray(sanitizedCalendar.events)) {
442
776
  return {
443
- ...calendar,
444
- events: mergeConsecutiveEvents(calendar.events)
777
+ ...sanitizedCalendar,
778
+ events: mergeConsecutiveEvents(sanitizedCalendar.events, userEmails)
445
779
  };
446
780
  }
447
- return calendar;
781
+ return sanitizedCalendar;
448
782
  })
449
783
  };
450
784
  } else if (Array.isArray(jsonData.data)) {
451
785
  // Format: {data: [...], ...}
786
+ const sanitizedRoot = removeCalendarName(jsonData);
452
787
  return {
453
- ...jsonData,
454
- data: mergeConsecutiveEvents(jsonData.data)
788
+ ...sanitizedRoot,
789
+ data: mergeConsecutiveEvents(jsonData.data, userEmails)
455
790
  };
456
791
  } else if (Array.isArray(jsonData.items)) {
457
792
  // Format: {items: [...], ...}
793
+ const sanitizedRoot = removeCalendarName(jsonData);
458
794
  return {
459
- ...jsonData,
460
- items: mergeConsecutiveEvents(jsonData.items)
795
+ ...sanitizedRoot,
796
+ items: mergeConsecutiveEvents(jsonData.items, userEmails)
461
797
  };
462
798
  }
463
799
  // Unknown structure, try to find any array of events
@@ -469,9 +805,10 @@ function processCalendarEventsJson(jsonData) {
469
805
  const hasTimeField = getEventTime(firstItem, 'start') !== null ||
470
806
  getEventTime(firstItem, 'end') !== null;
471
807
  if (hasTimeField) {
808
+ const sanitizedRoot = removeCalendarName(jsonData);
472
809
  return {
473
- ...jsonData,
474
- [key]: mergeConsecutiveEvents(jsonData[key])
810
+ ...sanitizedRoot,
811
+ [key]: mergeConsecutiveEvents(jsonData[key], userEmails)
475
812
  };
476
813
  }
477
814
  }
@@ -479,17 +816,38 @@ function processCalendarEventsJson(jsonData) {
479
816
  }
480
817
  }
481
818
 
482
- // Unknown format, return as-is
819
+ // Unknown format, remove calendar name if present and return
820
+ if (jsonData && typeof jsonData === 'object') {
821
+ return removeCalendarName(jsonData);
822
+ }
483
823
  return jsonData;
484
824
  }
485
825
 
486
826
  /**
487
827
  * Sanitize response body to hide calendar URLs in error messages
488
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
489
832
  */
490
- async function sanitizeResponse(response, pathname) {
833
+ async function sanitizeResponse(response, pathname, userEmails = []) {
491
834
  const contentType = response.headers.get('content-type') || '';
492
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
+
493
851
  // Only sanitize HTML and JSON responses (error messages)
494
852
  // Skip JavaScript files to avoid breaking code
495
853
  if (!contentType.includes('text/html') &&
@@ -520,7 +878,7 @@ async function sanitizeResponse(response, pathname) {
520
878
  if (isCalendarEventsEndpoint) {
521
879
  try {
522
880
  const jsonData = JSON.parse(body);
523
- const processedData = processCalendarEventsJson(jsonData);
881
+ const processedData = processCalendarEventsJson(jsonData, userEmails);
524
882
  sanitizedBody = JSON.stringify(processedData);
525
883
  } catch (error) {
526
884
  // If JSON parsing fails, continue with original body
@@ -568,6 +926,10 @@ export default {
568
926
  const encryptionKey = env.ENCRYPTION_KEY;
569
927
  const calendarUrlSecret = env.CALENDAR_URL; // Read from Cloudflare Worker secret
570
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
+
571
933
  if (!calendarUrlSecret) {
572
934
  return new Response('CALENDAR_URL not configured in Cloudflare Worker secrets', {
573
935
  status: 500,
@@ -598,6 +960,17 @@ export default {
598
960
  // Get the pathname to determine request type
599
961
  const pathname = url.pathname;
600
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
+
601
974
  // Check if this is the main calendar page request
602
975
  const isMainCalendarPage = pathname === '/' ||
603
976
  pathname === '/calendar.html' ||
@@ -669,8 +1042,8 @@ export default {
669
1042
  });
670
1043
 
671
1044
  // Sanitize response to hide calendar URLs in error messages
672
- // Pass pathname for calendar events processing
673
- return await sanitizeResponse(response, pathname);
1045
+ // Pass pathname for calendar events processing and user emails for declined filtering
1046
+ return await sanitizeResponse(response, pathname, userEmails);
674
1047
  }
675
1048
 
676
1049
  // Handle main calendar page requests - always add calendar URLs from secret
@@ -725,7 +1098,7 @@ export default {
725
1098
 
726
1099
  // Sanitize response to hide calendar URLs in error messages
727
1100
  // Pass pathname for calendar events processing
728
- return await sanitizeResponse(response, pathname);
1101
+ return await sanitizeResponse(response, pathname, userEmails);
729
1102
  }
730
1103
 
731
1104
  // For all other requests (static resources, etc.), proxy directly
@@ -751,7 +1124,7 @@ export default {
751
1124
 
752
1125
  // Sanitize response to hide calendar URLs in error messages
753
1126
  // Pass pathname for calendar events processing
754
- return await sanitizeResponse(response, pathname);
1127
+ return await sanitizeResponse(response, pathname, userEmails);
755
1128
  } catch (error) {
756
1129
  return new Response(`Error: ${error.message}`, {
757
1130
  status: 500,