@dytsou/calendar-build 2.1.1 → 2.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,7 @@ wrangler secret put ENCRYPTION_KEY
30
30
  ```
31
31
 
32
32
  **Getting Google Calendar iCal URLs:**
33
+
33
34
  - Go to your Google Calendar settings
34
35
  - Find the calendar you want to share
35
36
  - Click "Integrate calendar" or "Get shareable link"
@@ -193,12 +194,15 @@ This project uses a Cloudflare Worker to manage calendar URLs securely. Calendar
193
194
  ### Setup Instructions
194
195
 
195
196
  1. **Copy wrangler.toml.example to wrangler.toml:**
197
+
196
198
  ```bash
197
199
  cp wrangler.toml.example wrangler.toml
198
200
  ```
201
+
199
202
  Then edit `wrangler.toml` and customize it for your environment (e.g., add custom domain routes).
200
203
 
201
204
  2. **Install Wrangler CLI:**
205
+
202
206
  ```bash
203
207
  npm install -g wrangler
204
208
  # or
@@ -206,46 +210,55 @@ This project uses a Cloudflare Worker to manage calendar URLs securely. Calendar
206
210
  ```
207
211
 
208
212
  3. **Login to Cloudflare:**
213
+
209
214
  ```bash
210
215
  wrangler login
211
216
  ```
212
217
 
213
218
  4. **Set Calendar URLs Secret:**
219
+
214
220
  ```bash
215
221
  wrangler secret put CALENDAR_URL
216
222
  ```
223
+
217
224
  When prompted, enter your calendar URLs (comma-separated):
225
+
218
226
  ```
219
227
  https://calendar.google.com/calendar/ical/example%40gmail.com/public/basic.ics,https://calendar.google.com/calendar/ical/another%40gmail.com/public/basic.ics
220
228
  ```
221
-
229
+
222
230
  **Note:** URLs can be plain or `fernet://` encrypted. If using encrypted URLs, you'll also need to set `ENCRYPTION_KEY`.
223
231
 
