@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 +6 -2
- package/scripts/encrypt-urls.js +76 -0
- package/worker.js +762 -0
- package/wrangler.toml.example +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dytsou/calendar-build",
|
|
3
|
-
"version": "2.
|
|
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
|
+
|