@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.
- package/package.json +1 -1
- package/worker.js +661 -6
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|