224
232
  5. **Set Encryption Key Secret (if using fernet:// encrypted URLs):**
233
+
225
234
  ```bash
226
235
  wrangler secret put ENCRYPTION_KEY
227
236
  ```
237
+
228
238
  When prompted, enter your Fernet encryption key (base64url-encoded 32-byte key).
229
-
239
+
230
240
  Generate one with:
241
+
231
242
  ```bash
232
243
  node -e "console.log(require('crypto').randomBytes(32).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''))"
233
244
  ```
234
245
 
235
246
  6. **Deploy the Worker:**
247
+
236
248
  ```bash
237
249
  wrangler deploy
238
250
  ```
239
251
 
240
- 6. **Update your .env file:**
252
+ 7. **Update your .env file:**
241
253
  After deployment, update the `WORKER_URL` in your `.env` file with your worker URL:
254
+
242
255
  ```
243
256
  WORKER_URL=https://your-worker.your-subdomain.workers.dev
244
257
  # or if using custom domain:
245
258
  WORKER_URL=https://your-domain.com
246
259
  ```
247
260
 
248
- 7. **Rebuild your project:**
261
+ 8. **Rebuild your project:**
249
262
  ```bash
250
263
  pnpm run build
251
264
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dytsou/calendar-build",
3
- "version": "2.1.1",
3
+ "version": "2.1.4",
4
4
  "description": "Build script for calendar HTML from template and environment variables",
5
5
  "main": "scripts/build.js",
6
6
  "bin": {
@@ -34,9 +34,8 @@
34
34
  "node": ">=12.0.0"
35
35
  },
36
36
  "devDependencies": {
37
- "browserify": "^17.0.1",
38
37
  "prettier": "^3.7.4",
39
- "wrangler": "4.54.0"
38
+ "wrangler": "4.58.0"
40
39
  },
41
40
  "dependencies": {
42
41
  "fernet": "^0.3.3",
package/scripts/build.js CHANGED
@@ -26,7 +26,7 @@ if (needsWorkerUrl || hasPlaceholders) {
26
26
  console.error('Error: .env file not found (required for placeholders)');
27
27
  process.exit(1);
28
28
  }
29
-
29
+
30
30
  if (fs.existsSync(envPath)) {
31
31
  const envContent = fs.readFileSync(envPath, 'utf-8');
32
32
  const envLines = envContent.split('\n');
@@ -58,7 +58,6 @@ if (needsWorkerUrl || hasPlaceholders) {
58
58
  }
59
59
 
60
60
  if (hasPlaceholders) {
61
-
62
61
  // Replace CALENDAR_SOURCES placeholder
63
62
  if (html.includes('{{CALENDAR_SOURCES}}')) {
64
63
  if (calendarSources.length === 0) {
@@ -119,7 +118,7 @@ if (hasPlaceholders) {
119
118
  console.log(
120
119
  `āœ“ Replaced {{CALENDAR_URLS}} with ${encryptedUrls.length} Fernet-encrypted calendar URL(s)`
121
120
  );
122
-
121
+
123
122
  // Output encrypted URLs for copying to .env
124
123
  console.log('\nšŸ“‹ Encrypted URLs (for .env file):');
125
124
  console.log('CALENDAR_URL_ENCRYPTED=' + encryptedUrls.join(','));
@@ -137,17 +136,20 @@ if (hasPlaceholders) {
137
136
  '[\n' + calendarUrls.map(url => ` "${url}"`).join(',\n') + '\n ]';
138
137
  html = html.replace('{{ENCRYPTION_METHOD}}', 'none');
139
138
  html = html.replace('{{ENCRYPTION_KEY}}', '');
140
- html = html.replace('{{WORKER_URL}}', 'https://open-web-calendar.hosted.quelltext.eu/calendar.html');
139
+ html = html.replace(
140
+ '{{WORKER_URL}}',
141
+ 'https://open-web-calendar.hosted.quelltext.eu/calendar.html'
142
+ );
141
143
  console.log(
142
144
  `āœ“ Replaced {{CALENDAR_URLS}} with ${calendarUrls.length} calendar URL(s) (no encryption)`
143
145
  );
144
146
  }
145
147
 
146
148
  html = html.replace('{{CALENDAR_URLS}}', calendarUrlsArrayCode);
147
-
149
+
148
150
  // Remove encryption key placeholder (not needed with Worker)
149
151
  html = html.replace('{{ENCRYPTION_KEY}}', '');
150
-
152
+
151
153
  // Clean up fernet-bundle.js if it exists (no longer needed with Worker)
152
154
  const bundleFile = path.join(__dirname, '..', 'fernet-bundle.js');
153
155
  if (fs.existsSync(bundleFile)) {
@@ -159,7 +161,8 @@ if (hasPlaceholders) {
159
161
 
160
162
  // Always replace WORKER_URL placeholder if it exists (regardless of other placeholders)
161
163
  if (html.includes('{{WORKER_URL}}')) {
162
- const finalWorkerUrl = workerUrl || process.env.WORKER_URL || 'https://open-web-calendar.hosted.quelltext.eu';
164
+ const finalWorkerUrl =
165
+ workerUrl || process.env.WORKER_URL || 'https://open-web-calendar.hosted.quelltext.eu';
163
166
  html = html.replace('{{WORKER_URL}}', finalWorkerUrl);
164
167
  if (finalWorkerUrl !== 'https://open-web-calendar.hosted.quelltext.eu') {
165
168
  console.log(`āœ“ Using Cloudflare Worker: ${finalWorkerUrl}`);
@@ -73,4 +73,3 @@ try {
73
73
  console.error('Error encrypting URLs:', error.message);
74
74
  process.exit(1);
75
75
  }
76
-
package/worker.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Cloudflare Worker to decrypt fernet:// URLs and proxy to open-web-calendar
3
- *
3
+ *
4
4
  * This worker:
5
5
  * 1. Reads CALENDAR_URL from Cloudflare Worker secrets (comma-separated)
6
6
  * 2. Decrypts fernet:// URLs using Fernet encryption (ENCRYPTION_METHOD is hardcoded to 'fernet')
@@ -8,13 +8,13 @@
8
8
  * 4. Filters out events declined by user (based on USER_EMAILS secret)
9
9
  * 5. Sanitizes non-PUBLIC events to only show busy time
10
10
  * 6. Returns the calendar HTML
11
- *
11
+ *
12
12
  * Configuration:
13
13
  * - ENCRYPTION_METHOD: Hardcoded to 'fernet' (not configurable)
14
14
  * - ENCRYPTION_KEY: Required Cloudflare Worker secret (for decrypting fernet:// URLs)
15
15
  * - CALENDAR_URL: Required Cloudflare Worker secret (comma-separated, can be plain or fernet:// encrypted)
16
16
  * - USER_EMAILS: Optional Cloudflare Worker secret (comma-separated list of user emails for declined event filtering)
17
- *
17
+ *
18
18
  * Usage:
19
19
  * 1. Set CALENDAR_URL secret: wrangler secret put CALENDAR_URL
20
20
  * 2. Set ENCRYPTION_KEY secret: wrangler secret put ENCRYPTION_KEY
@@ -53,22 +53,23 @@ async function decryptFernetToken(token, secretKey) {
53
53
  try {
54
54
  // Decode the token
55
55
  const tokenBytes = base64UrlDecode(token);
56
-
57
- if (tokenBytes.length < 57) { // Minimum: 1 + 8 + 16 + 0 + 32 = 57 bytes
56
+
57
+ if (tokenBytes.length < 57) {
58
+ // Minimum: 1 + 8 + 16 + 0 + 32 = 57 bytes
58
59
  throw new Error('Invalid Fernet token: too short');
59
60
  }
60
-
61
+
61
62
  // Extract components
62
63
  const version = tokenBytes[0];
63
64
  if (version !== 0x80) {
64
65
  throw new Error('Invalid Fernet token: unsupported version');
65
66
  }
66
-
67
+
67
68
  const timestamp = tokenBytes.slice(1, 9);
68
69
  const iv = tokenBytes.slice(9, 25);
69
70
  const hmac = tokenBytes.slice(-32);
70
71
  const ciphertext = tokenBytes.slice(25, -32);
71
-
72
+
72
73
  // Derive signing key and encryption key from secret
73
74
  // Fernet secret is base64url-encoded 32-byte key
74
75
  // The library splits it directly: first 16 bytes = signing key, last 16 bytes = encryption key
@@ -82,13 +83,13 @@ async function decryptFernetToken(token, secretKey) {
82
83
  } catch (error) {
83
84
  throw new Error(`Failed to decode secret key: ${error.message}`);
84
85
  }
85
-
86
+
86
87
  // Fernet splits the 32-byte secret directly:
87
88
  // Signing key: first 16 bytes (128 bits)
88
89
  // Encryption key: last 16 bytes (128 bits)
89
90
  const signingKey = secretBytes.slice(0, 16);
90
91
  const encryptionKey = secretBytes.slice(16, 32);
91
-
92
+
92
93
  // Verify HMAC
93
94
  // HMAC message is: version (1 byte) + timestamp (8 bytes) + IV (16 bytes) + ciphertext
94
95
  const message = tokenBytes.slice(0, -32);
@@ -99,10 +100,10 @@ async function decryptFernetToken(token, secretKey) {
99
100
  false,
100
101
  ['sign', 'verify']
101
102
  );
102
-
103
+
103
104
  const computedHmac = await crypto.subtle.sign('HMAC', hmacKey, message);
104
105
  const computedHmacBytes = new Uint8Array(computedHmac);
105
-
106
+
106
107
  // Constant-time comparison
107
108
  let hmacValid = true;
108
109
  if (computedHmacBytes.length !== hmac.length) {
@@ -114,11 +115,11 @@ async function decryptFernetToken(token, secretKey) {
114
115
  }
115
116
  }
116
117
  }
117
-
118
+
118
119
  if (!hmacValid) {
119
120
  throw new Error('Invalid Fernet token: HMAC verification failed');
120
121
  }
121
-
122
+
122
123
  // Decrypt using AES-128-CBC
123
124
  const cryptoKey = await crypto.subtle.importKey(
124
125
  'raw',
@@ -127,13 +128,13 @@ async function decryptFernetToken(token, secretKey) {
127
128
  false,
128
129
  ['decrypt']
129
130
  );
130
-
131
+
131
132
  const decrypted = await crypto.subtle.decrypt(
132
133
  { name: 'AES-CBC', iv: iv },
133
134
  cryptoKey,
134
135
  ciphertext
135
136
  );
136
-
137
+
137
138
  // Decode the decrypted message (PKCS7 padding will be removed automatically)
138
139
  const decoder = new TextDecoder();
139
140
  return decoder.decode(decrypted);
@@ -147,10 +148,8 @@ async function decryptFernetToken(token, secretKey) {
147
148
  */
148
149
  async function decryptFernetUrl(fernetUrl, secretKey) {
149
150
  // Remove fernet:// prefix
150
- const token = fernetUrl.startsWith('fernet://')
151
- ? fernetUrl.substring(9)
152
- : fernetUrl;
153
-
151
+ const token = fernetUrl.startsWith('fernet://') ? fernetUrl.substring(9) : fernetUrl;
152
+
154
153
  return await decryptFernetToken(token, secretKey);
155
154
  }
156
155
 
@@ -215,7 +214,7 @@ function injectConsoleFilter(html) {
215
214
  };
216
215
  })();
217
216
  </script>`;
218
-
217
+
219
218
  // Inject the script right after <head> or at the beginning of <body>
220
219
  if (html.includes('</head>')) {
221
220
  return html.replace('</head>', consoleFilterScript + '</head>');
@@ -225,7 +224,7 @@ function injectConsoleFilter(html) {
225
224
  return html.replace(bodyMatch[0], bodyMatch[0] + consoleFilterScript);
226
225
  }
227
226
  }
228
-
227
+
229
228
  // Fallback: inject at the very beginning
230
229
  return consoleFilterScript + html;
231
230
  }
