@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.
- package/package.json +1 -1
- package/worker.js +425 -52
package/package.json
CHANGED
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.
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
349
|
-
const
|
|
350
|
-
const
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
if (currentMerge.
|
|
373
|
-
if (
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
770
|
+
...sanitizedRoot,
|
|
440
771
|
calendars: jsonData.calendars.map(calendar => {
|
|
441
|
-
|
|
772
|
+
// Remove calendar name from calendar object
|
|
773
|
+
const sanitizedCalendar = removeCalendarName(calendar);
|
|
774
|
+
|
|
775
|
+
if (Array.isArray(sanitizedCalendar.events)) {
|
|
442
776
|
return {
|
|
443
|
-
...
|
|
444
|
-
events: mergeConsecutiveEvents(
|
|
777
|
+
...sanitizedCalendar,
|
|
778
|
+
events: mergeConsecutiveEvents(sanitizedCalendar.events, userEmails)
|
|
445
779
|
};
|
|
446
780
|
}
|
|
447
|
-
return
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
|
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,
|