@dytsou/calendar-build 2.1.1 → 2.1.5
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/LICENSE +1 -1
- package/README.md +17 -4
- package/package.json +4 -4
- package/scripts/build.js +10 -7
- package/scripts/encrypt-urls.js +0 -1
- package/worker.js +259 -217
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c)
|
|
3
|
+
Copyright (c) 2026 dytsou
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
6
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.1.5",
|
|
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.
|
|
38
|
+
"wrangler": "4.58.0"
|
|
40
39
|
},
|
|
41
40
|
"dependencies": {
|
|
42
41
|
"fernet": "^0.3.3",
|
|
@@ -45,6 +44,7 @@
|
|
|
45
44
|
"scripts": {
|
|
46
45
|
"build": "node scripts/build.js",
|
|
47
46
|
"format": "prettier --write .",
|
|
48
|
-
"format:check": "prettier --check ."
|
|
47
|
+
"format:check": "prettier --check .",
|
|
48
|
+
"publish:npmjs": "pnpm publish --access public"
|
|
49
49
|
}
|
|
50
50
|
}
|
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(
|
|
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 =
|
|
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}`);
|
package/scripts/encrypt-urls.js
CHANGED
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) {
|
|
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
|
-
|
|
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: [
|
|
258
|
-
|
|
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 =
|
|
299
|
-
|
|
300
|
-
|
|
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 =
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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';
|
|
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 = '';
|
|
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 = '';
|
|
383
|
-
event.id = '';
|
|
384
|
-
event.categories = '';
|
|
385
|
-
event.color = '';
|
|
386
|
-
event.css_classes = '';
|
|
387
|
-
event.owc = '';
|
|
388
|
-
event.recurrence = '';
|
|
389
|
-
event.sequence = '';
|
|
390
|
-
event.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(
|
|
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 =
|
|
632
|
-
|
|
633
|
-
|
|
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 =
|
|
648
|
-
|
|
649
|
-
|
|
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 =
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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 (
|
|
681
|
-
|
|
682
|
-
|
|
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 =
|
|
806
|
-
|
|
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 (
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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 =
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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 =
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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 =
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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 (
|
|
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(
|
|
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 (
|
|
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
|
},
|