@@ -254,10 +253,19 @@ function normalizeDate(dateValue) {
254
253
  function getEventTime(event, field) {
255
254
  // Try common field name variations (including iCal and calendar.js formats)
256
255
  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']
256
+ start: [
257
+ 'start',
258
+ 'startDate',
259
+ 'start_time',
260
+ 'startTime',
261
+ 'dtstart',
262
+ 'start_date',
263
+ 'dateStart',
264
+ 'date_start',
265
+ ],
266
+ end: ['end', 'endDate', 'end_time', 'endTime', 'dtend', 'end_date', 'dateEnd', 'date_end'],
259
267
  };
260
-
268
+
261
269
  const fieldNames = variations[field] || [field];
262
270
  for (const name of fieldNames) {
263
271
  if (event[name] !== undefined && event[name] !== null) {
@@ -295,22 +303,19 @@ function getCalendarId(event) {
295
303
  function getEventClass(event) {
296
304
  // Check various possible field names for CLASS property
297
305
  // 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
-
306
+ let classValue =
307
+ event.class || event.CLASS || event.classification || event['CLASS'] || event['class'] || null;
308
+
305
309
  // Check nested properties structure (common in some iCal parsers)
306
310
  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;
311
+ classValue =
312
+ event.properties.CLASS ||
313
+ event.properties.class ||
314
+ event.properties.CLASS?.value ||
315
+ event.properties.class?.value ||
316
+ null;
312
317
  }
313
-
318
+
314
319
  // Check inside 'ical' field - open-web-calendar stores raw iCal data here
315
320
  if (!classValue && event.ical) {
316
321
  // ical might be a string containing raw iCal data or an object
@@ -324,12 +329,12 @@ function getEventClass(event) {
324
329
  classValue = event.ical.CLASS || event.ical.class || null;
325
330
  }
326
331
  }
327
-
332
+
328
333
  // If no CLASS property found, return null (will be treated as PRIVATE by default)
329
334
  if (classValue === null || classValue === undefined || classValue === '') {
330
335
  return null;
331
336
  }
332
-
337
+
333
338
  // Normalize to uppercase string for comparison
334
339
  return String(classValue).toUpperCase();
335
340
  }
@@ -357,7 +362,7 @@ function removeAllEventInfo(event) {
357
362
  event.title = '';
358
363
  event.summary = '';
359
364
  event.name = '';
360
- event.text = 'BUSY'; // open-web-calendar uses 'text' field for title
365
+ event.text = 'BUSY'; // open-web-calendar uses 'text' field for title
361
366
  event.description = '';
362
367
  event.desc = '';
363
368
  event.location = '';
@@ -367,31 +372,31 @@ function removeAllEventInfo(event) {
367
372
  event.organizer = '';
368
373
  event.attendees = '';
369
374
  event.attendee = '';
370
- event.participants = ''; // open-web-calendar uses 'participants'
375
+ event.participants = ''; // open-web-calendar uses 'participants'
371
376
  event.label = '';
372
377
  event.notes = '';
373
378
  event.note = '';
374
379
  event.comment = '';
375
380
  event.comments = '';
376
-
381
+
377
382
  // CRITICAL: Remove ical field which contains raw iCal data with all event details
378
383
  // This prevents any sensitive information from being exposed in the response
379
384
  event.ical = '';
380
-
385
+
381
386
  // 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
-
387
+ event.uid = ''; // Remove unique identifier
388
+ event.id = ''; // Remove ID if present
389
+ event.categories = ''; // Remove categories
390
+ event.color = ''; // Remove color
391
+ event.css_classes = ''; // Remove CSS classes
392
+ event.owc = ''; // Remove open-web-calendar specific data
393
+ event.recurrence = ''; // Remove recurrence info
394
+ event.sequence = ''; // Remove sequence
395
+ event.type = ''; // Remove type
396
+
392
397
  // Mark this event as sanitized
393
398
  event._isNonPublic = true;
394
-
399
+
395
400
  return event;
396
401
  }
397
402
 
@@ -402,23 +407,23 @@ function removeAllEventInfo(event) {
402
407
  function sanitizeNonPublicEvent(event) {
403
408
  const eventClass = getEventClass(event);
404
409
  const isPublic = isPublicEvent(event);
405
-
410
+
406
411
  // If CLASS is PUBLIC, return event as-is (no sanitization)
407
412
  if (isPublic) {
408
413
  return event;
409
414
  }
410
-
415
+
411
416
  // For non-PUBLIC events (PRIVATE, CONFIDENTIAL, missing CLASS, or any other value), sanitize
412
417
  // Create a sanitized version with only time fields
413
418
  // Start with a copy of the event to preserve structure
414
419
  const sanitized = { ...event };
415
-
420
+
416
421
  // Remove ALL identifying information - only keep time fields
417
422
  removeAllEventInfo(sanitized);
418
-
423
+
419
424
  // Only preserve time fields, calendar ID, and CLASS property
420
425
  // All other fields have been removed to prevent information leakage
421
-
426
+
422
427
  return sanitized;
423
428
  }
424
429
 
@@ -440,13 +445,13 @@ function parseUserEmails(userEmailsSecret) {
440
445
  /**
441
446
  * Check if event is declined by the calendar owner (user responded "no")
442
447
  * Returns true ONLY if the event is explicitly marked as declined by the calendar owner
443
- *
448
+ *
444
449
  * For calendar invitations:
445
450
  * - When YOU decline an event, the event STATUS is still CONFIRMED
446
451
  * - But YOUR PARTSTAT (participation status) becomes DECLINED
447
452
  * - We need to check for PARTSTAT=DECLINED in YOUR ATTENDEE line specifically
448
453
  * - Other attendees declining should NOT filter the event
449
- *
454
+ *
450
455
  * @param {Object} event - The event object to check
451
456
  * @param {string[]} userEmails - Array of user email addresses to check for declined status
452
457
  */
@@ -455,7 +460,7 @@ function isEventDeclined(event, userEmails = []) {
455
460
  const cssClasses = event['css-classes'] || event.css_classes || event.cssClasses || [];
456
461
  const eventType = event.type || '';
457
462
  const ical = event.ical || '';
458
-
463
+
459
464
  // Check css-classes for declined indicator
460
465
  // open-web-calendar uses an array of classes like:
461
466
  // ['event', 'STATUS-CONFIRMED', 'CLASS-PUBLIC', etc.]
@@ -464,14 +469,13 @@ function isEventDeclined(event, userEmails = []) {
464
469
  for (const cls of cssClasses) {
465
470
  const lowerCls = String(cls).toLowerCase();
466
471
  // Only match exact status classes, not just containing 'declined'
467
- if (lowerCls === 'status-declined' ||
468
- lowerCls === 'partstat-declined') {
472
+ if (lowerCls === 'status-declined' || lowerCls === 'partstat-declined') {
469
473
  console.log('[Declined Check] FILTERED - css-class declined:', title);
470
474
  return true;
471
475
  }
472
476
  }
473
477
  }
474
-
478
+
475
479
  // Check the event's own type/status field
476
480
  if (eventType) {
477
481
  const upperType = String(eventType).toUpperCase();
@@ -480,7 +484,7 @@ function isEventDeclined(event, userEmails = []) {
480
484
  return true;
481
485
  }
482
486
  }
483
-
487
+
484
488
  // Check ical field for the USER's PARTSTAT=DECLINED
485
489
  if (ical && typeof ical === 'string') {
486
490
  // Check for STATUS:CANCELLED or STATUS:DECLINED (entire event cancelled)
@@ -489,15 +493,15 @@ function isEventDeclined(event, userEmails = []) {
489
493
  console.log('[Declined Check] FILTERED - event STATUS cancelled:', title);
490
494
  return true;
491
495
  }
492
-
496
+
493
497
  // Check for PARTSTAT=DECLINED in the user's ATTENDEE line
494
498
  // Format: ATTENDEE;...;PARTSTAT=DECLINED;...:mailto:user@email.com
495
499
  // We need to find ATTENDEE lines that contain both PARTSTAT=DECLINED AND a user email
496
-
500
+
497
501
  // Split ical into lines and look for ATTENDEE lines
498
502
  // Note: ATTENDEE lines can be folded (continuation lines start with space)
499
503
  const icalLines = ical.replace(/\r\n /g, '').split(/\r?\n/);
500
-
504
+
501
505
  for (const line of icalLines) {
502
506
  if (line.startsWith('ATTENDEE')) {
503
507
  // Check if this attendee line has PARTSTAT=DECLINED
@@ -505,7 +509,12 @@ function isEventDeclined(event, userEmails = []) {
505
509
  // Check if this is for one of the user's emails
506
510
  for (const userEmail of userEmails) {
507
511
  if (line.toLowerCase().includes(userEmail.toLowerCase())) {
508
- console.log('[Declined Check] FILTERED - user PARTSTAT=DECLINED:', title, '| email:', userEmail);
512
+ console.log(
513
+ '[Declined Check] FILTERED - user PARTSTAT=DECLINED:',
514
+ title,
515
+ '| email:',
516
+ userEmail
517
+ );
509
518
  return true;
510
519
  }
511
520
  }
@@ -513,7 +522,7 @@ function isEventDeclined(event, userEmails = []) {
513
522
  }
514
523
  }
515
524
  }
516
-
525
+
517
526
  // Check if event has explicit status field indicating declined/cancelled
518
527
  const eventStatus = event.event_status || event.status || null;
519
528
  if (eventStatus) {
@@ -523,7 +532,7 @@ function isEventDeclined(event, userEmails = []) {
523
532
  return true;
524
533
  }
525
534
  }
526
-
535
+
527
536
  return false;
528
537
  }
529
538
 
@@ -536,13 +545,13 @@ function filterDeclinedEvents(events, userEmails = []) {
536
545
  if (!Array.isArray(events) || events.length === 0) {
537
546
  return events;
538
547
  }
539
-
548
+
540
549
  console.log('[Filter Declined] Processing', events.length, 'events');
541
-
550
+
542
551
  const filteredEvents = events.filter(event => !isEventDeclined(event, userEmails));
543
-
552
+
544
553
  console.log('[Filter Declined] After filtering:', filteredEvents.length, 'events remain');
545
-
554
+
546
555
  return filteredEvents;
547
556
  }
548
557
 
@@ -556,13 +565,13 @@ function mergeConsecutiveEvents(events, userEmails = []) {
556
565
  if (!Array.isArray(events) || events.length === 0) {
557
566
  return events;
558
567
  }
559
-
568
+
560
569
  // Filter out declined events first
561
570
  const filteredEvents = filterDeclinedEvents(events, userEmails);
562
-
571
+
563
572
  // Sanitize non-PUBLIC events
564
573
  const sanitizedEvents = filteredEvents.map(event => sanitizeNonPublicEvent(event));
565
-
574
+
566
575
  // Group events by calendar identifier
567
576
  const eventsByCalendar = {};
568
577
  for (const event of sanitizedEvents) {
@@ -572,13 +581,13 @@ function mergeConsecutiveEvents(events, userEmails = []) {
572
581
  }
573
582
  eventsByCalendar[calendarId].push(event);
574
583
  }
575
-
584
+
576
585
  const mergedEvents = [];
577
-
586
+
578
587
  // Process each calendar group separately
579
588
  for (const calendarId in eventsByCalendar) {
580
589
  const calendarEvents = eventsByCalendar[calendarId];
581
-
590
+
582
591
  // Sort events by start time
583
592
  calendarEvents.sort((a, b) => {
584
593
  const startA = getEventTime(a, 'start');
@@ -586,16 +595,16 @@ function mergeConsecutiveEvents(events, userEmails = []) {
586
595
  if (!startA || !startB) return 0;
587
596
  return new Date(startA) - new Date(startB);
588
597
  });
589
-
598
+
590
599
  // Merge consecutive events
591
600
  let currentMerge = null;
592
-
601
+
593
602
  for (let i = 0; i < calendarEvents.length; i++) {
594
603
  const event = calendarEvents[i];
595
604
  // Event is already sanitized, but we need to preserve it during merge
596
605
  const startTime = getEventTime(event, 'start');
597
606
  const endTime = getEventTime(event, 'end');
598
-
607
+
599
608
  if (!startTime || !endTime) {
600
609
  // If event is missing time info, add as-is
601
610
  if (currentMerge) {
@@ -605,7 +614,7 @@ function mergeConsecutiveEvents(events, userEmails = []) {
605
614
  mergedEvents.push(event);
606
615
  continue;
607
616
  }
608
-
617
+
609
618
  if (currentMerge === null) {
610
619
  // Start a new merge group
611
620
  currentMerge = { ...event };
@@ -615,12 +624,12 @@ function mergeConsecutiveEvents(events, userEmails = []) {
615
624
  // Normalize dates for comparison (handle different date formats)
616
625
  const normalizedEndTime = normalizeDate(currentEndTime);
617
626
  const normalizedStartTime = normalizeDate(startTime);
618
-
627
+
619
628
  if (normalizedEndTime && normalizedStartTime && normalizedEndTime === normalizedStartTime) {
620
629
  // Check if either event is non-PUBLIC - if so, keep titles empty
621
630
  const currentIsNonPublic = currentMerge._isNonPublic || !isPublicEvent(currentMerge);
622
631
  const eventIsNonPublic = event._isNonPublic || !isPublicEvent(event);
623
-
632
+
624
633
  if (currentIsNonPublic || eventIsNonPublic) {
625
634
  // At least one event is non-PUBLIC, remove ALL identifying information
626
635
  removeAllEventInfo(currentMerge);
@@ -628,10 +637,11 @@ function mergeConsecutiveEvents(events, userEmails = []) {
628
637
  // Both are PUBLIC, merge titles normally
629
638
  const currentTitle = getEventTitle(currentMerge);
630
639
  const eventTitle = getEventTitle(event);
631
- const combinedTitle = currentTitle && eventTitle
632
- ? `${currentTitle} + ${eventTitle}`
633
- : currentTitle || eventTitle;
634
-
640
+ const combinedTitle =
641
+ currentTitle && eventTitle
642
+ ? `${currentTitle} + ${eventTitle}`
643
+ : currentTitle || eventTitle;
644
+
635
645
  // Update title field (try common variations)
636
646
  if (currentMerge.title !== undefined) currentMerge.title = combinedTitle;
637
647
  if (currentMerge.summary !== undefined) currentMerge.summary = combinedTitle;
@@ -639,15 +649,16 @@ function mergeConsecutiveEvents(events, userEmails = []) {
639
649
  if (!currentMerge.title && !currentMerge.summary && !currentMerge.name) {
640
650
  currentMerge.title = combinedTitle;
641
651
  }
642
-
652
+
643
653
  // Combine descriptions
644
654
  const currentDesc = getEventDescription(currentMerge);
645
655
  const eventDesc = getEventDescription(event);
646
656
  if (currentDesc || eventDesc) {
647
- const combinedDesc = currentDesc && eventDesc
648
- ? `${currentDesc}\n\n${eventDesc}`
649
- : currentDesc || eventDesc;
650
-
657
+ const combinedDesc =
658
+ currentDesc && eventDesc
659
+ ? `${currentDesc}\n\n${eventDesc}`
660
+ : currentDesc || eventDesc;
661
+
651
662
  if (currentMerge.description !== undefined) currentMerge.description = combinedDesc;
652
663
  if (currentMerge.desc !== undefined) currentMerge.desc = combinedDesc;
653
664
  if (!currentMerge.description && !currentMerge.desc) {
@@ -655,19 +666,29 @@ function mergeConsecutiveEvents(events, userEmails = []) {
655
666
  }
656
667
  }
657
668
  }
658
-
669
+
659
670
  // 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
+ const originalEndField =
672
+ getEventTime(currentMerge, 'end') !== null
673
+ ? currentMerge.end !== undefined
674
+ ? 'end'
675
+ : currentMerge.endDate !== undefined
676
+ ? 'endDate'
677
+ : currentMerge.end_time !== undefined
678
+ ? 'end_time'
679
+ : currentMerge.endTime !== undefined
680
+ ? 'endTime'
681
+ : currentMerge.dtend !== undefined
682
+ ? 'dtend'
683
+ : currentMerge.end_date !== undefined
684
+ ? 'end_date'
685
+ : currentMerge.dateEnd !== undefined
686
+ ? 'dateEnd'
687
+ : currentMerge.date_end !== undefined
688
+ ? 'date_end'
689
+ : 'end'
690
+ : 'end';
691
+
671
692
  // Update all possible end time fields to ensure compatibility
672
693
  if (currentMerge.end !== undefined) currentMerge.end = endTime;
673
694
  if (currentMerge.endDate !== undefined) currentMerge.endDate = endTime;
@@ -677,9 +698,16 @@ function mergeConsecutiveEvents(events, userEmails = []) {
677
698
  if (currentMerge.end_date !== undefined) currentMerge.end_date = endTime;
678
699
  if (currentMerge.dateEnd !== undefined) currentMerge.dateEnd = endTime;
679
700
  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) {
701
+ if (
702
+ !currentMerge.end &&
703
+ !currentMerge.endDate &&
704
+ !currentMerge.end_time &&
705
+ !currentMerge.endTime &&
706
+ !currentMerge.dtend &&
707
+ !currentMerge.end_date &&
708
+ !currentMerge.dateEnd &&
709
+ !currentMerge.date_end
710
+ ) {
683
711
  currentMerge.end = endTime;
684
712
  }
685
713
  } else {
@@ -689,13 +717,13 @@ function mergeConsecutiveEvents(events, userEmails = []) {
689
717
  }
690
718
  }
691
719
  }
692
-
720
+
693
721
  // Add the last merge group if any
694
722
  if (currentMerge !== null) {
695
723
  mergedEvents.push(currentMerge);
696
724
  }
697
725
  }
698
-
726
+
699
727
  return mergedEvents;
700
728
  }
701
729
 
@@ -707,13 +735,13 @@ function removeCalendarName(calendar) {
707
735
  if (!calendar || typeof calendar !== 'object') {
708
736
  return calendar;
709
737
  }
710
-
738
+
711
739
  // Create a copy of the calendar object without the name field
712
740
  const sanitized = { ...calendar };
713
741
  delete sanitized.name;
714
742
  delete sanitized.calendarName;
715
743
  delete sanitized.title;
716
-
744
+
717
745
  return sanitized;
718
746
  }
719
747
 
@@ -742,12 +770,12 @@ function processCalendarEventsJson(jsonData, userEmails = []) {
742
770
  }
743
771
  }
744
772
  }
745
-
773
+
746
774
  // Debug logging removed - CLASS detection is working
747
775
  } catch (e) {
748
776
  // Ignore errors
749
777
  }
750
-
778
+
751
779
  if (Array.isArray(jsonData)) {
752
780
  // Format: [{event1}, {event2}, ...]
753
781
  return mergeConsecutiveEvents(jsonData, userEmails);
@@ -759,41 +787,41 @@ function processCalendarEventsJson(jsonData, userEmails = []) {
759
787
  const sanitizedRoot = removeCalendarName(jsonData);
760
788
  return {
761
789
  ...sanitizedRoot,
762
- events: mergeConsecutiveEvents(jsonData.events, userEmails)
790
+ events: mergeConsecutiveEvents(jsonData.events, userEmails),
763
791
  };
764
792
  } else if (Array.isArray(jsonData.calendars)) {
765
793
  // Format: {calendars: [{events: [...]}, ...], ...}
766
794
  // Remove calendar name from root level as well
767
795
  const sanitizedRoot = removeCalendarName(jsonData);
768
-
796
+
769
797
  return {
770
798
  ...sanitizedRoot,
771
799
  calendars: jsonData.calendars.map(calendar => {
772
800
  // Remove calendar name from calendar object
773
801
  const sanitizedCalendar = removeCalendarName(calendar);
774
-
802
+
775
803
  if (Array.isArray(sanitizedCalendar.events)) {
776
804
  return {
777
805
  ...sanitizedCalendar,
778
- events: mergeConsecutiveEvents(sanitizedCalendar.events, userEmails)
806
+ events: mergeConsecutiveEvents(sanitizedCalendar.events, userEmails),
779
807
  };
780
808
  }
781
809
  return sanitizedCalendar;
782
- })
810
+ }),
783
811
  };
784
812
  } else if (Array.isArray(jsonData.data)) {
785
813
  // Format: {data: [...], ...}
786
814
  const sanitizedRoot = removeCalendarName(jsonData);
787
815
  return {
788
816
  ...sanitizedRoot,
789
- data: mergeConsecutiveEvents(jsonData.data, userEmails)
817
+ data: mergeConsecutiveEvents(jsonData.data, userEmails),
790
818
  };
791
819
  } else if (Array.isArray(jsonData.items)) {
792
820
  // Format: {items: [...], ...}
793
821
  const sanitizedRoot = removeCalendarName(jsonData);
794
822
  return {
795
823
  ...sanitizedRoot,
796
- items: mergeConsecutiveEvents(jsonData.items, userEmails)
824
+ items: mergeConsecutiveEvents(jsonData.items, userEmails),
797
825
  };
798
826
  }
799
827
  // Unknown structure, try to find any array of events
@@ -802,20 +830,20 @@ function processCalendarEventsJson(jsonData, userEmails = []) {
802
830
  // Check if it looks like events (has time fields)
803
831
  const firstItem = jsonData[key][0];
804
832
  if (firstItem && typeof firstItem === 'object') {
805
- const hasTimeField = getEventTime(firstItem, 'start') !== null ||
806
- getEventTime(firstItem, 'end') !== null;
833
+ const hasTimeField =
834
+ getEventTime(firstItem, 'start') !== null || getEventTime(firstItem, 'end') !== null;
807
835
  if (hasTimeField) {
808
836
  const sanitizedRoot = removeCalendarName(jsonData);
809
837
  return {
810
838
  ...sanitizedRoot,
811
- [key]: mergeConsecutiveEvents(jsonData[key], userEmails)
839
+ [key]: mergeConsecutiveEvents(jsonData[key], userEmails),
812
840
  };
813
841
  }
814
842
  }
815
843
  }
816
844
  }
817
845
  }
818
-
846
+
819
847
  // Unknown format, remove calendar name if present and return
820
848
  if (jsonData && typeof jsonData === 'object') {
821
849
  return removeCalendarName(jsonData);
@@ -832,26 +860,27 @@ function processCalendarEventsJson(jsonData, userEmails = []) {
832
860
  */
833
861
  async function sanitizeResponse(response, pathname, userEmails = []) {
834
862
  const contentType = response.headers.get('content-type') || '';
835
-
863
+
836
864
  // 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', {
865
+ if (
866
+ contentType.includes('text/calendar') ||
867
+ contentType.includes('application/ics') ||
868
+ pathname.endsWith('.ics') ||
869
+ pathname.endsWith('.ICAL') ||
870
+ pathname.endsWith('.iCal')
871
+ ) {
872
+ return new Response('Calendar file download is not allowed', {
843
873
  status: 403,
844
- headers: {
874
+ headers: {
845
875
  'Content-Type': 'text/plain',
846
- 'Access-Control-Allow-Origin': '*'
847
- }
876
+ 'Access-Control-Allow-Origin': '*',
877
+ },
848
878
  });
849
879
  }
850
-
880
+
851
881
  // Only sanitize HTML and JSON responses (error messages)
852
882
  // Skip JavaScript files to avoid breaking code
853
- if (!contentType.includes('text/html') &&
854
- !contentType.includes('application/json')) {
883
+ if (!contentType.includes('text/html') && !contentType.includes('application/json')) {
855
884
  // For non-HTML/JSON responses (including JavaScript), just add CORS header and return
856
885
  const responseHeaders = new Headers(response.headers);
857
886
  responseHeaders.set('Access-Control-Allow-Origin', '*');
@@ -861,20 +890,21 @@ async function sanitizeResponse(response, pathname, userEmails = []) {
861
890
  headers: responseHeaders,
862
891
  });
863
892
  }
864
-
893
+
865
894
  const body = await response.text();
866
-
895
+
867
896
  let sanitizedBody = body;
868
-
897
+
869
898
  // For JSON responses, check if it's a calendar events endpoint
870
899
  if (contentType.includes('application/json')) {
871
900
  // Check for calendar events endpoints - open-web-calendar uses /calendar.json
872
901
  // 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
-
902
+ const isCalendarEventsEndpoint =
903
+ pathname === '/calendar.events.json' ||
904
+ pathname === '/calendar.json' ||
905
+ pathname.endsWith('.events.json') ||
906
+ pathname.endsWith('.json');
907
+
878
908
  if (isCalendarEventsEndpoint) {
879
909
  try {
880
910
  const jsonData = JSON.parse(body);
@@ -886,12 +916,12 @@ async function sanitizeResponse(response, pathname, userEmails = []) {
886
916
  }
887
917
  }
888
918
  }
889
-
919
+
890
920
  // For HTML responses, inject console filtering
891
921
  if (contentType.includes('text/html')) {
892
922
  sanitizedBody = injectConsoleFilter(sanitizedBody);
893
923
  }
894
-
924
+
895
925
  // Patterns to detect and sanitize calendar URLs
896
926
  // Only match complete URLs, not code fragments
897
927
  const urlPatterns = [
@@ -900,14 +930,14 @@ async function sanitizeResponse(response, pathname, userEmails = []) {
900
930
  // Only match %40 when it's part of a URL (followed by domain-like pattern)
901
931
  /%40[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}[^\s"']*/gi,
902
932
  ];
903
-
933
+
904
934
  urlPatterns.forEach(pattern => {
905
935
  sanitizedBody = sanitizedBody.replace(pattern, '[Calendar URL hidden]');
906
936
  });
907
-
937
+
908
938
  const responseHeaders = new Headers(response.headers);
909
939
  responseHeaders.set('Access-Control-Allow-Origin', '*');
910
-
940
+
911
941
  return new Response(sanitizedBody, {
912
942
  status: response.status,
913
943
  statusText: response.statusText,
@@ -919,103 +949,105 @@ export default {
919
949
  async fetch(request, env) {
920
950
  try {
921
951
  const url = new URL(request.url);
922
-
952
+
923
953
  // ENCRYPTION_METHOD is hardcoded to 'fernet'
924
954
  const ENCRYPTION_METHOD = 'fernet';
925
-
955
+
926
956
  const encryptionKey = env.ENCRYPTION_KEY;
927
957
  const calendarUrlSecret = env.CALENDAR_URL; // Read from Cloudflare Worker secret
928
-
958
+
929
959
  // Parse user emails from secret for declined event filtering
930
960
  // USER_EMAILS secret should be comma-separated list of emails
931
961
  const userEmails = parseUserEmails(env.USER_EMAILS);
932
-
962
+
933
963
  if (!calendarUrlSecret) {
934
- return new Response('CALENDAR_URL not configured in Cloudflare Worker secrets', {
964
+ return new Response('CALENDAR_URL not configured in Cloudflare Worker secrets', {
935
965
  status: 500,
936
- headers: { 'Content-Type': 'text/plain' }
966
+ headers: { 'Content-Type': 'text/plain' },
937
967
  });
938
968
  }
939
-
969
+
940
970
  // Parse calendar URLs from secret (comma-separated, can be plain or fernet://)
941
971
  const calendarUrlsFromSecret = calendarUrlSecret
942
972
  .split(',')
943
973
  .map(s => s.trim())
944
974
  .filter(s => s);
945
-
975
+
946
976
  // Use calendar URLs from secret (they may be fernet:// encrypted or plain)
947
977
  const calendarUrls = calendarUrlsFromSecret;
948
-
978
+
949
979
  // Check if any of the URLs are fernet:// encrypted
950
980
  const hasFernetUrls = calendarUrls.some(url => url.startsWith('fernet://'));
951
-
981
+
952
982
  // If we have fernet:// URLs, we need ENCRYPTION_KEY
953
983
  if (hasFernetUrls && !encryptionKey) {
954
- return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
984
+ return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
955
985
  status: 500,
956
- headers: { 'Content-Type': 'text/plain' }
986
+ headers: { 'Content-Type': 'text/plain' },
957
987
  });
958
988
  }
959
-
989
+
960
990
  // Get the pathname to determine request type
961
991
  const pathname = url.pathname;
962
-
992
+
963
993
  // Block ICS file downloads - prevent access to raw calendar files
964
994
  if (pathname.endsWith('.ics') || pathname.endsWith('.ICAL') || pathname.endsWith('.iCal')) {
965
- return new Response('Calendar file download is not allowed', {
995
+ return new Response('Calendar file download is not allowed', {
966
996
  status: 403,
967
- headers: {
997
+ headers: {
968
998
  'Content-Type': 'text/plain',
969
- 'Access-Control-Allow-Origin': '*'
970
- }
999
+ 'Access-Control-Allow-Origin': '*',
1000
+ },
971
1001
  });
972
1002
  }
973
-
1003
+
974
1004
  // Check if this is the main calendar page request
975
- const isMainCalendarPage = pathname === '/' ||
976
- pathname === '/calendar.html' ||
977
- pathname.endsWith('/calendar.html') ||
978
- pathname === '';
979
-
1005
+ const isMainCalendarPage =
1006
+ pathname === '/' ||
1007
+ pathname === '/calendar.html' ||
1008
+ pathname.endsWith('/calendar.html') ||
1009
+ pathname === '';
1010
+
980
1011
  // Check if this is an API endpoint that needs calendar URLs
981
1012
  // This includes /srcdoc, /calendar.events.json, /calendar.json, etc.
982
- const isApiEndpoint = pathname === '/srcdoc' ||
983
- pathname.startsWith('/srcdoc') ||
984
- pathname === '/calendar.events.json' ||
985
- pathname === '/calendar.json' ||
986
- pathname.endsWith('.events.json') ||
987
- pathname.endsWith('.json');
988
-
1013
+ const isApiEndpoint =
1014
+ pathname === '/srcdoc' ||
1015
+ pathname.startsWith('/srcdoc') ||
1016
+ pathname === '/calendar.events.json' ||
1017
+ pathname === '/calendar.json' ||
1018
+ pathname.endsWith('.events.json') ||
1019
+ pathname.endsWith('.json');
1020
+
989
1021
  // For API endpoints like /srcdoc, always use calendar URLs from secret
990
1022
  if (isApiEndpoint) {
991
1023
  // Build the target URL
992
1024
  const targetUrl = new URL(`https://open-web-calendar.hosted.quelltext.eu${pathname}`);
993
-
1025
+
994
1026
  // Copy all query parameters except 'url'
995
1027
  for (const [key, value] of url.searchParams.entries()) {
996
1028
  if (key !== 'url') {
997
1029
  targetUrl.searchParams.append(key, value);
998
1030
  }
999
1031
  }
1000
-
1032
+
1001
1033
  // Decrypt and add calendar URLs from secret
1002
1034
  // ENCRYPTION_METHOD is hardcoded to 'fernet'
1003
1035
  const decryptedUrls = [];
1004
1036
  for (const urlParam of calendarUrls) {
1005
1037
  if (urlParam.startsWith('fernet://')) {
1006
1038
  if (!encryptionKey) {
1007
- return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
1039
+ return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
1008
1040
  status: 500,
1009
- headers: { 'Content-Type': 'text/plain' }
1041
+ headers: { 'Content-Type': 'text/plain' },
1010
1042
  });
1011
1043
  }
1012
1044
  try {
1013
1045
  const decrypted = await decryptFernetUrl(urlParam, encryptionKey);
1014
1046
  decryptedUrls.push(decrypted);
1015
1047
  } catch (error) {
1016
- return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
1048
+ return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
1017
1049
  status: 500,
1018
- headers: { 'Content-Type': 'text/plain' }
1050
+ headers: { 'Content-Type': 'text/plain' },
1019
1051
  });
1020
1052
  }
1021
1053
  } else {
@@ -1023,29 +1055,33 @@ export default {
1023
1055
  decryptedUrls.push(urlParam);
1024
1056
  }
1025
1057
  }
1026
-
1058
+
1027
1059
  // Add decrypted URLs
1028
1060
  for (const decryptedUrl of decryptedUrls) {
1029
1061
  targetUrl.searchParams.append('url', decryptedUrl);
1030
1062
  }
1031
-
1063
+
1032
1064
  // Forward all headers from the original request
1033
1065
  const requestHeaders = new Headers();
1034
1066
  request.headers.forEach((value, key) => {
1035
- if (key.toLowerCase() !== 'host' && key.toLowerCase() !== 'cf-ray' && key.toLowerCase() !== 'cf-connecting-ip') {
1067
+ if (
1068
+ key.toLowerCase() !== 'host' &&
1069
+ key.toLowerCase() !== 'cf-ray' &&
1070
+ key.toLowerCase() !== 'cf-connecting-ip'
1071
+ ) {
1036
1072
  requestHeaders.set(key, value);
1037
1073
  }
1038
1074
  });
1039
-
1075
+
1040
1076
  const response = await fetch(targetUrl.toString(), {
1041
1077
  headers: requestHeaders,
1042
1078
  });
1043
-
1079
+
1044
1080
  // Sanitize response to hide calendar URLs in error messages
1045
1081
  // Pass pathname for calendar events processing and user emails for declined filtering
1046
1082
  return await sanitizeResponse(response, pathname, userEmails);
1047
1083
  }
1048
-
1084
+
1049
1085
  // Handle main calendar page requests - always add calendar URLs from secret
1050
1086
  if (isMainCalendarPage) {
1051
1087
  // Decrypt calendar URLs from secret
@@ -1054,18 +1090,18 @@ export default {
1054
1090
  for (const urlParam of calendarUrls) {
1055
1091
  if (urlParam.startsWith('fernet://')) {
1056
1092
  if (!encryptionKey) {
1057
- return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
1093
+ return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
1058
1094
  status: 500,
1059
- headers: { 'Content-Type': 'text/plain' }
1095
+ headers: { 'Content-Type': 'text/plain' },
1060
1096
  });
1061
1097
  }
1062
1098
  try {
1063
1099
  const decrypted = await decryptFernetUrl(urlParam, encryptionKey);
1064
1100
  decryptedUrls.push(decrypted);
1065
1101
  } catch (error) {
1066
- return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
1102
+ return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
1067
1103
  status: 500,
1068
- headers: { 'Content-Type': 'text/plain' }
1104
+ headers: { 'Content-Type': 'text/plain' },
1069
1105
  });
1070
1106
  }
1071
1107
  } else {
@@ -1073,62 +1109,68 @@ export default {
1073
1109
  decryptedUrls.push(urlParam);
1074
1110
  }
1075
1111
  }
1076
-
1112
+
1077
1113
  // Build the open-web-calendar URL with decrypted URLs
1078
1114
  const calendarUrl = new URL('https://open-web-calendar.hosted.quelltext.eu/calendar.html');
1079
-
1115
+
1080
1116
  // Copy all query parameters except 'url' (calendar URLs come from secret)
1081
1117
  for (const [key, value] of url.searchParams.entries()) {
1082
1118
  if (key !== 'url') {
1083
1119
  calendarUrl.searchParams.append(key, value);
1084
1120
  }
1085
1121
  }
1086
-
1122
+
1087
1123
  // Add decrypted URLs from secret
1088
1124
  for (const decryptedUrl of decryptedUrls) {
1089
1125
  calendarUrl.searchParams.append('url', decryptedUrl);
1090
1126
  }
1091
-
1127
+
1092
1128
  // Fetch from open-web-calendar
1093
1129
  const response = await fetch(calendarUrl.toString(), {
1094
1130
  headers: {
1095
1131
  'User-Agent': request.headers.get('User-Agent') || 'Cloudflare-Worker',
1096
1132
  },
1097
1133
  });
1098
-
1134
+
1099
1135
  // Sanitize response to hide calendar URLs in error messages
1100
1136
  // Pass pathname for calendar events processing
1101
1137
  return await sanitizeResponse(response, pathname, userEmails);
1102
1138
  }
1103
-
1139
+
1104
1140
  // For all other requests (static resources, etc.), proxy directly
1105
1141
  let targetPath = pathname;
1106
1142
  if (pathname === '/' || pathname === '') {
1107
1143
  targetPath = '/calendar.html';
1108
1144
  }
1109
-
1110
- const targetUrl = new URL(`https://open-web-calendar.hosted.quelltext.eu${targetPath}${url.search}`);
1111
-
1145
+
1146
+ const targetUrl = new URL(
1147
+ `https://open-web-calendar.hosted.quelltext.eu${targetPath}${url.search}`
1148
+ );
1149
+
1112
1150
  // Forward all headers from the original request
1113
1151
  const requestHeaders = new Headers();
1114
1152
  request.headers.forEach((value, key) => {
1115
1153
  // Skip certain headers that shouldn't be forwarded
1116
- if (key.toLowerCase() !== 'host' && key.toLowerCase() !== 'cf-ray' && key.toLowerCase() !== 'cf-connecting-ip') {
1154
+ if (
1155
+ key.toLowerCase() !== 'host' &&
1156
+ key.toLowerCase() !== 'cf-ray' &&
1157
+ key.toLowerCase() !== 'cf-connecting-ip'
1158
+ ) {
1117
1159
  requestHeaders.set(key, value);
1118
1160
  }
1119
1161
  });
1120
-
1162
+
1121
1163
  const response = await fetch(targetUrl.toString(), {
1122
1164
  headers: requestHeaders,
1123
1165
  });
1124
-
1166
+
1125
1167
  // Sanitize response to hide calendar URLs in error messages
1126
1168
  // Pass pathname for calendar events processing
1127
1169
  return await sanitizeResponse(response, pathname, userEmails);
1128
1170
  } catch (error) {
1129
- return new Response(`Error: ${error.message}`, {
1171
+ return new Response(`Error: ${error.message}`, {
1130
1172
  status: 500,
1131
- headers: { 'Content-Type': 'text/plain' }
1173
+ headers: { 'Content-Type': 'text/plain' },
1132
1174
  });
1133
1175
  }
1134
1176
  },