@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 +6 -2
- package/scripts/encrypt-urls.js +76 -0
- package/worker.js +480 -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.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
|
+
|