@dytsou/calendar-build 2.0.0 → 2.1.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dytsou/calendar-build",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Build script for calendar HTML from template and environment variables",
5
5
  "main": "scripts/build.js",
6
6
  "bin": {
@@ -8,8 +8,12 @@
8
8
  },
9
9
  "files": [
10
10
  "scripts/build.js",
11
+ "scripts/encrypt-urls.js",
12
+ "worker.js",
13
+ "wrangler.toml.example",
11
14
  "index.html.template",
12
- "README.md"
15
+ "README.md",
16
+ "LICENSE"
13
17
  ],
14
18
  "keywords": [
15
19
  "calendar",
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Helper script to encrypt calendar URLs
5
+ * Usage: node encrypt-urls.js <url1> <url2> ...
6
+ * Or set CALENDAR_URL in .env and run: node encrypt-urls.js
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const Fernet = require('fernet');
12
+
13
+ // Read .env file
14
+ const envPath = path.join(__dirname, '..', '.env');
15
+ let encryptionKey = '';
16
+ let calendarUrls = [];
17
+
18
+ if (fs.existsSync(envPath)) {
19
+ const envContent = fs.readFileSync(envPath, 'utf-8');
20
+ const envLines = envContent.split('\n');
21
+
22
+ for (const line of envLines) {
23
+ if (line.startsWith('ENCRYPTION_KEY=')) {
24
+ encryptionKey = line.substring('ENCRYPTION_KEY='.length).trim();
25
+ } else if (line.startsWith('CALENDAR_URL=')) {
26
+ const value = line.substring('CALENDAR_URL='.length).trim();
27
+ calendarUrls = value
28
+ .split(',')
29
+ .map(s => s.trim())
30
+ .filter(s => s);
31
+ }
32
+ }
33
+ }
34
+
35
+ // Get URLs from command line arguments if provided
36
+ const args = process.argv.slice(2);
37
+ if (args.length > 0) {
38
+ calendarUrls = args;
39
+ }
40
+
41
+ if (calendarUrls.length === 0) {
42
+ console.error('Error: No calendar URLs provided');
43
+ console.error('Usage: node encrypt-urls.js <url1> <url2> ...');
44
+ console.error('Or set CALENDAR_URL in .env file');
45
+ process.exit(1);
46
+ }
47
+
48
+ if (!encryptionKey) {
49
+ console.error('Error: ENCRYPTION_KEY not found in .env file');
50
+ console.error('Please set ENCRYPTION_KEY in .env file');
51
+ process.exit(1);
52
+ }
53
+
54
+ try {
55
+ const secret = new Fernet.Secret(encryptionKey);
56
+ const token = new Fernet.Token({
57
+ secret: secret,
58
+ ttl: 0, // No expiration
59
+ });
60
+
61
+ const encryptedUrls = calendarUrls.map(url => {
62
+ return token.encode(url);
63
+ });
64
+
65
+ console.log('\nšŸ“‹ Encrypted URLs:\n');
66
+ console.log('CALENDAR_URL_ENCRYPTED=' + encryptedUrls.join(','));
67
+ console.log('\nOr individually:');
68
+ encryptedUrls.forEach((encrypted, index) => {
69
+ console.log(`# URL ${index + 1}: ${encrypted}`);
70
+ });
71
+ console.log('\nāœ… Copy the encrypted URLs to your .env file\n');
72
+ } catch (error) {
73
+ console.error('Error encrypting URLs:', error.message);
74
+ process.exit(1);
75
+ }
76
+
package/worker.js ADDED
@@ -0,0 +1,762 @@
1
+ /**
2
+ * Cloudflare Worker to decrypt fernet:// URLs and proxy to open-web-calendar
3
+ *
4
+ * This worker:
5
+ * 1. Reads CALENDAR_URL from Cloudflare Worker secrets (comma-separated)
6
+ * 2. Decrypts fernet:// URLs using Fernet encryption (ENCRYPTION_METHOD is hardcoded to 'fernet')
7
+ * 3. Forwards the request to open-web-calendar with decrypted URLs
8
+ * 4. Returns the calendar HTML
9
+ *
10
+ * Configuration:
11
+ * - ENCRYPTION_METHOD: Hardcoded to 'fernet' (not configurable)
12
+ * - ENCRYPTION_KEY: Required Cloudflare Worker secret (for decrypting fernet:// URLs)
13
+ * - CALENDAR_URL: Required Cloudflare Worker secret (comma-separated, can be plain or fernet:// encrypted)
14
+ *
15
+ * Usage:
16
+ * 1. Set CALENDAR_URL secret: wrangler secret put CALENDAR_URL
17
+ * 2. Set ENCRYPTION_KEY secret: wrangler secret put ENCRYPTION_KEY
18
+ * 3. Deploy: wrangler deploy
19
+ */
20
+
21
+ // Import Fernet library - we'll need to bundle this for Workers
22
+ // For now, we'll implement a basic Fernet decoder using Web Crypto API
23
+
24
+ /**
25
+ * Decode base64url string
26
+ */
27
+ function base64UrlDecode(str) {
28
+ // Convert base64url to base64
29
+ let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
30
+ // Add padding if needed
31
+ while (base64.length % 4) {
32
+ base64 += '=';
33
+ }
34
+ // Decode
35
+ const binaryString = atob(base64);
36
+ const bytes = new Uint8Array(binaryString.length);
37
+ for (let i = 0; i < binaryString.length; i++) {
38
+ bytes[i] = binaryString.charCodeAt(i);
39
+ }
40
+ return bytes;
41
+ }
42
+
43
+ /**
44
+ * Decode Fernet token
45
+ * Fernet token format: Version (1 byte) + Timestamp (8 bytes) + IV (16 bytes) + Ciphertext + HMAC (32 bytes)
46
+ */
47
+ async function decryptFernetToken(token, secretKey) {
48
+ try {
49
+ // Decode the token
50
+ const tokenBytes = base64UrlDecode(token);
51
+
52
+ if (tokenBytes.length < 57) { // Minimum: 1 + 8 + 16 + 0 + 32 = 57 bytes
53
+ throw new Error('Invalid Fernet token: too short');
54
+ }
55
+
56
+ // Extract components
57
+ const version = tokenBytes[0];
58
+ if (version !== 0x80) {
59
+ throw new Error('Invalid Fernet token: unsupported version');
60
+ }
61
+
62
+ const timestamp = tokenBytes.slice(1, 9);
63
+ const iv = tokenBytes.slice(9, 25);
64
+ const hmac = tokenBytes.slice(-32);
65
+ const ciphertext = tokenBytes.slice(25, -32);
66
+
67
+ // Derive signing key and encryption key from secret
68
+ // Fernet secret is base64url-encoded 32-byte key
69
+ // The library splits it directly: first 16 bytes = signing key, last 16 bytes = encryption key
70
+ // NO SHA256 hashing is used!
71
+ let secretBytes;
72
+ try {
73
+ secretBytes = base64UrlDecode(secretKey);
74
+ if (secretBytes.length !== 32) {
75
+ throw new Error(`Invalid secret key length: ${secretBytes.length}, expected 32`);
76
+ }
77
+ } catch (error) {
78
+ throw new Error(`Failed to decode secret key: ${error.message}`);
79
+ }
80
+
81
+ // Fernet splits the 32-byte secret directly:
82
+ // Signing key: first 16 bytes (128 bits)
83
+ // Encryption key: last 16 bytes (128 bits)
84
+ const signingKey = secretBytes.slice(0, 16);
85
+ const encryptionKey = secretBytes.slice(16, 32);
86
+
87
+ // Verify HMAC
88
+ // HMAC message is: version (1 byte) + timestamp (8 bytes) + IV (16 bytes) + ciphertext
89
+ const message = tokenBytes.slice(0, -32);
90
+ const hmacKey = await crypto.subtle.importKey(
91
+ 'raw',
92
+ signingKey,
93
+ { name: 'HMAC', hash: 'SHA-256' },
94
+ false,
95
+ ['sign', 'verify']
96
+ );
97
+
98
+ const computedHmac = await crypto.subtle.sign('HMAC', hmacKey, message);
99
+ const computedHmacBytes = new Uint8Array(computedHmac);
100
+
101
+ // Constant-time comparison
102
+ let hmacValid = true;
103
+ if (computedHmacBytes.length !== hmac.length) {
104
+ hmacValid = false;
105
+ } else {
106
+ for (let i = 0; i < 32; i++) {
107
+ if (computedHmacBytes[i] !== hmac[i]) {
108
+ hmacValid = false;
109
+ }
110
+ }
111
+ }
112
+
113
+ if (!hmacValid) {
114
+ throw new Error('Invalid Fernet token: HMAC verification failed');
115
+ }
116
+
117
+ // Decrypt using AES-128-CBC
118
+ const cryptoKey = await crypto.subtle.importKey(
119
+ 'raw',
120
+ encryptionKey,
121
+ { name: 'AES-CBC' },
122
+ false,
123
+ ['decrypt']
124
+ );
125
+
126
+ const decrypted = await crypto.subtle.decrypt(
127
+ { name: 'AES-CBC', iv: iv },
128
+ cryptoKey,
129
+ ciphertext
130
+ );
131
+
132
+ // Decode the decrypted message (PKCS7 padding will be removed automatically)
133
+ const decoder = new TextDecoder();
134
+ return decoder.decode(decrypted);
135
+ } catch (error) {
136
+ throw new Error(`Fernet decryption failed: ${error.message}`);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Decrypt a fernet:// URL
142
+ */
143
+ async function decryptFernetUrl(fernetUrl, secretKey) {
144
+ // Remove fernet:// prefix
145
+ const token = fernetUrl.startsWith('fernet://')
146
+ ? fernetUrl.substring(9)
147
+ : fernetUrl;
148
+
149
+ return await decryptFernetToken(token, secretKey);
150
+ }
151
+
152
+ /**
153
+ * Inject console filtering script into HTML to block calendar info logs
154
+ */
155
+ function injectConsoleFilter(html) {
156
+ const consoleFilterScript = `
157
+ <script>
158
+ (function() {
159
+ const originalLog = console.log;
160
+ const originalInfo = console.info;
161
+
162
+ function containsCalendarInfo(args) {
163
+ const message = args.map(arg => {
164
+ if (typeof arg === 'string') {
165
+ return arg;
166
+ } else if (arg && typeof arg === 'object') {
167
+ try {
168
+ return JSON.stringify(arg);
169
+ } catch (e) {
170
+ return String(arg);
171
+ }
172
+ }
173
+ return String(arg);
174
+ }).join(' ');
175
+
176
+ if (message.includes('Calendar Info:') ||
177
+ message.includes('calendars:') ||
178
+ message.includes('calendar_index:') ||
179
+ message.includes('url_index:')) {
180
+ return true;
181
+ }
182
+
183
+ for (const arg of args) {
184
+ if (arg && typeof arg === 'object') {
185
+ const keys = Object.keys(arg);
186
+ if (keys.includes('calendars') ||
187
+ keys.includes('calendar_index') ||
188
+ keys.includes('url_index') ||
189
+ (arg.calendars && Array.isArray(arg.calendars))) {
190
+ return true;
191
+ }
192
+ }
193
+ }
194
+
195
+ return false;
196
+ }
197
+
198
+ console.log = function(...args) {
199
+ if (containsCalendarInfo(args)) {
200
+ return; // Don't log calendar info
201
+ }
202
+ originalLog.apply(console, args);
203
+ };
204
+
205
+ console.info = function(...args) {
206
+ if (containsCalendarInfo(args)) {
207
+ return; // Don't log calendar info
208
+ }
209
+ originalInfo.apply(console, args);
210
+ };
211
+ })();
212
+ </script>`;
213
+
214
+ // Inject the script right after <head> or at the beginning of <body>
215
+ if (html.includes('</head>')) {
216
+ return html.replace('</head>', consoleFilterScript + '</head>');
217
+ } else if (html.includes('<body')) {
218
+ const bodyMatch = html.match(/<body[^>]*>/);
219
+ if (bodyMatch) {
220
+ return html.replace(bodyMatch[0], bodyMatch[0] + consoleFilterScript);
221
+ }
222
+ }
223
+
224
+ // Fallback: inject at the very beginning
225
+ return consoleFilterScript + html;
226
+ }
227
+
228
+ /**
229
+ * Normalize date to ISO string for comparison
230
+ */
231
+ function normalizeDate(dateValue) {
232
+ if (!dateValue) return null;
233
+ if (typeof dateValue === 'string') {
234
+ // Parse and return ISO string
235
+ const date = new Date(dateValue);
236
+ return isNaN(date.getTime()) ? null : date.toISOString();
237
+ }
238
+ if (dateValue instanceof Date) {
239
+ return dateValue.toISOString();
240
+ }
241
+ // Try to convert to date
242
+ const date = new Date(dateValue);
243
+ return isNaN(date.getTime()) ? null : date.toISOString();
244
+ }
245
+
246
+ /**
247
+ * Extract event time fields (handles various field name formats)
248
+ */
249
+ function getEventTime(event, field) {
250
+ // Try common field name variations (including iCal and calendar.js formats)
251
+ const variations = {
252
+ start: ['start', 'startDate', 'start_time', 'startTime', 'dtstart', 'start_date', 'dateStart', 'date_start'],
253
+ end: ['end', 'endDate', 'end_time', 'endTime', 'dtend', 'end_date', 'dateEnd', 'date_end']
254
+ };
255
+
256
+ const fieldNames = variations[field] || [field];
257
+ for (const name of fieldNames) {
258
+ if (event[name] !== undefined && event[name] !== null) {
259
+ return event[name];
260
+ }
261
+ }
262
+ return null;
263
+ }
264
+
265
+ /**
266
+ * Extract event title (handles various field name formats)
267
+ */
268
+ function getEventTitle(event) {
269
+ return event.title || event.summary || event.name || '';
270
+ }
271
+
272
+ /**
273
+ * Extract event description (handles various field name formats)
274
+ */
275
+ function getEventDescription(event) {
276
+ return event.description || event.desc || '';
277
+ }
278
+
279
+ /**
280
+ * Extract calendar identifier (handles various field name formats)
281
+ */
282
+ function getCalendarId(event) {
283
+ return event.calendar || event.calendarId || event.calendar_id || event.source || 'default';
284
+ }
285
+
286
+ /**
287
+ * Merge consecutive events within the same calendar
288
+ * Events are consecutive if event1.end === event2.start (exact match)
289
+ */
290
+ function mergeConsecutiveEvents(events) {
291
+ if (!Array.isArray(events) || events.length === 0) {
292
+ return events;
293
+ }
294
+
295
+ // Group events by calendar identifier
296
+ const eventsByCalendar = {};
297
+ for (const event of events) {
298
+ const calendarId = getCalendarId(event);
299
+ if (!eventsByCalendar[calendarId]) {
300
+ eventsByCalendar[calendarId] = [];
301
+ }
302
+ eventsByCalendar[calendarId].push(event);
303
+ }
304
+
305
+ const mergedEvents = [];
306
+
307
+ // Process each calendar group separately
308
+ for (const calendarId in eventsByCalendar) {
309
+ const calendarEvents = eventsByCalendar[calendarId];
310
+
311
+ // Sort events by start time
312
+ calendarEvents.sort((a, b) => {
313
+ const startA = getEventTime(a, 'start');
314
+ const startB = getEventTime(b, 'start');
315
+ if (!startA || !startB) return 0;
316
+ return new Date(startA) - new Date(startB);
317
+ });
318
+
319
+ // Merge consecutive events
320
+ let currentMerge = null;
321
+
322
+ for (let i = 0; i < calendarEvents.length; i++) {
323
+ const event = calendarEvents[i];
324
+ const startTime = getEventTime(event, 'start');
325
+ const endTime = getEventTime(event, 'end');
326
+
327
+ if (!startTime || !endTime) {
328
+ // If event is missing time info, add as-is
329
+ if (currentMerge) {
330
+ mergedEvents.push(currentMerge);
331
+ currentMerge = null;
332
+ }
333
+ mergedEvents.push(event);
334
+ continue;
335
+ }
336
+
337
+ if (currentMerge === null) {
338
+ // Start a new merge group
339
+ currentMerge = { ...event };
340
+ } else {
341
+ // Check if this event is consecutive to the current merge
342
+ const currentEndTime = getEventTime(currentMerge, 'end');
343
+ // Normalize dates for comparison (handle different date formats)
344
+ const normalizedEndTime = normalizeDate(currentEndTime);
345
+ const normalizedStartTime = normalizeDate(startTime);
346
+
347
+ if (normalizedEndTime && normalizedStartTime && normalizedEndTime === normalizedStartTime) {
348
+ // Merge: combine titles and extend end time
349
+ const currentTitle = getEventTitle(currentMerge);
350
+ const eventTitle = getEventTitle(event);
351
+ const combinedTitle = currentTitle && eventTitle
352
+ ? `${currentTitle} + ${eventTitle}`
353
+ : currentTitle || eventTitle;
354
+
355
+ // Update title field (try common variations)
356
+ if (currentMerge.title !== undefined) currentMerge.title = combinedTitle;
357
+ if (currentMerge.summary !== undefined) currentMerge.summary = combinedTitle;
358
+ if (currentMerge.name !== undefined) currentMerge.name = combinedTitle;
359
+ if (!currentMerge.title && !currentMerge.summary && !currentMerge.name) {
360
+ currentMerge.title = combinedTitle;
361
+ }
362
+
363
+ // Combine descriptions
364
+ const currentDesc = getEventDescription(currentMerge);
365
+ const eventDesc = getEventDescription(event);
366
+ if (currentDesc || eventDesc) {
367
+ const combinedDesc = currentDesc && eventDesc
368
+ ? `${currentDesc}\n\n${eventDesc}`
369
+ : currentDesc || eventDesc;
370
+
371
+ if (currentMerge.description !== undefined) currentMerge.description = combinedDesc;
372
+ if (currentMerge.desc !== undefined) currentMerge.desc = combinedDesc;
373
+ if (!currentMerge.description && !currentMerge.desc) {
374
+ currentMerge.description = combinedDesc;
375
+ }
376
+ }
377
+
378
+ // Update end time - preserve original field name format
379
+ const originalEndField = getEventTime(currentMerge, 'end') !== null
380
+ ? (currentMerge.end !== undefined ? 'end' :
381
+ currentMerge.endDate !== undefined ? 'endDate' :
382
+ currentMerge.end_time !== undefined ? 'end_time' :
383
+ currentMerge.endTime !== undefined ? 'endTime' :
384
+ currentMerge.dtend !== undefined ? 'dtend' :
385
+ currentMerge.end_date !== undefined ? 'end_date' :
386
+ currentMerge.dateEnd !== undefined ? 'dateEnd' :
387
+ currentMerge.date_end !== undefined ? 'date_end' : 'end')
388
+ : 'end';
389
+
390
+ // Update all possible end time fields to ensure compatibility
391
+ if (currentMerge.end !== undefined) currentMerge.end = endTime;
392
+ if (currentMerge.endDate !== undefined) currentMerge.endDate = endTime;
393
+ if (currentMerge.end_time !== undefined) currentMerge.end_time = endTime;
394
+ if (currentMerge.endTime !== undefined) currentMerge.endTime = endTime;
395
+ if (currentMerge.dtend !== undefined) currentMerge.dtend = endTime;
396
+ if (currentMerge.end_date !== undefined) currentMerge.end_date = endTime;
397
+ if (currentMerge.dateEnd !== undefined) currentMerge.dateEnd = endTime;
398
+ if (currentMerge.date_end !== undefined) currentMerge.date_end = endTime;
399
+ if (!currentMerge.end && !currentMerge.endDate && !currentMerge.end_time &&
400
+ !currentMerge.endTime && !currentMerge.dtend && !currentMerge.end_date &&
401
+ !currentMerge.dateEnd && !currentMerge.date_end) {
402
+ currentMerge.end = endTime;
403
+ }
404
+ } else {
405
+ // Not consecutive, save current merge and start new one
406
+ mergedEvents.push(currentMerge);
407
+ currentMerge = { ...event };
408
+ }
409
+ }
410
+ }
411
+
412
+ // Add the last merge group if any
413
+ if (currentMerge !== null) {
414
+ mergedEvents.push(currentMerge);
415
+ }
416
+ }
417
+
418
+ return mergedEvents;
419
+ }
420
+
421
+ /**
422
+ * Process calendar events JSON and merge consecutive events
423
+ */
424
+ function processCalendarEventsJson(jsonData) {
425
+ if (Array.isArray(jsonData)) {
426
+ // Format: [{event1}, {event2}, ...]
427
+ return mergeConsecutiveEvents(jsonData);
428
+ } else if (jsonData && typeof jsonData === 'object') {
429
+ // Check for nested structures
430
+ if (Array.isArray(jsonData.events)) {
431
+ // Format: {events: [...], ...}
432
+ return {
433
+ ...jsonData,
434
+ events: mergeConsecutiveEvents(jsonData.events)
435
+ };
436
+ } else if (Array.isArray(jsonData.calendars)) {
437
+ // Format: {calendars: [{events: [...]}, ...], ...}
438
+ return {
439
+ ...jsonData,
440
+ calendars: jsonData.calendars.map(calendar => {
441
+ if (Array.isArray(calendar.events)) {
442
+ return {
443
+ ...calendar,
444
+ events: mergeConsecutiveEvents(calendar.events)
445
+ };
446
+ }
447
+ return calendar;
448
+ })
449
+ };
450
+ } else if (Array.isArray(jsonData.data)) {
451
+ // Format: {data: [...], ...}
452
+ return {
453
+ ...jsonData,
454
+ data: mergeConsecutiveEvents(jsonData.data)
455
+ };
456
+ } else if (Array.isArray(jsonData.items)) {
457
+ // Format: {items: [...], ...}
458
+ return {
459
+ ...jsonData,
460
+ items: mergeConsecutiveEvents(jsonData.items)
461
+ };
462
+ }
463
+ // Unknown structure, try to find any array of events
464
+ for (const key in jsonData) {
465
+ if (Array.isArray(jsonData[key]) && jsonData[key].length > 0) {
466
+ // Check if it looks like events (has time fields)
467
+ const firstItem = jsonData[key][0];
468
+ if (firstItem && typeof firstItem === 'object') {
469
+ const hasTimeField = getEventTime(firstItem, 'start') !== null ||
470
+ getEventTime(firstItem, 'end') !== null;
471
+ if (hasTimeField) {
472
+ return {
473
+ ...jsonData,
474
+ [key]: mergeConsecutiveEvents(jsonData[key])
475
+ };
476
+ }
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ // Unknown format, return as-is
483
+ return jsonData;
484
+ }
485
+
486
+ /**
487
+ * Sanitize response body to hide calendar URLs in error messages
488
+ * Only sanitizes HTML and JSON responses to avoid breaking JavaScript code
489
+ */
490
+ async function sanitizeResponse(response, pathname) {
491
+ const contentType = response.headers.get('content-type') || '';
492
+
493
+ // Only sanitize HTML and JSON responses (error messages)
494
+ // Skip JavaScript files to avoid breaking code
495
+ if (!contentType.includes('text/html') &&
496
+ !contentType.includes('application/json')) {
497
+ // For non-HTML/JSON responses (including JavaScript), just add CORS header and return
498
+ const responseHeaders = new Headers(response.headers);
499
+ responseHeaders.set('Access-Control-Allow-Origin', '*');
500
+ return new Response(response.body, {
501
+ status: response.status,
502
+ statusText: response.statusText,
503
+ headers: responseHeaders,
504
+ });
505
+ }
506
+
507
+ const body = await response.text();
508
+
509
+ let sanitizedBody = body;
510
+
511
+ // For JSON responses, check if it's a calendar events endpoint
512
+ if (contentType.includes('application/json')) {
513
+ // Check for calendar events endpoints - open-web-calendar uses /calendar.json
514
+ // Also check for any .json file that might contain calendar events
515
+ const isCalendarEventsEndpoint = pathname === '/calendar.events.json' ||
516
+ pathname === '/calendar.json' ||
517
+ pathname.endsWith('.events.json') ||
518
+ pathname.endsWith('.json');
519
+
520
+ if (isCalendarEventsEndpoint) {
521
+ try {
522
+ const jsonData = JSON.parse(body);
523
+ const processedData = processCalendarEventsJson(jsonData);
524
+ sanitizedBody = JSON.stringify(processedData);
525
+ } catch (error) {
526
+ // If JSON parsing fails, continue with original body
527
+ console.error('Failed to parse calendar events JSON:', error);
528
+ }
529
+ }
530
+ }
531
+
532
+ // For HTML responses, inject console filtering
533
+ if (contentType.includes('text/html')) {
534
+ sanitizedBody = injectConsoleFilter(sanitizedBody);
535
+ }
536
+
537
+ // Patterns to detect and sanitize calendar URLs
538
+ // Only match complete URLs, not code fragments
539
+ const urlPatterns = [
540
+ /https?:\/\/[^\s"']+\.ics/gi,
541
+ /https?:\/\/calendar\.google\.com\/calendar\/ical\/[^\s"']+/gi,
542
+ // Only match %40 when it's part of a URL (followed by domain-like pattern)
543
+ /%40[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}[^\s"']*/gi,
544
+ ];
545
+
546
+ urlPatterns.forEach(pattern => {
547
+ sanitizedBody = sanitizedBody.replace(pattern, '[Calendar URL hidden]');
548
+ });
549
+
550
+ const responseHeaders = new Headers(response.headers);
551
+ responseHeaders.set('Access-Control-Allow-Origin', '*');
552
+
553
+ return new Response(sanitizedBody, {
554
+ status: response.status,
555
+ statusText: response.statusText,
556
+ headers: responseHeaders,
557
+ });
558
+ }
559
+
560
+ export default {
561
+ async fetch(request, env) {
562
+ try {
563
+ const url = new URL(request.url);
564
+
565
+ // ENCRYPTION_METHOD is hardcoded to 'fernet'
566
+ const ENCRYPTION_METHOD = 'fernet';
567
+
568
+ const encryptionKey = env.ENCRYPTION_KEY;
569
+ const calendarUrlSecret = env.CALENDAR_URL; // Read from Cloudflare Worker secret
570
+
571
+ if (!calendarUrlSecret) {
572
+ return new Response('CALENDAR_URL not configured in Cloudflare Worker secrets', {
573
+ status: 500,
574
+ headers: { 'Content-Type': 'text/plain' }
575
+ });
576
+ }
577
+
578
+ // Parse calendar URLs from secret (comma-separated, can be plain or fernet://)
579
+ const calendarUrlsFromSecret = calendarUrlSecret
580
+ .split(',')
581
+ .map(s => s.trim())
582
+ .filter(s => s);
583
+
584
+ // Use calendar URLs from secret (they may be fernet:// encrypted or plain)
585
+ const calendarUrls = calendarUrlsFromSecret;
586
+
587
+ // Check if any of the URLs are fernet:// encrypted
588
+ const hasFernetUrls = calendarUrls.some(url => url.startsWith('fernet://'));
589
+
590
+ // If we have fernet:// URLs, we need ENCRYPTION_KEY
591
+ if (hasFernetUrls && !encryptionKey) {
592
+ return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
593
+ status: 500,
594
+ headers: { 'Content-Type': 'text/plain' }
595
+ });
596
+ }
597
+
598
+ // Get the pathname to determine request type
599
+ const pathname = url.pathname;
600
+
601
+ // Check if this is the main calendar page request
602
+ const isMainCalendarPage = pathname === '/' ||
603
+ pathname === '/calendar.html' ||
604
+ pathname.endsWith('/calendar.html') ||
605
+ pathname === '';
606
+
607
+ // Check if this is an API endpoint that needs calendar URLs
608
+ // This includes /srcdoc, /calendar.events.json, /calendar.json, etc.
609
+ const isApiEndpoint = pathname === '/srcdoc' ||
610
+ pathname.startsWith('/srcdoc') ||
611
+ pathname === '/calendar.events.json' ||
612
+ pathname === '/calendar.json' ||
613
+ pathname.endsWith('.events.json') ||
614
+ pathname.endsWith('.json');
615
+
616
+ // For API endpoints like /srcdoc, always use calendar URLs from secret
617
+ if (isApiEndpoint) {
618
+ // Build the target URL
619
+ const targetUrl = new URL(`https://open-web-calendar.hosted.quelltext.eu${pathname}`);
620
+
621
+ // Copy all query parameters except 'url'
622
+ for (const [key, value] of url.searchParams.entries()) {
623
+ if (key !== 'url') {
624
+ targetUrl.searchParams.append(key, value);
625
+ }
626
+ }
627
+
628
+ // Decrypt and add calendar URLs from secret
629
+ // ENCRYPTION_METHOD is hardcoded to 'fernet'
630
+ const decryptedUrls = [];
631
+ for (const urlParam of calendarUrls) {
632
+ if (urlParam.startsWith('fernet://')) {
633
+ if (!encryptionKey) {
634
+ return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
635
+ status: 500,
636
+ headers: { 'Content-Type': 'text/plain' }
637
+ });
638
+ }
639
+ try {
640
+ const decrypted = await decryptFernetUrl(urlParam, encryptionKey);
641
+ decryptedUrls.push(decrypted);
642
+ } catch (error) {
643
+ return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
644
+ status: 500,
645
+ headers: { 'Content-Type': 'text/plain' }
646
+ });
647
+ }
648
+ } else {
649
+ // Plain URL, use as-is
650
+ decryptedUrls.push(urlParam);
651
+ }
652
+ }
653
+
654
+ // Add decrypted URLs
655
+ for (const decryptedUrl of decryptedUrls) {
656
+ targetUrl.searchParams.append('url', decryptedUrl);
657
+ }
658
+
659
+ // Forward all headers from the original request
660
+ const requestHeaders = new Headers();
661
+ request.headers.forEach((value, key) => {
662
+ if (key.toLowerCase() !== 'host' && key.toLowerCase() !== 'cf-ray' && key.toLowerCase() !== 'cf-connecting-ip') {
663
+ requestHeaders.set(key, value);
664
+ }
665
+ });
666
+
667
+ const response = await fetch(targetUrl.toString(), {
668
+ headers: requestHeaders,
669
+ });
670
+
671
+ // Sanitize response to hide calendar URLs in error messages
672
+ // Pass pathname for calendar events processing
673
+ return await sanitizeResponse(response, pathname);
674
+ }
675
+
676
+ // Handle main calendar page requests - always add calendar URLs from secret
677
+ if (isMainCalendarPage) {
678
+ // Decrypt calendar URLs from secret
679
+ // ENCRYPTION_METHOD is hardcoded to 'fernet'
680
+ const decryptedUrls = [];
681
+ for (const urlParam of calendarUrls) {
682
+ if (urlParam.startsWith('fernet://')) {
683
+ if (!encryptionKey) {
684
+ return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
685
+ status: 500,
686
+ headers: { 'Content-Type': 'text/plain' }
687
+ });
688
+ }
689
+ try {
690
+ const decrypted = await decryptFernetUrl(urlParam, encryptionKey);
691
+ decryptedUrls.push(decrypted);
692
+ } catch (error) {
693
+ return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
694
+ status: 500,
695
+ headers: { 'Content-Type': 'text/plain' }
696
+ });
697
+ }
698
+ } else {
699
+ // Plain URL, use as-is
700
+ decryptedUrls.push(urlParam);
701
+ }
702
+ }
703
+
704
+ // Build the open-web-calendar URL with decrypted URLs
705
+ const calendarUrl = new URL('https://open-web-calendar.hosted.quelltext.eu/calendar.html');
706
+
707
+ // Copy all query parameters except 'url' (calendar URLs come from secret)
708
+ for (const [key, value] of url.searchParams.entries()) {
709
+ if (key !== 'url') {
710
+ calendarUrl.searchParams.append(key, value);
711
+ }
712
+ }
713
+
714
+ // Add decrypted URLs from secret
715
+ for (const decryptedUrl of decryptedUrls) {
716
+ calendarUrl.searchParams.append('url', decryptedUrl);
717
+ }
718
+
719
+ // Fetch from open-web-calendar
720
+ const response = await fetch(calendarUrl.toString(), {
721
+ headers: {
722
+ 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare-Worker',
723
+ },
724
+ });
725
+
726
+ // Sanitize response to hide calendar URLs in error messages
727
+ // Pass pathname for calendar events processing
728
+ return await sanitizeResponse(response, pathname);
729
+ }
730
+
731
+ // For all other requests (static resources, etc.), proxy directly
732
+ let targetPath = pathname;
733
+ if (pathname === '/' || pathname === '') {
734
+ targetPath = '/calendar.html';
735
+ }
736
+
737
+ const targetUrl = new URL(`https://open-web-calendar.hosted.quelltext.eu${targetPath}${url.search}`);
738
+
739
+ // Forward all headers from the original request
740
+ const requestHeaders = new Headers();
741
+ request.headers.forEach((value, key) => {
742
+ // Skip certain headers that shouldn't be forwarded
743
+ if (key.toLowerCase() !== 'host' && key.toLowerCase() !== 'cf-ray' && key.toLowerCase() !== 'cf-connecting-ip') {
744
+ requestHeaders.set(key, value);
745
+ }
746
+ });
747
+
748
+ const response = await fetch(targetUrl.toString(), {
749
+ headers: requestHeaders,
750
+ });
751
+
752
+ // Sanitize response to hide calendar URLs in error messages
753
+ // Pass pathname for calendar events processing
754
+ return await sanitizeResponse(response, pathname);
755
+ } catch (error) {
756
+ return new Response(`Error: ${error.message}`, {
757
+ status: 500,
758
+ headers: { 'Content-Type': 'text/plain' }
759
+ });
760
+ }
761
+ },
762
+ };
@@ -0,0 +1,34 @@
1
+ name = "calendar-proxy"
2
+ main = "worker.js"
3
+ compatibility_date = "2025-12-28"
4
+
5
+ # Environment variables (secrets should be set via wrangler secret put)
6
+ # ENCRYPTION_KEY should be set as a secret: wrangler secret put ENCRYPTION_KEY
7
+ #
8
+ # Custom domain routes can be configured via:
9
+ # 1. Cloudflare Dashboard: Workers & Pages > Your Worker > Settings > Triggers > Routes
10
+ # 2. Wrangler CLI: wrangler routes add <pattern> --env production
11
+ # 3. Or add routes here for your environment:
12
+ # routes = [
13
+ # { pattern = "your-domain.com", custom_domain = true }
14
+ # ]
15
+
16
+ [env.production]
17
+ name = "calendar-proxy"
18
+ # Add your custom domain routes here if needed
19
+ # routes = [
20
+ # { pattern = "your-domain.com", custom_domain = true }
21
+ # ]
22
+
23
+ [env.development]
24
+ name = "calendar-proxy-dev"
25
+
26
+ [observability]
27
+ [observability.logs]
28
+ enabled = true
29
+ head_sampling_rate = 1
30
+ invocation_logs = false
31
+ [observability.traces]
32
+ enabled = false
33
+ head_sampling_rate = 1
34
+