@dytsou/calendar-build 2.0.0 → 2.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dytsou/calendar-build",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
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,480 @@
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
+ * Sanitize response body to hide calendar URLs in error messages
230
+ * Only sanitizes HTML and JSON responses to avoid breaking JavaScript code
231
+ */
232
+ async function sanitizeResponse(response) {
233
+ const contentType = response.headers.get('content-type') || '';
234
+
235
+ // Only sanitize HTML and JSON responses (error messages)
236
+ // Skip JavaScript files to avoid breaking code
237
+ if (!contentType.includes('text/html') &&
238
+ !contentType.includes('application/json')) {
239
+ // For non-HTML/JSON responses (including JavaScript), just add CORS header and return
240
+ const responseHeaders = new Headers(response.headers);
241
+ responseHeaders.set('Access-Control-Allow-Origin', '*');
242
+ return new Response(response.body, {
243
+ status: response.status,
244
+ statusText: response.statusText,
245
+ headers: responseHeaders,
246
+ });
247
+ }
248
+
249
+ const body = await response.text();
250
+
251
+ let sanitizedBody = body;
252
+
253
+ // For HTML responses, inject console filtering
254
+ if (contentType.includes('text/html')) {
255
+ sanitizedBody = injectConsoleFilter(sanitizedBody);
256
+ }
257
+
258
+ // Patterns to detect and sanitize calendar URLs
259
+ // Only match complete URLs, not code fragments
260
+ const urlPatterns = [
261
+ /https?:\/\/[^\s"']+\.ics/gi,
262
+ /https?:\/\/calendar\.google\.com\/calendar\/ical\/[^\s"']+/gi,
263
+ // Only match %40 when it's part of a URL (followed by domain-like pattern)
264
+ /%40[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}[^\s"']*/gi,
265
+ ];
266
+
267
+ urlPatterns.forEach(pattern => {
268
+ sanitizedBody = sanitizedBody.replace(pattern, '[Calendar URL hidden]');
269
+ });
270
+
271
+ const responseHeaders = new Headers(response.headers);
272
+ responseHeaders.set('Access-Control-Allow-Origin', '*');
273
+
274
+ return new Response(sanitizedBody, {
275
+ status: response.status,
276
+ statusText: response.statusText,
277
+ headers: responseHeaders,
278
+ });
279
+ }
280
+
281
+ export default {
282
+ async fetch(request, env) {
283
+ try {
284
+ const url = new URL(request.url);
285
+
286
+ // ENCRYPTION_METHOD is hardcoded to 'fernet'
287
+ const ENCRYPTION_METHOD = 'fernet';
288
+
289
+ const encryptionKey = env.ENCRYPTION_KEY;
290
+ const calendarUrlSecret = env.CALENDAR_URL; // Read from Cloudflare Worker secret
291
+
292
+ if (!calendarUrlSecret) {
293
+ return new Response('CALENDAR_URL not configured in Cloudflare Worker secrets', {
294
+ status: 500,
295
+ headers: { 'Content-Type': 'text/plain' }
296
+ });
297
+ }
298
+
299
+ // Parse calendar URLs from secret (comma-separated, can be plain or fernet://)
300
+ const calendarUrlsFromSecret = calendarUrlSecret
301
+ .split(',')
302
+ .map(s => s.trim())
303
+ .filter(s => s);
304
+
305
+ // Use calendar URLs from secret (they may be fernet:// encrypted or plain)
306
+ const calendarUrls = calendarUrlsFromSecret;
307
+
308
+ // Check if any of the URLs are fernet:// encrypted
309
+ const hasFernetUrls = calendarUrls.some(url => url.startsWith('fernet://'));
310
+
311
+ // If we have fernet:// URLs, we need ENCRYPTION_KEY
312
+ if (hasFernetUrls && !encryptionKey) {
313
+ return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
314
+ status: 500,
315
+ headers: { 'Content-Type': 'text/plain' }
316
+ });
317
+ }
318
+
319
+ // Get the pathname to determine request type
320
+ const pathname = url.pathname;
321
+
322
+ // Check if this is the main calendar page request
323
+ const isMainCalendarPage = pathname === '/' ||
324
+ pathname === '/calendar.html' ||
325
+ pathname.endsWith('/calendar.html') ||
326
+ pathname === '';
327
+
328
+ // Check if this is an API endpoint that needs calendar URLs
329
+ // This includes /srcdoc, /calendar.events.json, /calendar.json, etc.
330
+ const isApiEndpoint = pathname === '/srcdoc' ||
331
+ pathname.startsWith('/srcdoc') ||
332
+ pathname === '/calendar.events.json' ||
333
+ pathname === '/calendar.json' ||
334
+ pathname.endsWith('.events.json') ||
335
+ pathname.endsWith('.json');
336
+
337
+ // For API endpoints like /srcdoc, always use calendar URLs from secret
338
+ if (isApiEndpoint) {
339
+ // Build the target URL
340
+ const targetUrl = new URL(`https://open-web-calendar.hosted.quelltext.eu${pathname}`);
341
+
342
+ // Copy all query parameters except 'url'
343
+ for (const [key, value] of url.searchParams.entries()) {
344
+ if (key !== 'url') {
345
+ targetUrl.searchParams.append(key, value);
346
+ }
347
+ }
348
+
349
+ // Decrypt and add calendar URLs from secret
350
+ // ENCRYPTION_METHOD is hardcoded to 'fernet'
351
+ const decryptedUrls = [];
352
+ for (const urlParam of calendarUrls) {
353
+ if (urlParam.startsWith('fernet://')) {
354
+ if (!encryptionKey) {
355
+ return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
356
+ status: 500,
357
+ headers: { 'Content-Type': 'text/plain' }
358
+ });
359
+ }
360
+ try {
361
+ const decrypted = await decryptFernetUrl(urlParam, encryptionKey);
362
+ decryptedUrls.push(decrypted);
363
+ } catch (error) {
364
+ return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
365
+ status: 500,
366
+ headers: { 'Content-Type': 'text/plain' }
367
+ });
368
+ }
369
+ } else {
370
+ // Plain URL, use as-is
371
+ decryptedUrls.push(urlParam);
372
+ }
373
+ }
374
+
375
+ // Add decrypted URLs
376
+ for (const decryptedUrl of decryptedUrls) {
377
+ targetUrl.searchParams.append('url', decryptedUrl);
378
+ }
379
+
380
+ // Forward all headers from the original request
381
+ const requestHeaders = new Headers();
382
+ request.headers.forEach((value, key) => {
383
+ if (key.toLowerCase() !== 'host' && key.toLowerCase() !== 'cf-ray' && key.toLowerCase() !== 'cf-connecting-ip') {
384
+ requestHeaders.set(key, value);
385
+ }
386
+ });
387
+
388
+ const response = await fetch(targetUrl.toString(), {
389
+ headers: requestHeaders,
390
+ });
391
+
392
+ // Sanitize response to hide calendar URLs in error messages
393
+ return await sanitizeResponse(response);
394
+ }
395
+
396
+ // Handle main calendar page requests - always add calendar URLs from secret
397
+ if (isMainCalendarPage) {
398
+ // Decrypt calendar URLs from secret
399
+ // ENCRYPTION_METHOD is hardcoded to 'fernet'
400
+ const decryptedUrls = [];
401
+ for (const urlParam of calendarUrls) {
402
+ if (urlParam.startsWith('fernet://')) {
403
+ if (!encryptionKey) {
404
+ return new Response('ENCRYPTION_KEY not configured (required for fernet:// URLs)', {
405
+ status: 500,
406
+ headers: { 'Content-Type': 'text/plain' }
407
+ });
408
+ }
409
+ try {
410
+ const decrypted = await decryptFernetUrl(urlParam, encryptionKey);
411
+ decryptedUrls.push(decrypted);
412
+ } catch (error) {
413
+ return new Response(`Failed to decrypt calendar URL: ${error.message}`, {
414
+ status: 500,
415
+ headers: { 'Content-Type': 'text/plain' }
416
+ });
417
+ }
418
+ } else {
419
+ // Plain URL, use as-is
420
+ decryptedUrls.push(urlParam);
421
+ }
422
+ }
423
+
424
+ // Build the open-web-calendar URL with decrypted URLs
425
+ const calendarUrl = new URL('https://open-web-calendar.hosted.quelltext.eu/calendar.html');
426
+
427
+ // Copy all query parameters except 'url' (calendar URLs come from secret)
428
+ for (const [key, value] of url.searchParams.entries()) {
429
+ if (key !== 'url') {
430
+ calendarUrl.searchParams.append(key, value);
431
+ }
432
+ }
433
+
434
+ // Add decrypted URLs from secret
435
+ for (const decryptedUrl of decryptedUrls) {
436
+ calendarUrl.searchParams.append('url', decryptedUrl);
437
+ }
438
+
439
+ // Fetch from open-web-calendar
440
+ const response = await fetch(calendarUrl.toString(), {
441
+ headers: {
442
+ 'User-Agent': request.headers.get('User-Agent') || 'Cloudflare-Worker',
443
+ },
444
+ });
445
+
446
+ // Sanitize response to hide calendar URLs in error messages
447
+ return await sanitizeResponse(response);
448
+ }
449
+
450
+ // For all other requests (static resources, etc.), proxy directly
451
+ let targetPath = pathname;
452
+ if (pathname === '/' || pathname === '') {
453
+ targetPath = '/calendar.html';
454
+ }
455
+
456
+ const targetUrl = new URL(`https://open-web-calendar.hosted.quelltext.eu${targetPath}${url.search}`);
457
+
458
+ // Forward all headers from the original request
459
+ const requestHeaders = new Headers();
460
+ request.headers.forEach((value, key) => {
461
+ // Skip certain headers that shouldn't be forwarded
462
+ if (key.toLowerCase() !== 'host' && key.toLowerCase() !== 'cf-ray' && key.toLowerCase() !== 'cf-connecting-ip') {
463
+ requestHeaders.set(key, value);
464
+ }
465
+ });
466
+
467
+ const response = await fetch(targetUrl.toString(), {
468
+ headers: requestHeaders,
469
+ });
470
+
471
+ // Sanitize response to hide calendar URLs in error messages
472
+ return await sanitizeResponse(response);
473
+ } catch (error) {
474
+ return new Response(`Error: ${error.message}`, {
475
+ status: 500,
476
+ headers: { 'Content-Type': 'text/plain' }
477
+ });
478
+ }
479
+ },
480
+ };
@@ -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
+