Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(calendar): event reminders, Web Push notifications, ICS subscription sync (#354)

scott 8683e9ed 970d2e72

+2065 -8
+1
CHANGELOG.md
··· 45 45 - E2E tests for daily notes creation and reopen (#303) 46 46 47 47 ### Fixed 48 + - Fix column background becomes transparent when cell is selected in sheets (#600) 48 49 - Improve calendar scrolling behavior (#489) 49 50 - Fix calendar view scrolling for overflow content (#483) 50 51 - CSS: add hex fallbacks for all 320 oklch() color declarations for older browser support (#408)
+102 -6
package-lock.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.19.0", 3 + "version": "0.30.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tools", 9 - "version": "0.19.0", 9 + "version": "0.30.0", 10 10 "dependencies": { 11 11 "@tiptap/core": "^2.11.0", 12 12 "@tiptap/extension-code-block-lowlight": "^2.27.2", ··· 44 44 "markdown-it": "^14.1.1", 45 45 "turndown": "^7.2.2", 46 46 "turndown-plugin-gfm": "^1.0.2", 47 + "web-push": "^3.6.7", 47 48 "ws": "^8.18.0", 48 49 "y-prosemirror": "^1.2.15", 49 50 "yjs": "^13.6.20" ··· 54 55 "@types/compression": "^1.8.1", 55 56 "@types/express": "^5.0.6", 56 57 "@types/node": "^25.5.0", 58 + "@types/web-push": "^3.6.4", 57 59 "@types/ws": "^8.18.1", 58 60 "concurrently": "^9.1.0", 59 61 "electron": "^41.1.0", ··· 2959 2961 "license": "MIT", 2960 2962 "optional": true 2961 2963 }, 2964 + "node_modules/@types/web-push": { 2965 + "version": "3.6.4", 2966 + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", 2967 + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", 2968 + "dev": true, 2969 + "license": "MIT", 2970 + "dependencies": { 2971 + "@types/node": "*" 2972 + } 2973 + }, 2962 2974 "node_modules/@types/ws": { 2963 2975 "version": "8.18.1", 2964 2976 "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", ··· 3145 3157 "version": "7.1.4", 3146 3158 "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", 3147 3159 "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", 3148 - "dev": true, 3149 3160 "license": "MIT", 3150 3161 "engines": { 3151 3162 "node": ">= 14" ··· 3510 3521 "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", 3511 3522 "license": "MIT" 3512 3523 }, 3524 + "node_modules/asn1.js": { 3525 + "version": "5.4.1", 3526 + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", 3527 + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", 3528 + "license": "MIT", 3529 + "dependencies": { 3530 + "bn.js": "^4.0.0", 3531 + "inherits": "^2.0.1", 3532 + "minimalistic-assert": "^1.0.0", 3533 + "safer-buffer": "^2.1.0" 3534 + } 3535 + }, 3513 3536 "node_modules/assert-plus": { 3514 3537 "version": "1.0.0", 3515 3538 "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", ··· 3682 3705 "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", 3683 3706 "license": "MIT" 3684 3707 }, 3708 + "node_modules/bn.js": { 3709 + "version": "4.12.3", 3710 + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", 3711 + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", 3712 + "license": "MIT" 3713 + }, 3685 3714 "node_modules/body-parser": { 3686 3715 "version": "1.20.4", 3687 3716 "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", ··· 3757 3786 "engines": { 3758 3787 "node": "*" 3759 3788 } 3789 + }, 3790 + "node_modules/buffer-equal-constant-time": { 3791 + "version": "1.0.1", 3792 + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", 3793 + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", 3794 + "license": "BSD-3-Clause" 3760 3795 }, 3761 3796 "node_modules/buffer-from": { 3762 3797 "version": "1.1.2", ··· 4970 5005 "dev": true, 4971 5006 "license": "MIT" 4972 5007 }, 5008 + "node_modules/ecdsa-sig-formatter": { 5009 + "version": "1.0.11", 5010 + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", 5011 + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", 5012 + "license": "Apache-2.0", 5013 + "dependencies": { 5014 + "safe-buffer": "^5.0.1" 5015 + } 5016 + }, 4973 5017 "node_modules/ee-first": { 4974 5018 "version": "1.1.1", 4975 5019 "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", ··· 6236 6280 "jspdf": "^4.0.0" 6237 6281 } 6238 6282 }, 6283 + "node_modules/http_ece": { 6284 + "version": "1.2.0", 6285 + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", 6286 + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", 6287 + "license": "MIT", 6288 + "engines": { 6289 + "node": ">=16" 6290 + } 6291 + }, 6239 6292 "node_modules/http-cache-semantics": { 6240 6293 "version": "4.2.0", 6241 6294 "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", ··· 6320 6373 "version": "7.0.6", 6321 6374 "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", 6322 6375 "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", 6323 - "dev": true, 6324 6376 "license": "MIT", 6325 6377 "dependencies": { 6326 6378 "agent-base": "^7.1.2", ··· 6334 6386 "version": "4.4.3", 6335 6387 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 6336 6388 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 6337 - "dev": true, 6338 6389 "license": "MIT", 6339 6390 "dependencies": { 6340 6391 "ms": "^2.1.3" ··· 6352 6403 "version": "2.1.3", 6353 6404 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 6354 6405 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 6355 - "dev": true, 6356 6406 "license": "MIT" 6357 6407 }, 6358 6408 "node_modules/iconv-corefoundation": { ··· 6757 6807 "safe-buffer": "~5.1.0" 6758 6808 } 6759 6809 }, 6810 + "node_modules/jwa": { 6811 + "version": "2.0.1", 6812 + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", 6813 + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", 6814 + "license": "MIT", 6815 + "dependencies": { 6816 + "buffer-equal-constant-time": "^1.0.1", 6817 + "ecdsa-sig-formatter": "1.0.11", 6818 + "safe-buffer": "^5.0.1" 6819 + } 6820 + }, 6821 + "node_modules/jws": { 6822 + "version": "4.0.1", 6823 + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", 6824 + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", 6825 + "license": "MIT", 6826 + "dependencies": { 6827 + "jwa": "^2.0.1", 6828 + "safe-buffer": "^5.0.1" 6829 + } 6830 + }, 6760 6831 "node_modules/keyv": { 6761 6832 "version": "4.5.4", 6762 6833 "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", ··· 7236 7307 "funding": { 7237 7308 "url": "https://github.com/sponsors/sindresorhus" 7238 7309 } 7310 + }, 7311 + "node_modules/minimalistic-assert": { 7312 + "version": "1.0.1", 7313 + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", 7314 + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", 7315 + "license": "ISC" 7239 7316 }, 7240 7317 "node_modules/minimatch": { 7241 7318 "version": "3.1.5", ··· 10620 10697 "license": "MIT", 10621 10698 "dependencies": { 10622 10699 "defaults": "^1.0.3" 10700 + } 10701 + }, 10702 + "node_modules/web-push": { 10703 + "version": "3.6.7", 10704 + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", 10705 + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", 10706 + "license": "MPL-2.0", 10707 + "dependencies": { 10708 + "asn1.js": "^5.3.0", 10709 + "http_ece": "1.2.0", 10710 + "https-proxy-agent": "^7.0.0", 10711 + "jws": "^4.0.0", 10712 + "minimist": "^1.2.5" 10713 + }, 10714 + "bin": { 10715 + "web-push": "src/cli.js" 10716 + }, 10717 + "engines": { 10718 + "node": ">= 16" 10623 10719 } 10624 10720 }, 10625 10721 "node_modules/webidl-conversions": {
+3 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.29.0", 3 + "version": "0.30.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js", ··· 53 53 "markdown-it": "^14.1.1", 54 54 "turndown": "^7.2.2", 55 55 "turndown-plugin-gfm": "^1.0.2", 56 + "web-push": "^3.6.7", 56 57 "ws": "^8.18.0", 57 58 "y-prosemirror": "^1.2.15", 58 59 "yjs": "^13.6.20" ··· 63 64 "@types/compression": "^1.8.1", 64 65 "@types/express": "^5.0.6", 65 66 "@types/node": "^25.5.0", 67 + "@types/web-push": "^3.6.4", 66 68 "@types/ws": "^8.18.1", 67 69 "concurrently": "^9.1.0", 68 70 "electron": "^41.1.0",
+33 -1
public/sw.js
··· 8 8 * - API/WS/health: network-only (never cached) 9 9 */ 10 10 11 - const CACHE_NAME = 'tools-v5'; 11 + const CACHE_NAME = 'tools-v6'; 12 12 13 13 // Static assets to pre-cache on install 14 14 const PRECACHE_URLS = [ ··· 102 102 return new Response('Offline', { status: 503 }); 103 103 } 104 104 } 105 + 106 + // --- Web Push notifications --- 107 + 108 + self.addEventListener('push', (event) => { 109 + if (!event.data) return; 110 + let payload; 111 + try { payload = event.data.json(); } catch { 112 + payload = { title: 'Calendar Reminder', body: 'You have an upcoming event' }; 113 + } 114 + const title = payload.title || 'Calendar Reminder'; 115 + const options = { 116 + body: payload.body || '', 117 + icon: '/favicon.svg', 118 + badge: '/favicon.svg', 119 + tag: payload.tag || 'calendar-reminder', 120 + data: { url: payload.url || '/', eventId: payload.eventId || null }, 121 + }; 122 + event.waitUntil(self.registration.showNotification(title, options)); 123 + }); 124 + 125 + self.addEventListener('notificationclick', (event) => { 126 + event.notification.close(); 127 + const url = event.notification.data?.url || '/'; 128 + event.waitUntil( 129 + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { 130 + for (const client of clients) { 131 + if (client.url.includes('/calendar/') && 'focus' in client) return client.focus(); 132 + } 133 + return self.clients.openWindow(url); 134 + }) 135 + ); 136 + });
+3
server/index.ts
··· 15 15 import blobRoutes from './routes/blobs.js'; 16 16 import aiRoutes from './routes/ai.js'; 17 17 import apiV1Routes from './routes/api-v1.js'; 18 + import { notificationRoutes, startReminderScheduler } from './routes/notifications.js'; 18 19 19 20 // --- Setup --- 20 21 ··· 80 81 app.use(blobRoutes); 81 82 app.use(aiRoutes); 82 83 app.use(apiV1Routes); 84 + app.use(notificationRoutes); 83 85 84 86 // Room management for E2EE relay (referenced by health check) 85 87 const rooms = new Map<string, Set<WebSocket>>(); ··· 233 235 234 236 server.listen(PORT, () => { 235 237 console.log(`Tools running on http://localhost:${PORT}`); 238 + startReminderScheduler(); 236 239 }); 237 240 238 241 if (httpsServer) {
+347
server/routes/notifications.ts
··· 1 + /** 2 + * Web Push notification routes, ICS proxy, and reminder scheduler. 3 + */ 4 + 5 + import { Router, type Request, type Response } from 'express'; 6 + import webpush from 'web-push'; 7 + import { db } from '../db.js'; 8 + import type { TailscaleUser } from '../types.js'; 9 + 10 + const router = Router(); 11 + 12 + // --- VAPID key management --- 13 + 14 + function ensureNotificationTables(): void { 15 + db.exec(` 16 + CREATE TABLE IF NOT EXISTS vapid_keys ( 17 + id INTEGER PRIMARY KEY CHECK (id = 1), 18 + public_key TEXT NOT NULL, 19 + private_key TEXT NOT NULL 20 + ) 21 + `); 22 + db.exec(` 23 + CREATE TABLE IF NOT EXISTS push_subscriptions ( 24 + id INTEGER PRIMARY KEY AUTOINCREMENT, 25 + user_login TEXT NOT NULL, 26 + endpoint TEXT NOT NULL UNIQUE, 27 + p256dh TEXT NOT NULL, 28 + auth TEXT NOT NULL, 29 + created_at INTEGER DEFAULT (unixepoch()) 30 + ) 31 + `); 32 + db.exec(` 33 + CREATE TABLE IF NOT EXISTS scheduled_reminders ( 34 + id INTEGER PRIMARY KEY AUTOINCREMENT, 35 + user_login TEXT NOT NULL, 36 + fire_at INTEGER NOT NULL, 37 + encrypted_payload TEXT NOT NULL, 38 + subscription_endpoint TEXT NOT NULL, 39 + sent INTEGER DEFAULT 0, 40 + FOREIGN KEY (subscription_endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE 41 + ) 42 + `); 43 + } 44 + 45 + ensureNotificationTables(); 46 + 47 + function initVapidKeys(): { publicKey: string; privateKey: string } { 48 + const existing = db.prepare('SELECT public_key, private_key FROM vapid_keys WHERE id = 1').get() as 49 + { public_key: string; private_key: string } | undefined; 50 + 51 + if (existing) { 52 + return { publicKey: existing.public_key, privateKey: existing.private_key }; 53 + } 54 + 55 + const keys = webpush.generateVAPIDKeys(); 56 + db.prepare('INSERT INTO vapid_keys (id, public_key, private_key) VALUES (1, ?, ?)').run( 57 + keys.publicKey, keys.privateKey 58 + ); 59 + return keys; 60 + } 61 + 62 + const vapidKeys = initVapidKeys(); 63 + webpush.setVapidDetails('mailto:push@tools.local', vapidKeys.publicKey, vapidKeys.privateKey); 64 + 65 + // --- Prepared statements --- 66 + 67 + const stmts = { 68 + getSubscription: db.prepare('SELECT * FROM push_subscriptions WHERE endpoint = ?'), 69 + getSubscriptionByUser: db.prepare('SELECT * FROM push_subscriptions WHERE user_login = ? LIMIT 1'), 70 + upsertSubscription: db.prepare(` 71 + INSERT INTO push_subscriptions (user_login, endpoint, p256dh, auth) 72 + VALUES (?, ?, ?, ?) 73 + ON CONFLICT(endpoint) DO UPDATE SET 74 + user_login = excluded.user_login, 75 + p256dh = excluded.p256dh, 76 + auth = excluded.auth 77 + `), 78 + deleteSubscription: db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?'), 79 + deleteSubscriptionsByUser: db.prepare('DELETE FROM push_subscriptions WHERE user_login = ?'), 80 + deleteRemindersByUser: db.prepare('DELETE FROM scheduled_reminders WHERE user_login = ?'), 81 + insertReminder: db.prepare( 82 + 'INSERT INTO scheduled_reminders (user_login, fire_at, encrypted_payload, subscription_endpoint) VALUES (?, ?, ?, ?)' 83 + ), 84 + getDueReminders: db.prepare( 85 + 'SELECT r.id, r.encrypted_payload, r.subscription_endpoint, s.p256dh, s.auth FROM scheduled_reminders r JOIN push_subscriptions s ON r.subscription_endpoint = s.endpoint WHERE r.fire_at <= ? AND r.sent = 0' 86 + ), 87 + markReminderSent: db.prepare('UPDATE scheduled_reminders SET sent = 1 WHERE id = ?'), 88 + cleanupOldReminders: db.prepare('DELETE FROM scheduled_reminders WHERE sent = 1 AND fire_at < ?'), 89 + }; 90 + 91 + // --- Routes --- 92 + 93 + router.get('/api/push/vapid-key', (_req: Request, res: Response) => { 94 + res.json({ publicKey: vapidKeys.publicKey }); 95 + }); 96 + 97 + router.post('/api/push/subscribe', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 98 + if (!req.tsUser) { 99 + res.status(403).json({ error: 'Authentication required' }); 100 + return; 101 + } 102 + 103 + const { endpoint, keys } = req.body as { 104 + endpoint?: string; 105 + keys?: { p256dh?: string; auth?: string }; 106 + }; 107 + 108 + if (!endpoint || typeof endpoint !== 'string') { 109 + res.status(400).json({ error: 'endpoint is required' }); 110 + return; 111 + } 112 + if (!keys?.p256dh || !keys?.auth) { 113 + res.status(400).json({ error: 'keys.p256dh and keys.auth are required' }); 114 + return; 115 + } 116 + 117 + stmts.upsertSubscription.run(req.tsUser.login, endpoint, keys.p256dh, keys.auth); 118 + res.status(201).json({ ok: true }); 119 + }); 120 + 121 + router.delete('/api/push/subscribe', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 122 + const { endpoint } = req.body as { endpoint?: string }; 123 + 124 + if (!endpoint || typeof endpoint !== 'string') { 125 + res.status(400).json({ error: 'endpoint is required' }); 126 + return; 127 + } 128 + 129 + stmts.deleteSubscription.run(endpoint); 130 + res.status(204).send(); 131 + }); 132 + 133 + router.post('/api/push/schedule', (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 134 + if (!req.tsUser) { 135 + res.status(403).json({ error: 'Authentication required' }); 136 + return; 137 + } 138 + 139 + const { reminders } = req.body as { 140 + reminders?: Array<{ fireAt?: number; encryptedPayload?: string }>; 141 + }; 142 + 143 + if (!Array.isArray(reminders)) { 144 + res.status(400).json({ error: 'reminders must be an array' }); 145 + return; 146 + } 147 + 148 + // Validate all reminders before modifying DB 149 + for (const r of reminders) { 150 + if (typeof r.fireAt !== 'number' || typeof r.encryptedPayload !== 'string') { 151 + res.status(400).json({ error: 'Each reminder must have numeric fireAt and string encryptedPayload' }); 152 + return; 153 + } 154 + } 155 + 156 + // Find the user's subscription 157 + const sub = stmts.getSubscriptionByUser.get(req.tsUser.login) as 158 + { endpoint: string } | undefined; 159 + 160 + if (!sub) { 161 + res.status(400).json({ error: 'No push subscription found for this user. Subscribe first.' }); 162 + return; 163 + } 164 + 165 + db.transaction(() => { 166 + stmts.deleteRemindersByUser.run(req.tsUser!.login); 167 + for (const r of reminders) { 168 + stmts.insertReminder.run(req.tsUser!.login, r.fireAt, r.encryptedPayload, sub.endpoint); 169 + } 170 + })(); 171 + 172 + res.json({ ok: true, count: reminders.length }); 173 + }); 174 + 175 + // --- ICS proxy --- 176 + 177 + interface IcsCacheEntry { 178 + body: string; 179 + fetchedAt: number; 180 + } 181 + 182 + const icsCache = new Map<string, IcsCacheEntry>(); 183 + const ICS_CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes 184 + 185 + /** 186 + * Validate a URL for ICS proxy: must be https, no localhost or private IPs. 187 + */ 188 + export function isValidIcsUrl(raw: string): boolean { 189 + let parsed: URL; 190 + try { 191 + parsed = new URL(raw); 192 + } catch { 193 + return false; 194 + } 195 + 196 + if (parsed.protocol !== 'https:') return false; 197 + 198 + const hostname = parsed.hostname.toLowerCase(); 199 + 200 + // Block localhost variants 201 + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') { 202 + return false; 203 + } 204 + 205 + // Block private IPv4 ranges 206 + const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); 207 + if (ipv4Match) { 208 + const [, a, b] = ipv4Match.map(Number); 209 + if (a === 10) return false; // 10.0.0.0/8 210 + if (a === 172 && b !== undefined && b >= 16 && b <= 31) return false; // 172.16.0.0/12 211 + if (a === 192 && b === 168) return false; // 192.168.0.0/16 212 + if (a === 169 && b === 254) return false; // 169.254.0.0/16 (link-local) 213 + if (a === 0) return false; // 0.0.0.0/8 214 + } 215 + 216 + // Block IPv6 private/link-local (common bracket notation in URLs) 217 + if (hostname.startsWith('[')) { 218 + const inner = hostname.slice(1, -1).toLowerCase(); 219 + if (inner.startsWith('fe80:') || inner.startsWith('fc') || inner.startsWith('fd') || inner === '::1') { 220 + return false; 221 + } 222 + } 223 + 224 + return true; 225 + } 226 + 227 + router.get('/api/ics-proxy', async (req: Request & { tsUser?: TailscaleUser | null }, res: Response) => { 228 + if (!req.tsUser) { 229 + res.status(403).json({ error: 'Authentication required' }); 230 + return; 231 + } 232 + 233 + const url = req.query['url'] as string | undefined; 234 + if (!url) { 235 + res.status(400).json({ error: 'url query parameter is required' }); 236 + return; 237 + } 238 + 239 + if (!isValidIcsUrl(url)) { 240 + res.status(400).json({ error: 'Invalid URL: must be https and not a private/localhost address' }); 241 + return; 242 + } 243 + 244 + // Check cache 245 + const cached = icsCache.get(url); 246 + if (cached && Date.now() - cached.fetchedAt < ICS_CACHE_TTL_MS) { 247 + res.type('text/calendar').send(cached.body); 248 + return; 249 + } 250 + 251 + try { 252 + const controller = new AbortController(); 253 + const timeout = setTimeout(() => controller.abort(), 15000); 254 + 255 + const response = await fetch(url, { 256 + signal: controller.signal, 257 + headers: { 'Accept': 'text/calendar, text/plain' }, 258 + }); 259 + clearTimeout(timeout); 260 + 261 + if (!response.ok) { 262 + res.status(502).json({ error: `Upstream returned ${response.status}` }); 263 + return; 264 + } 265 + 266 + const body = await response.text(); 267 + 268 + // Cache the response 269 + icsCache.set(url, { body, fetchedAt: Date.now() }); 270 + 271 + // Evict stale cache entries 272 + const now = Date.now(); 273 + for (const [key, entry] of icsCache) { 274 + if (now - entry.fetchedAt >= ICS_CACHE_TTL_MS) { 275 + icsCache.delete(key); 276 + } 277 + } 278 + 279 + res.type('text/calendar').send(body); 280 + } catch (err: unknown) { 281 + const message = err instanceof Error ? err.message : 'Unknown error'; 282 + res.status(502).json({ error: `Failed to fetch ICS feed: ${message}` }); 283 + } 284 + }); 285 + 286 + // --- Reminder scheduler --- 287 + 288 + let schedulerInterval: ReturnType<typeof setInterval> | null = null; 289 + 290 + async function processReminders(): Promise<void> { 291 + const nowSec = Math.floor(Date.now() / 1000); 292 + 293 + const dueReminders = stmts.getDueReminders.all(nowSec) as Array<{ 294 + id: number; 295 + encrypted_payload: string; 296 + subscription_endpoint: string; 297 + p256dh: string; 298 + auth: string; 299 + }>; 300 + 301 + for (const reminder of dueReminders) { 302 + try { 303 + await webpush.sendNotification( 304 + { 305 + endpoint: reminder.subscription_endpoint, 306 + keys: { 307 + p256dh: reminder.p256dh, 308 + auth: reminder.auth, 309 + }, 310 + }, 311 + reminder.encrypted_payload 312 + ); 313 + stmts.markReminderSent.run(reminder.id); 314 + } catch (err: unknown) { 315 + // 410 Gone or 404 means the subscription is expired/invalid 316 + const statusCode = (err as { statusCode?: number }).statusCode; 317 + if (statusCode === 410 || statusCode === 404) { 318 + stmts.deleteSubscription.run(reminder.subscription_endpoint); 319 + } 320 + // Mark as sent regardless to avoid infinite retry 321 + stmts.markReminderSent.run(reminder.id); 322 + } 323 + } 324 + 325 + // Clean up sent reminders older than 7 days 326 + const sevenDaysAgo = nowSec - 7 * 24 * 60 * 60; 327 + stmts.cleanupOldReminders.run(sevenDaysAgo); 328 + } 329 + 330 + export function startReminderScheduler(): void { 331 + if (schedulerInterval) return; 332 + schedulerInterval = setInterval(() => { 333 + processReminders().catch(err => { 334 + console.error('Reminder scheduler error:', err); 335 + }); 336 + }, 30000); 337 + schedulerInterval.unref(); 338 + } 339 + 340 + export function stopReminderScheduler(): void { 341 + if (schedulerInterval) { 342 + clearInterval(schedulerInterval); 343 + schedulerInterval = null; 344 + } 345 + } 346 + 347 + export { router as notificationRoutes, icsCache };
+94
src/calendar/helpers.ts
··· 21 21 color: string; 22 22 description: string; 23 23 recurrence?: Recurrence; 24 + reminders?: Reminder[]; 24 25 createdAt: number; 25 26 updatedAt: number; 26 27 } ··· 348 349 e.description.toLowerCase().includes(q) 349 350 ); 350 351 } 352 + 353 + // --------------------------------------------------------------------------- 354 + // Reminders 355 + // --------------------------------------------------------------------------- 356 + 357 + export type ReminderUnit = 'minutes' | 'hours' | 'days'; 358 + 359 + export interface Reminder { 360 + amount: number; 361 + unit: ReminderUnit; 362 + } 363 + 364 + export const REMINDER_PRESETS: Reminder[] = [ 365 + { amount: 0, unit: 'minutes' }, 366 + { amount: 5, unit: 'minutes' }, 367 + { amount: 15, unit: 'minutes' }, 368 + { amount: 30, unit: 'minutes' }, 369 + { amount: 1, unit: 'hours' }, 370 + { amount: 2, unit: 'hours' }, 371 + { amount: 1, unit: 'days' }, 372 + ]; 373 + 374 + export function reminderToMinutes(r: Reminder): number { 375 + switch (r.unit) { 376 + case 'minutes': return r.amount; 377 + case 'hours': return r.amount * 60; 378 + case 'days': return r.amount * 24 * 60; 379 + } 380 + } 381 + 382 + export function reminderLabel(r: Reminder): string { 383 + if (r.amount === 0) return 'At time of event'; 384 + const plural = r.amount !== 1 ? 's' : ''; 385 + return `${r.amount} ${r.unit.replace(/s$/, '')}${plural} before`; 386 + } 387 + 388 + export function reminderFireTime(event: CalendarEvent, reminder: Reminder): Date | null { 389 + const eventDate = parseEventDate(event.date); 390 + if (event.allDay) { 391 + const midnight = new Date(eventDate); 392 + midnight.setMinutes(midnight.getMinutes() - reminderToMinutes(reminder)); 393 + return midnight; 394 + } 395 + if (!event.startTime) return null; 396 + const parts = event.startTime.split(':').map(Number); 397 + eventDate.setHours(parts[0] ?? 0, parts[1] ?? 0, 0, 0); 398 + eventDate.setMinutes(eventDate.getMinutes() - reminderToMinutes(reminder)); 399 + return eventDate; 400 + } 401 + 402 + export function getUpcomingReminders( 403 + events: CalendarEvent[], 404 + windowStart: Date, 405 + windowEnd: Date, 406 + ): Array<{ event: CalendarEvent; reminder: Reminder; fireTime: Date }> { 407 + const results: Array<{ event: CalendarEvent; reminder: Reminder; fireTime: Date }> = []; 408 + for (const event of events) { 409 + if (!event.reminders || event.reminders.length === 0) continue; 410 + for (const reminder of event.reminders) { 411 + const fireTime = reminderFireTime(event, reminder); 412 + if (!fireTime) continue; 413 + if (fireTime >= windowStart && fireTime < windowEnd) { 414 + results.push({ event, reminder, fireTime }); 415 + } 416 + } 417 + } 418 + return results.sort((a, b) => a.fireTime.getTime() - b.fireTime.getTime()); 419 + } 420 + 421 + // --------------------------------------------------------------------------- 422 + // ICS Subscriptions 423 + // --------------------------------------------------------------------------- 424 + 425 + export interface IcsSubscription { 426 + id: string; 427 + name: string; 428 + url: string; 429 + color: string; 430 + lastSync?: number; 431 + enabled: boolean; 432 + } 433 + 434 + export interface ExternalEvent { 435 + subscriptionId: string; 436 + title: string; 437 + date: string; 438 + endDate?: string; 439 + startTime: string; 440 + endTime: string; 441 + allDay: boolean; 442 + color: string; 443 + description: string; 444 + }
+41
src/calendar/index.html
··· 126 126 <option value="24">24-hour (14:30)</option> 127 127 </select> 128 128 </label> 129 + <hr class="cal-settings-divider"> 130 + <div class="cal-settings-title">Calendar Subscriptions</div> 131 + <div class="cal-sub-list" id="cal-sub-list"></div> 132 + <button class="btn-add-subscription" id="btn-add-subscription">+ Add subscription</button> 133 + </div> 134 + 135 + <!-- Subscription add/edit modal --> 136 + <div class="modal-backdrop" id="sub-modal-backdrop" style="display:none"> 137 + <div class="sub-modal" id="sub-modal" role="dialog" aria-labelledby="sub-modal-title" aria-modal="true"> 138 + <h2 id="sub-modal-title">Add Calendar Subscription</h2> 139 + <div class="event-modal-field"> 140 + <label for="sub-name">Name</label> 141 + <input type="text" id="sub-name" class="event-modal-input" placeholder="Work Calendar"> 142 + </div> 143 + <div class="event-modal-field"> 144 + <label for="sub-url">ICS URL</label> 145 + <input type="url" id="sub-url" class="event-modal-input" placeholder="https://calendar.google.com/...basic.ics"> 146 + </div> 147 + <div class="event-modal-field"> 148 + <label>Color</label> 149 + <div class="event-color-picker" id="sub-color-picker"> 150 + <button class="event-color-swatch active" data-color="#d9974a" style="background:#d9974a" title="Amber" type="button"></button> 151 + <button class="event-color-swatch" data-color="#4a7ee8" style="background:#4a7ee8" title="Blue" type="button"></button> 152 + <button class="event-color-swatch" data-color="#8e6abf" style="background:#8e6abf" title="Purple" type="button"></button> 153 + <button class="event-color-swatch" data-color="#d94a4a" style="background:#d94a4a" title="Red" type="button"></button> 154 + <button class="event-color-swatch" data-color="#4aad5b" style="background:#4aad5b" title="Green" type="button"></button> 155 + <button class="event-color-swatch" data-color="#888888" style="background:#888888" title="Gray" type="button"></button> 156 + </div> 157 + </div> 158 + <div class="event-modal-actions"> 159 + <span class="topbar-spacer"></span> 160 + <button class="btn-secondary" id="btn-sub-cancel">Cancel</button> 161 + <button class="btn-primary" id="btn-sub-save">Add</button> 162 + </div> 163 + </div> 129 164 </div> 130 165 131 166 <!-- Event hover preview tooltip --> ··· 202 237 <div class="event-modal-field"> 203 238 <label for="event-description">Description</label> 204 239 <textarea id="event-description" class="event-modal-input event-modal-textarea" rows="3" placeholder="Add a description..."></textarea> 240 + </div> 241 + 242 + <div class="event-modal-field"> 243 + <label>Reminders</label> 244 + <div class="event-reminder-rows" id="event-reminder-list"></div> 245 + <button type="button" class="btn-add-reminder" id="btn-add-reminder">+ Add reminder</button> 205 246 </div> 206 247 207 248 <div class="event-modal-actions">
+376
src/calendar/main.ts
··· 18 18 type CalendarSettings, 19 19 type Recurrence, 20 20 type RecurrenceType, 21 + type Reminder, 22 + type IcsSubscription, 23 + type ExternalEvent, 21 24 EVENT_COLORS, 22 25 DAYS_OF_WEEK, 23 26 MONTHS, 24 27 MONTHS_SHORT, 28 + REMINDER_PRESETS, 25 29 formatDate, 26 30 parseEventDate, 27 31 isSameDay, ··· 42 46 getRotatedDayLetters, 43 47 getWeekStartForDay, 44 48 monthGridFirstDayOffset, 49 + reminderLabel, 50 + reminderFireTime, 51 + getUpcomingReminders, 45 52 } from './helpers.js'; 46 53 import { parseIcsFile } from './ics-parser.js'; 47 54 import { exportIcsFile } from './ics-export.js'; ··· 156 163 const previewTime = document.getElementById('cal-preview-time') as HTMLElement; 157 164 const previewDesc = document.getElementById('cal-preview-desc') as HTMLElement; 158 165 166 + // Reminder UI refs 167 + const reminderList = document.getElementById('event-reminder-list') as HTMLElement; 168 + const addReminderBtn = document.getElementById('btn-add-reminder') as HTMLButtonElement; 169 + 170 + // Subscription UI refs 171 + const subList = document.getElementById('cal-sub-list') as HTMLElement; 172 + const addSubBtn = document.getElementById('btn-add-subscription') as HTMLButtonElement; 173 + const subModalBackdrop = document.getElementById('sub-modal-backdrop') as HTMLElement; 174 + 175 + // Notification / subscription state 176 + let notificationsEnabled = false; 177 + let pushSubscription: PushSubscription | null = null; 178 + let icsSubscriptions: IcsSubscription[] = []; 179 + let externalEvents: ExternalEvent[] = []; 180 + const ICS_STORAGE_KEY = `tools-cal-subs-${docId}`; 181 + const NOTIF_DISMISSED_KEY = `tools-cal-notif-dismissed-${docId}`; 182 + 159 183 // --------------------------------------------------------------------------- 160 184 // Helpers 161 185 // --------------------------------------------------------------------------- ··· 190 214 const events = expandedEventsCache?.events ?? state.events; 191 215 const filtered = searchQuery ? searchEvents(events, searchQuery) : events; 192 216 return eventsOnDateHelper(filtered, dateStr); 217 + } 218 + 219 + function externalEventsOnDate(dateStr: string): ExternalEvent[] { 220 + return externalEvents.filter(e => { 221 + if (e.date === dateStr) return true; 222 + if (e.endDate && e.endDate > e.date) return dateStr >= e.date && dateStr <= e.endDate; 223 + return false; 224 + }).sort((a, b) => { 225 + if (a.allDay && !b.allDay) return -1; 226 + if (!a.allDay && b.allDay) return 1; 227 + return timeToMinutes(a.startTime) - timeToMinutes(b.startTime); 228 + }); 229 + } 230 + 231 + function externalEventPillHtml(evt: ExternalEvent): string { 232 + const timeStr = evt.allDay ? '' : fmtTime(evt.startTime); 233 + const timeLabel = timeStr ? `<span class="cal-pill-time">${escapeHtml(timeStr)}</span> ` : ''; 234 + return `<div class="cal-event-pill cal-event-external" style="--pill-color: ${evt.color}" title="From: ${escapeHtml(evt.title)}">${timeLabel}${escapeHtml(evt.title || 'Untitled')}</div>`; 193 235 } 194 236 195 237 // --------------------------------------------------------------------------- ··· 516 558 html += `<div class="cal-more-link" data-date="${dateStr}">+${remaining} more</div>`; 517 559 } 518 560 561 + // External subscription events 562 + const extEvts = externalEventsOnDate(dateStr); 563 + for (const ext of extEvts.slice(0, 2)) { 564 + html += externalEventPillHtml(ext); 565 + } 566 + 519 567 html += '</div>'; 520 568 } 521 569 ··· 791 839 html += '</div>'; 792 840 html += '</div>'; 793 841 } 842 + 843 + // External subscription events for this date 844 + const extEvts = externalEventsOnDate(dateStr); 845 + for (const ext of extEvts) { 846 + const extTimeStr = ext.allDay ? 'All day' : `${fmtTime(ext.startTime)}\u2013${fmtTime(ext.endTime)}`; 847 + html += `<div class="cal-agenda-item cal-event-external">`; 848 + html += `<span class="cal-agenda-dot" style="background:${ext.color}"></span>`; 849 + html += '<div class="cal-agenda-content">'; 850 + html += `<div class="cal-agenda-title">${escapeHtml(ext.title || 'Untitled')}</div>`; 851 + html += `<div class="cal-agenda-time">${escapeHtml(extTimeStr)}</div>`; 852 + html += '</div>'; 853 + html += '</div>'; 854 + } 794 855 } 795 856 796 857 html += '</div>'; ··· 798 859 } 799 860 800 861 // --------------------------------------------------------------------------- 862 + // Reminders UI 863 + // --------------------------------------------------------------------------- 864 + 865 + let modalReminders: Reminder[] = []; 866 + 867 + function renderReminderList(): void { 868 + let html = ''; 869 + for (let i = 0; i < modalReminders.length; i++) { 870 + const r = modalReminders[i]!; 871 + html += `<div class="event-reminder-row">`; 872 + html += `<select class="event-modal-input reminder-select" data-reminder-idx="${i}">`; 873 + for (const preset of REMINDER_PRESETS) { 874 + const label = reminderLabel(preset); 875 + const selected = preset.amount === r.amount && preset.unit === r.unit ? ' selected' : ''; 876 + html += `<option value="${preset.amount}-${preset.unit}"${selected}>${escapeHtml(label)}</option>`; 877 + } 878 + html += `</select>`; 879 + html += `<button type="button" class="btn-remove-reminder" data-reminder-idx="${i}" title="Remove">&times;</button>`; 880 + html += `</div>`; 881 + } 882 + reminderList.innerHTML = html; 883 + 884 + reminderList.querySelectorAll('.reminder-select').forEach(sel => { 885 + sel.addEventListener('change', (e) => { 886 + const target = e.target as HTMLSelectElement; 887 + const idx = parseInt(target.dataset.reminderIdx ?? '0', 10); 888 + const [amtStr, unit] = target.value.split('-'); 889 + if (modalReminders[idx]) { 890 + modalReminders[idx] = { amount: parseInt(amtStr ?? '0', 10), unit: (unit ?? 'minutes') as Reminder['unit'] }; 891 + } 892 + }); 893 + }); 894 + 895 + reminderList.querySelectorAll('.btn-remove-reminder').forEach(btn => { 896 + btn.addEventListener('click', (e) => { 897 + const idx = parseInt((e.currentTarget as HTMLElement).dataset.reminderIdx ?? '0', 10); 898 + modalReminders.splice(idx, 1); 899 + renderReminderList(); 900 + }); 901 + }); 902 + } 903 + 904 + addReminderBtn.addEventListener('click', () => { 905 + modalReminders.push({ amount: 15, unit: 'minutes' }); 906 + renderReminderList(); 907 + }); 908 + 909 + // --------------------------------------------------------------------------- 910 + // Notifications 911 + // --------------------------------------------------------------------------- 912 + 913 + async function requestNotificationPermission(): Promise<boolean> { 914 + if (!('Notification' in window)) return false; 915 + if (Notification.permission === 'granted') return true; 916 + if (Notification.permission === 'denied') return false; 917 + const result = await Notification.requestPermission(); 918 + return result === 'granted'; 919 + } 920 + 921 + async function registerPushSubscription(): Promise<void> { 922 + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return; 923 + try { 924 + const reg = await navigator.serviceWorker.ready; 925 + const existing = await reg.pushManager.getSubscription(); 926 + if (existing) { pushSubscription = existing; return; } 927 + const resp = await fetch('/api/push/vapid-key'); 928 + if (!resp.ok) return; 929 + const { publicKey } = await resp.json(); 930 + const sub = await reg.pushManager.subscribe({ 931 + userVisibleOnly: true, 932 + applicationServerKey: publicKey, 933 + }); 934 + pushSubscription = sub; 935 + await fetch('/api/push/subscribe', { 936 + method: 'POST', 937 + headers: { 'Content-Type': 'application/json' }, 938 + body: JSON.stringify(sub.toJSON()), 939 + }); 940 + } catch { /* push not available */ } 941 + } 942 + 943 + function scheduleReminder(event: CalendarEvent, reminder: Reminder): void { 944 + const fireTime = reminderFireTime(event, reminder); 945 + if (!fireTime) return; 946 + const now = Date.now(); 947 + const delay = fireTime.getTime() - now; 948 + if (delay < 0 || delay > 24 * 60 * 60 * 1000) return; 949 + setTimeout(() => { 950 + if (Notification.permission === 'granted') { 951 + new Notification(event.title || 'Calendar Reminder', { 952 + body: `${reminderLabel(reminder)} — ${event.date}`, 953 + icon: '/favicon.svg', 954 + tag: `reminder-${event.id}`, 955 + }); 956 + } 957 + }, delay); 958 + } 959 + 960 + function scheduleAllReminders(event: CalendarEvent): void { 961 + if (!event.reminders) return; 962 + for (const r of event.reminders) { 963 + scheduleReminder(event, r); 964 + } 965 + } 966 + 967 + function maybeShowNotifBanner(): void { 968 + if (!('Notification' in window)) return; 969 + if (Notification.permission !== 'default') return; 970 + if (localStorage.getItem(NOTIF_DISMISSED_KEY)) return; 971 + 972 + const banner = document.createElement('div'); 973 + banner.className = 'cal-notif-banner'; 974 + banner.innerHTML = ` 975 + <span>Enable notifications for calendar reminders?</span> 976 + <button class="btn-primary btn-sm" id="notif-enable">Enable</button> 977 + <button class="btn-secondary btn-sm" id="notif-dismiss">Not now</button> 978 + `; 979 + document.getElementById('calendar-toolbar')?.after(banner); 980 + 981 + banner.querySelector('#notif-enable')?.addEventListener('click', async () => { 982 + const granted = await requestNotificationPermission(); 983 + if (granted) { 984 + notificationsEnabled = true; 985 + await registerPushSubscription(); 986 + } 987 + banner.remove(); 988 + }); 989 + banner.querySelector('#notif-dismiss')?.addEventListener('click', () => { 990 + localStorage.setItem(NOTIF_DISMISSED_KEY, '1'); 991 + banner.remove(); 992 + }); 993 + } 994 + 995 + function checkInPageReminders(): void { 996 + if (!notificationsEnabled && Notification.permission !== 'granted') return; 997 + const now = new Date(); 998 + const windowEnd = new Date(now.getTime() + 31_000); 999 + const upcoming = getUpcomingReminders(state.events, now, windowEnd); 1000 + for (const { event, reminder } of upcoming) { 1001 + if (Notification.permission === 'granted') { 1002 + new Notification(event.title || 'Calendar Reminder', { 1003 + body: reminderLabel(reminder), 1004 + icon: '/favicon.svg', 1005 + tag: `reminder-${event.id}-${reminder.amount}-${reminder.unit}`, 1006 + }); 1007 + } 1008 + } 1009 + } 1010 + 1011 + // --------------------------------------------------------------------------- 1012 + // ICS Subscriptions 1013 + // --------------------------------------------------------------------------- 1014 + 1015 + function loadSubscriptions(): void { 1016 + try { 1017 + const raw = localStorage.getItem(ICS_STORAGE_KEY); 1018 + if (raw) icsSubscriptions = JSON.parse(raw); 1019 + } catch { /* ignore */ } 1020 + } 1021 + 1022 + function saveSubscriptions(): void { 1023 + localStorage.setItem(ICS_STORAGE_KEY, JSON.stringify(icsSubscriptions)); 1024 + } 1025 + 1026 + function escHtmlAttr(s: string): string { 1027 + return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); 1028 + } 1029 + 1030 + function renderSubList(): void { 1031 + if (!subList) return; 1032 + if (icsSubscriptions.length === 0) { 1033 + subList.innerHTML = '<div class="cal-sub-empty">No subscriptions yet.</div>'; 1034 + return; 1035 + } 1036 + let html = ''; 1037 + for (const sub of icsSubscriptions) { 1038 + html += `<div class="cal-sub-item" data-sub-id="${escHtmlAttr(sub.id)}">`; 1039 + html += `<span class="cal-sub-dot" style="background:${sub.color}"></span>`; 1040 + html += `<span class="cal-sub-name">${escapeHtml(sub.name)}</span>`; 1041 + html += `<label class="cal-sub-toggle"><input type="checkbox" ${sub.enabled ? 'checked' : ''} data-sub-toggle="${escHtmlAttr(sub.id)}"><span class="cal-sub-toggle-label">Show</span></label>`; 1042 + html += `<button class="cal-sub-sync" data-sub-sync="${escHtmlAttr(sub.id)}" title="Sync now">&#8635;</button>`; 1043 + html += `<button class="cal-sub-delete" data-sub-delete="${escHtmlAttr(sub.id)}" title="Remove">&times;</button>`; 1044 + html += `</div>`; 1045 + } 1046 + subList.innerHTML = html; 1047 + 1048 + subList.querySelectorAll('[data-sub-toggle]').forEach(cb => { 1049 + cb.addEventListener('change', (e) => { 1050 + const id = (e.target as HTMLInputElement).dataset.subToggle; 1051 + const sub = icsSubscriptions.find(s => s.id === id); 1052 + if (sub) { sub.enabled = (e.target as HTMLInputElement).checked; saveSubscriptions(); renderView(); } 1053 + }); 1054 + }); 1055 + subList.querySelectorAll('[data-sub-sync]').forEach(btn => { 1056 + btn.addEventListener('click', (e) => { 1057 + const id = (e.currentTarget as HTMLElement).dataset.subSync; 1058 + const sub = icsSubscriptions.find(s => s.id === id); 1059 + if (sub) syncSubscription(sub); 1060 + }); 1061 + }); 1062 + subList.querySelectorAll('[data-sub-delete]').forEach(btn => { 1063 + btn.addEventListener('click', (e) => { 1064 + const id = (e.currentTarget as HTMLElement).dataset.subDelete; 1065 + icsSubscriptions = icsSubscriptions.filter(s => s.id !== id); 1066 + externalEvents = externalEvents.filter(ev => ev.subscriptionId !== id); 1067 + saveSubscriptions(); 1068 + renderSubList(); 1069 + renderView(); 1070 + }); 1071 + }); 1072 + } 1073 + 1074 + async function syncSubscription(sub: IcsSubscription): Promise<void> { 1075 + try { 1076 + const resp = await fetch(`/api/ics-proxy?url=${encodeURIComponent(sub.url)}`); 1077 + if (!resp.ok) { showToast('Failed to fetch subscription'); return; } 1078 + const text = await resp.text(); 1079 + const result = parseIcsFile(text); 1080 + externalEvents = externalEvents.filter(e => e.subscriptionId !== sub.id); 1081 + for (const evt of result.events) { 1082 + externalEvents.push({ 1083 + subscriptionId: sub.id, 1084 + title: evt.title, 1085 + date: evt.date, 1086 + endDate: evt.endDate, 1087 + startTime: evt.startTime, 1088 + endTime: evt.endTime, 1089 + allDay: evt.allDay, 1090 + color: sub.color, 1091 + description: evt.description, 1092 + }); 1093 + } 1094 + sub.lastSync = Date.now(); 1095 + saveSubscriptions(); 1096 + renderView(); 1097 + showToast(`Synced ${result.events.length} events from ${sub.name}`); 1098 + } catch { 1099 + showToast('Error syncing subscription'); 1100 + } 1101 + } 1102 + 1103 + async function syncAllSubscriptions(): Promise<void> { 1104 + for (const sub of icsSubscriptions) { 1105 + if (sub.enabled) await syncSubscription(sub); 1106 + } 1107 + } 1108 + 1109 + function openSubModal(): void { 1110 + (document.getElementById('sub-name') as HTMLInputElement).value = ''; 1111 + (document.getElementById('sub-url') as HTMLInputElement).value = ''; 1112 + const colorPicker = document.getElementById('sub-color-picker')!; 1113 + colorPicker.querySelectorAll('.event-color-swatch').forEach((s, i) => { 1114 + s.classList.toggle('active', i === 0); 1115 + }); 1116 + (document.getElementById('sub-modal-title') as HTMLElement).textContent = 'Add Calendar Subscription'; 1117 + subModalBackdrop.style.display = ''; 1118 + (document.getElementById('sub-name') as HTMLInputElement).focus(); 1119 + } 1120 + 1121 + function closeSubModal(): void { 1122 + subModalBackdrop.style.display = 'none'; 1123 + } 1124 + 1125 + addSubBtn?.addEventListener('click', openSubModal); 1126 + document.getElementById('btn-sub-cancel')?.addEventListener('click', closeSubModal); 1127 + 1128 + document.getElementById('btn-sub-save')?.addEventListener('click', () => { 1129 + const name = (document.getElementById('sub-name') as HTMLInputElement).value.trim(); 1130 + const url = (document.getElementById('sub-url') as HTMLInputElement).value.trim(); 1131 + if (!name || !url) { showToast('Name and URL are required'); return; } 1132 + const color = (document.getElementById('sub-color-picker')!.querySelector('.event-color-swatch.active') as HTMLElement)?.dataset.color || '#d9974a'; 1133 + const newSub: IcsSubscription = { 1134 + id: crypto.randomUUID(), 1135 + name, 1136 + url, 1137 + color, 1138 + enabled: true, 1139 + }; 1140 + icsSubscriptions.push(newSub); 1141 + saveSubscriptions(); 1142 + renderSubList(); 1143 + closeSubModal(); 1144 + syncSubscription(newSub); 1145 + }); 1146 + 1147 + document.getElementById('sub-color-picker')?.addEventListener('click', (e) => { 1148 + const swatch = (e.target as HTMLElement).closest('.event-color-swatch') as HTMLElement | null; 1149 + if (!swatch) return; 1150 + document.getElementById('sub-color-picker')!.querySelectorAll('.event-color-swatch').forEach(s => s.classList.remove('active')); 1151 + swatch.classList.add('active'); 1152 + }); 1153 + 1154 + // --------------------------------------------------------------------------- 801 1155 // Event Modal 802 1156 // --------------------------------------------------------------------------- 803 1157 ··· 842 1196 modalRecurrenceUntil.value = rec?.until ?? ''; 843 1197 updateRecurrenceUntilVisibility(); 844 1198 1199 + // Reminder fields 1200 + modalReminders = [...((evt as CalendarEvent).reminders ?? [])]; 1201 + renderReminderList(); 1202 + 845 1203 // Toggle time fields visibility based on all-day 846 1204 updateTimeFieldsVisibility(); 847 1205 ··· 889 1247 color: (modalColorPicker.querySelector('.event-color-swatch.active') as HTMLElement)?.dataset.color || EVENT_COLORS[0] || '#4a90d9', 890 1248 description: modalDescription.value.trim(), 891 1249 ...(recurrence ? { recurrence } : {}), 1250 + ...(modalReminders.length > 0 ? { reminders: [...modalReminders] } : {}), 892 1251 createdAt: editingEventId 893 1252 ? (state.events.find(e => e.id === editingEventId)?.createdAt ?? now) 894 1253 : now, ··· 902 1261 } 903 1262 904 1263 closeModal(); 1264 + scheduleAllReminders(event); 905 1265 } 906 1266 907 1267 function deleteEvent(): void { ··· 1762 2122 provider.on('sync', () => { 1763 2123 loadEventsFromYjs(); 1764 2124 renderView(); 2125 + maybeShowNotifBanner(); 1765 2126 }); 1766 2127 1767 2128 await loadTitle(); ··· 1774 2135 btn.classList.toggle('active', (btn as HTMLElement).dataset.view === state.view); 1775 2136 }); 1776 2137 2138 + // Load subscriptions and schedule reminders 2139 + loadSubscriptions(); 2140 + renderSubList(); 2141 + if (Notification.permission === 'granted') { 2142 + notificationsEnabled = true; 2143 + registerPushSubscription(); 2144 + for (const evt of state.events) scheduleAllReminders(evt); 2145 + } 2146 + 1777 2147 renderView(); 1778 2148 } 1779 2149 1780 2150 init(); 2151 + 2152 + // Check for in-page reminders every 30 seconds 2153 + setInterval(checkInPageReminders, 30_000); 2154 + 2155 + // Sync subscriptions on load (deferred) 2156 + setTimeout(syncAllSubscriptions, 3_000); 1781 2157 1782 2158 // Update now-line position every 60 seconds 1783 2159 setInterval(() => {
+175
src/css/app.css
··· 408 408 background: var(--color-bg); 409 409 line-height: 1.55; 410 410 min-height: 100dvh; 411 + overflow-x: hidden; 411 412 transition: background-color var(--transition-med), color var(--transition-med); 412 413 } 413 414 ··· 659 660 660 661 .create-actions { 661 662 display: flex; 663 + flex-wrap: wrap; 662 664 gap: var(--space-md); 663 665 margin-top: var(--space-xl); 664 666 } ··· 1090 1092 1091 1093 .doc-toolbar-actions { 1092 1094 display: flex; 1095 + flex-wrap: wrap; 1093 1096 align-items: center; 1094 1097 gap: var(--space-sm); 1095 1098 } ··· 10540 10543 -webkit-box-orient: vertical; 10541 10544 } 10542 10545 10546 + 10547 + /* ── Reminder rows (event modal) ─────────────────────────────────────── */ 10548 + 10549 + .event-reminder-rows { 10550 + display: flex; 10551 + flex-direction: column; 10552 + gap: var(--space-xs); 10553 + } 10554 + 10555 + .event-reminder-row { 10556 + display: flex; 10557 + align-items: center; 10558 + gap: var(--space-xs); 10559 + } 10560 + 10561 + .event-reminder-row .reminder-select { 10562 + flex: 1; 10563 + } 10564 + 10565 + .btn-remove-reminder { 10566 + background: none; 10567 + border: none; 10568 + color: var(--color-text-secondary); 10569 + font-size: 1.2rem; 10570 + cursor: pointer; 10571 + padding: 0 4px; 10572 + line-height: 1; 10573 + } 10574 + .btn-remove-reminder:hover { 10575 + color: var(--color-danger, #d94a4a); 10576 + } 10577 + 10578 + .btn-add-reminder { 10579 + background: none; 10580 + border: 1px dashed var(--color-border); 10581 + border-radius: var(--radius-sm); 10582 + padding: var(--space-xs) var(--space-sm); 10583 + color: var(--color-text-secondary); 10584 + cursor: pointer; 10585 + font-size: 0.85rem; 10586 + margin-top: var(--space-xs); 10587 + } 10588 + .btn-add-reminder:hover { 10589 + border-color: var(--color-teal); 10590 + color: var(--color-teal); 10591 + } 10592 + 10593 + /* ── Subscription list (settings popover) ────────────────────────────── */ 10594 + 10595 + .cal-settings-divider { 10596 + border: none; 10597 + border-top: 1px solid var(--color-border); 10598 + margin: var(--space-sm) 0; 10599 + } 10600 + 10601 + .cal-sub-list { 10602 + display: flex; 10603 + flex-direction: column; 10604 + gap: var(--space-xs); 10605 + margin-bottom: var(--space-sm); 10606 + } 10607 + 10608 + .cal-sub-empty { 10609 + color: var(--color-text-secondary); 10610 + font-size: 0.85rem; 10611 + padding: var(--space-xs) 0; 10612 + } 10613 + 10614 + .cal-sub-item { 10615 + display: flex; 10616 + align-items: center; 10617 + gap: var(--space-xs); 10618 + padding: var(--space-xs) 0; 10619 + } 10620 + 10621 + .cal-sub-dot { 10622 + width: 10px; 10623 + height: 10px; 10624 + border-radius: 50%; 10625 + flex-shrink: 0; 10626 + } 10627 + 10628 + .cal-sub-name { 10629 + flex: 1; 10630 + font-size: 0.85rem; 10631 + overflow: hidden; 10632 + text-overflow: ellipsis; 10633 + white-space: nowrap; 10634 + } 10635 + 10636 + .cal-sub-toggle { 10637 + display: flex; 10638 + align-items: center; 10639 + gap: 4px; 10640 + font-size: 0.8rem; 10641 + color: var(--color-text-secondary); 10642 + cursor: pointer; 10643 + } 10644 + 10645 + .cal-sub-toggle-label { 10646 + font-size: 0.75rem; 10647 + } 10648 + 10649 + .cal-sub-sync, 10650 + .cal-sub-delete { 10651 + background: none; 10652 + border: none; 10653 + cursor: pointer; 10654 + color: var(--color-text-secondary); 10655 + font-size: 1rem; 10656 + padding: 0 2px; 10657 + } 10658 + .cal-sub-sync:hover { color: var(--color-teal); } 10659 + .cal-sub-delete:hover { color: var(--color-danger, #d94a4a); } 10660 + 10661 + .btn-add-subscription { 10662 + background: none; 10663 + border: 1px dashed var(--color-border); 10664 + border-radius: var(--radius-sm); 10665 + padding: var(--space-xs) var(--space-sm); 10666 + color: var(--color-text-secondary); 10667 + cursor: pointer; 10668 + font-size: 0.85rem; 10669 + width: 100%; 10670 + } 10671 + .btn-add-subscription:hover { 10672 + border-color: var(--color-teal); 10673 + color: var(--color-teal); 10674 + } 10675 + 10676 + /* ── Subscription modal ──────────────────────────────────────────────── */ 10677 + 10678 + .sub-modal { 10679 + background: var(--color-surface); 10680 + border-radius: var(--radius-lg); 10681 + padding: var(--space-lg); 10682 + width: min(440px, 90vw); 10683 + max-height: 80vh; 10684 + overflow-y: auto; 10685 + box-shadow: var(--shadow-lg); 10686 + } 10687 + 10688 + .sub-modal h2 { 10689 + margin: 0 0 var(--space-md); 10690 + font-size: 1.1rem; 10691 + } 10692 + 10693 + /* ── External event overlay ──────────────────────────────────────────── */ 10694 + 10695 + .cal-event-external { 10696 + opacity: 0.75; 10697 + border-style: dashed !important; 10698 + } 10699 + 10700 + /* ── Notification banner ─────────────────────────────────────────────── */ 10701 + 10702 + .cal-notif-banner { 10703 + display: flex; 10704 + align-items: center; 10705 + gap: var(--space-sm); 10706 + padding: var(--space-xs) var(--space-md); 10707 + background: var(--color-surface-raised, var(--color-surface)); 10708 + border: 1px solid var(--color-border); 10709 + border-radius: var(--radius-sm); 10710 + margin: var(--space-xs) var(--space-md); 10711 + font-size: 0.85rem; 10712 + } 10713 + 10714 + .cal-notif-banner .btn-sm { 10715 + padding: 2px 10px; 10716 + font-size: 0.8rem; 10717 + } 10543 10718 10544 10719 /* ── Keyboard-focused event pill ─────────────────────────────────────── */ 10545 10720
+116
tests/calendar-helpers.test.ts
··· 20 20 getRotatedDayLetters, 21 21 getWeekStartForDay, 22 22 monthGridFirstDayOffset, 23 + reminderToMinutes, 24 + reminderLabel, 25 + reminderFireTime, 26 + getUpcomingReminders, 27 + REMINDER_PRESETS, 23 28 EVENT_COLORS, 24 29 DAYS_OF_WEEK, 25 30 MONTHS, ··· 733 738 // Jan 1 2026 is Thursday (day 4) 734 739 expect(monthGridFirstDayOffset(2026, 0, 0)).toBe(4); 735 740 expect(monthGridFirstDayOffset(2026, 0, 1)).toBe(3); 741 + }); 742 + }); 743 + 744 + describe('reminderToMinutes', () => { 745 + it('converts minutes', () => { 746 + expect(reminderToMinutes({ amount: 15, unit: 'minutes' })).toBe(15); 747 + }); 748 + it('converts hours', () => { 749 + expect(reminderToMinutes({ amount: 2, unit: 'hours' })).toBe(120); 750 + }); 751 + it('converts days', () => { 752 + expect(reminderToMinutes({ amount: 1, unit: 'days' })).toBe(1440); 753 + }); 754 + it('handles zero', () => { 755 + expect(reminderToMinutes({ amount: 0, unit: 'minutes' })).toBe(0); 756 + }); 757 + }); 758 + 759 + describe('reminderLabel', () => { 760 + it('labels zero as "At time of event"', () => { 761 + expect(reminderLabel({ amount: 0, unit: 'minutes' })).toBe('At time of event'); 762 + }); 763 + it('handles singular', () => { 764 + expect(reminderLabel({ amount: 1, unit: 'hours' })).toBe('1 hour before'); 765 + }); 766 + it('handles plural', () => { 767 + expect(reminderLabel({ amount: 15, unit: 'minutes' })).toBe('15 minutes before'); 768 + }); 769 + it('handles 1 day', () => { 770 + expect(reminderLabel({ amount: 1, unit: 'days' })).toBe('1 day before'); 771 + }); 772 + }); 773 + 774 + describe('reminderFireTime', () => { 775 + it('computes fire time for timed event', () => { 776 + const event: CalendarEvent = { 777 + id: '1', title: 'Test', date: '2026-06-15', startTime: '14:00', endTime: '15:00', 778 + allDay: false, color: '#000', description: '', createdAt: 0, updatedAt: 0, 779 + }; 780 + const result = reminderFireTime(event, { amount: 30, unit: 'minutes' }); 781 + expect(result).not.toBeNull(); 782 + expect(result!.getHours()).toBe(13); 783 + expect(result!.getMinutes()).toBe(30); 784 + }); 785 + it('computes fire time for all-day event', () => { 786 + const event: CalendarEvent = { 787 + id: '2', title: 'Test', date: '2026-06-15', startTime: '', endTime: '', 788 + allDay: true, color: '#000', description: '', createdAt: 0, updatedAt: 0, 789 + }; 790 + const result = reminderFireTime(event, { amount: 1, unit: 'days' }); 791 + expect(result).not.toBeNull(); 792 + expect(result!.getDate()).toBe(14); 793 + }); 794 + it('returns null if no startTime on non-allday', () => { 795 + const event: CalendarEvent = { 796 + id: '3', title: 'Test', date: '2026-06-15', startTime: '', endTime: '', 797 + allDay: false, color: '#000', description: '', createdAt: 0, updatedAt: 0, 798 + }; 799 + expect(reminderFireTime(event, { amount: 5, unit: 'minutes' })).toBeNull(); 800 + }); 801 + }); 802 + 803 + describe('getUpcomingReminders', () => { 804 + it('returns reminders within window', () => { 805 + const event: CalendarEvent = { 806 + id: '1', title: 'Meeting', date: '2026-06-15', startTime: '14:00', endTime: '15:00', 807 + allDay: false, color: '#000', description: '', createdAt: 0, updatedAt: 0, 808 + reminders: [{ amount: 30, unit: 'minutes' }], 809 + }; 810 + const windowStart = new Date(2026, 5, 15, 13, 25); 811 + const windowEnd = new Date(2026, 5, 15, 13, 35); 812 + const results = getUpcomingReminders([event], windowStart, windowEnd); 813 + expect(results).toHaveLength(1); 814 + expect(results[0]!.event.id).toBe('1'); 815 + }); 816 + it('excludes reminders outside window', () => { 817 + const event: CalendarEvent = { 818 + id: '1', title: 'Meeting', date: '2026-06-15', startTime: '14:00', endTime: '15:00', 819 + allDay: false, color: '#000', description: '', createdAt: 0, updatedAt: 0, 820 + reminders: [{ amount: 30, unit: 'minutes' }], 821 + }; 822 + const windowStart = new Date(2026, 5, 15, 12, 0); 823 + const windowEnd = new Date(2026, 5, 15, 12, 30); 824 + expect(getUpcomingReminders([event], windowStart, windowEnd)).toHaveLength(0); 825 + }); 826 + it('skips events without reminders', () => { 827 + const event: CalendarEvent = { 828 + id: '1', title: 'Meeting', date: '2026-06-15', startTime: '14:00', endTime: '15:00', 829 + allDay: false, color: '#000', description: '', createdAt: 0, updatedAt: 0, 830 + }; 831 + expect(getUpcomingReminders([event], new Date(2026, 5, 15, 13, 25), new Date(2026, 5, 15, 13, 35))).toHaveLength(0); 832 + }); 833 + it('sorts by fire time', () => { 834 + const event: CalendarEvent = { 835 + id: '1', title: 'Meeting', date: '2026-06-15', startTime: '14:00', endTime: '15:00', 836 + allDay: false, color: '#000', description: '', createdAt: 0, updatedAt: 0, 837 + reminders: [{ amount: 5, unit: 'minutes' }, { amount: 30, unit: 'minutes' }], 838 + }; 839 + const results = getUpcomingReminders([event], new Date(2026, 5, 15, 13, 25), new Date(2026, 5, 15, 14, 0)); 840 + expect(results).toHaveLength(2); 841 + expect(results[0]!.reminder.amount).toBe(30); 842 + expect(results[1]!.reminder.amount).toBe(5); 843 + }); 844 + }); 845 + 846 + describe('REMINDER_PRESETS', () => { 847 + it('has 7 presets', () => { 848 + expect(REMINDER_PRESETS).toHaveLength(7); 849 + }); 850 + it('starts with "at time of event"', () => { 851 + expect(REMINDER_PRESETS[0]!.amount).toBe(0); 736 852 }); 737 853 }); 738 854 });
+774
tests/server-notifications.test.ts
··· 1 + /** 2 + * Tests for Web Push notification routes, ICS proxy, and reminder scheduler. 3 + * 4 + * Uses an in-memory Express server that mirrors the notification route logic, 5 + * avoiding production DB side effects from the real module import. 6 + */ 7 + 8 + import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; 9 + import Database from 'better-sqlite3'; 10 + import { createServer } from 'http'; 11 + import express from 'express'; 12 + import type { Server } from 'http'; 13 + 14 + // --- isValidIcsUrl (pure function, tested directly) --- 15 + 16 + // Re-implement the validation logic here to test without importing the module 17 + // (which triggers DB side effects). The real implementation is in server/routes/notifications.ts. 18 + function isValidIcsUrl(raw: string): boolean { 19 + let parsed: URL; 20 + try { 21 + parsed = new URL(raw); 22 + } catch { 23 + return false; 24 + } 25 + 26 + if (parsed.protocol !== 'https:') return false; 27 + 28 + const hostname = parsed.hostname.toLowerCase(); 29 + 30 + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]') { 31 + return false; 32 + } 33 + 34 + const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); 35 + if (ipv4Match) { 36 + const [, a, b] = ipv4Match.map(Number); 37 + if (a === 10) return false; 38 + if (a === 172 && b !== undefined && b >= 16 && b <= 31) return false; 39 + if (a === 192 && b === 168) return false; 40 + if (a === 169 && b === 254) return false; 41 + if (a === 0) return false; 42 + } 43 + 44 + if (hostname.startsWith('[')) { 45 + const inner = hostname.slice(1, -1).toLowerCase(); 46 + if (inner.startsWith('fe80:') || inner.startsWith('fc') || inner.startsWith('fd') || inner === '::1') { 47 + return false; 48 + } 49 + } 50 + 51 + return true; 52 + } 53 + 54 + describe('isValidIcsUrl', () => { 55 + it('accepts valid https URLs', () => { 56 + expect(isValidIcsUrl('https://calendar.google.com/calendar/ical/test/basic.ics')).toBe(true); 57 + expect(isValidIcsUrl('https://outlook.office365.com/owa/calendar.ics')).toBe(true); 58 + expect(isValidIcsUrl('https://example.com/feed.ics')).toBe(true); 59 + }); 60 + 61 + it('rejects http URLs', () => { 62 + expect(isValidIcsUrl('http://example.com/feed.ics')).toBe(false); 63 + }); 64 + 65 + it('rejects non-URL strings', () => { 66 + expect(isValidIcsUrl('not a url')).toBe(false); 67 + expect(isValidIcsUrl('')).toBe(false); 68 + expect(isValidIcsUrl('ftp://example.com/feed.ics')).toBe(false); 69 + }); 70 + 71 + it('rejects localhost', () => { 72 + expect(isValidIcsUrl('https://localhost/feed.ics')).toBe(false); 73 + expect(isValidIcsUrl('https://127.0.0.1/feed.ics')).toBe(false); 74 + expect(isValidIcsUrl('https://[::1]/feed.ics')).toBe(false); 75 + }); 76 + 77 + it('rejects private IP ranges', () => { 78 + // 10.0.0.0/8 79 + expect(isValidIcsUrl('https://10.0.0.1/feed.ics')).toBe(false); 80 + expect(isValidIcsUrl('https://10.255.255.255/feed.ics')).toBe(false); 81 + 82 + // 172.16.0.0/12 83 + expect(isValidIcsUrl('https://172.16.0.1/feed.ics')).toBe(false); 84 + expect(isValidIcsUrl('https://172.31.255.255/feed.ics')).toBe(false); 85 + 86 + // 192.168.0.0/16 87 + expect(isValidIcsUrl('https://192.168.1.1/feed.ics')).toBe(false); 88 + expect(isValidIcsUrl('https://192.168.0.100/feed.ics')).toBe(false); 89 + 90 + // 169.254.0.0/16 (link-local) 91 + expect(isValidIcsUrl('https://169.254.1.1/feed.ics')).toBe(false); 92 + 93 + // 0.0.0.0/8 94 + expect(isValidIcsUrl('https://0.0.0.0/feed.ics')).toBe(false); 95 + }); 96 + 97 + it('allows public IP addresses', () => { 98 + expect(isValidIcsUrl('https://8.8.8.8/feed.ics')).toBe(true); 99 + expect(isValidIcsUrl('https://1.1.1.1/feed.ics')).toBe(true); 100 + // 172.15.x.x is NOT private (below 172.16) 101 + expect(isValidIcsUrl('https://172.15.0.1/feed.ics')).toBe(true); 102 + // 172.32.x.x is NOT private (above 172.31) 103 + expect(isValidIcsUrl('https://172.32.0.1/feed.ics')).toBe(true); 104 + }); 105 + 106 + it('rejects IPv6 private/link-local in bracket notation', () => { 107 + expect(isValidIcsUrl('https://[fe80::1]/feed.ics')).toBe(false); 108 + expect(isValidIcsUrl('https://[fc00::1]/feed.ics')).toBe(false); 109 + expect(isValidIcsUrl('https://[fd00::1]/feed.ics')).toBe(false); 110 + }); 111 + }); 112 + 113 + // --- Integration tests for notification routes --- 114 + 115 + type Req = express.Request & { tsUser?: { login: string; name: string; profilePic: string | null } | null }; 116 + type Res = express.Response; 117 + 118 + let baseUrl: string; 119 + let server: Server; 120 + let db: ReturnType<typeof Database>; 121 + 122 + // Simulated VAPID keys (not real, just for testing the flow) 123 + const TEST_VAPID_PUBLIC = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkPs-lqKJQUT0aZa_pR4VkM_M3Gp5fqZcxPAX-eBw'; 124 + const TEST_VAPID_PRIVATE = 'Dt1CLgQlTI0m3MiKb-5OOaQFo2Ke9YVqhMHSJzQ2Dmg'; 125 + 126 + beforeAll(async () => { 127 + db = new Database(':memory:'); 128 + db.pragma('journal_mode = WAL'); 129 + 130 + // Create notification tables 131 + db.exec(` 132 + CREATE TABLE vapid_keys ( 133 + id INTEGER PRIMARY KEY CHECK (id = 1), 134 + public_key TEXT NOT NULL, 135 + private_key TEXT NOT NULL 136 + ) 137 + `); 138 + db.exec(` 139 + CREATE TABLE push_subscriptions ( 140 + id INTEGER PRIMARY KEY AUTOINCREMENT, 141 + user_login TEXT NOT NULL, 142 + endpoint TEXT NOT NULL UNIQUE, 143 + p256dh TEXT NOT NULL, 144 + auth TEXT NOT NULL, 145 + created_at INTEGER DEFAULT (unixepoch()) 146 + ) 147 + `); 148 + db.exec(` 149 + CREATE TABLE scheduled_reminders ( 150 + id INTEGER PRIMARY KEY AUTOINCREMENT, 151 + user_login TEXT NOT NULL, 152 + fire_at INTEGER NOT NULL, 153 + encrypted_payload TEXT NOT NULL, 154 + subscription_endpoint TEXT NOT NULL, 155 + sent INTEGER DEFAULT 0, 156 + FOREIGN KEY (subscription_endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE 157 + ) 158 + `); 159 + 160 + // Seed VAPID keys 161 + db.prepare('INSERT INTO vapid_keys (id, public_key, private_key) VALUES (1, ?, ?)').run( 162 + TEST_VAPID_PUBLIC, TEST_VAPID_PRIVATE 163 + ); 164 + 165 + // Prepared statements 166 + const stmts = { 167 + getVapidKeys: db.prepare('SELECT public_key, private_key FROM vapid_keys WHERE id = 1'), 168 + upsertSubscription: db.prepare(` 169 + INSERT INTO push_subscriptions (user_login, endpoint, p256dh, auth) 170 + VALUES (?, ?, ?, ?) 171 + ON CONFLICT(endpoint) DO UPDATE SET 172 + user_login = excluded.user_login, 173 + p256dh = excluded.p256dh, 174 + auth = excluded.auth 175 + `), 176 + getSubscription: db.prepare('SELECT * FROM push_subscriptions WHERE endpoint = ?'), 177 + getSubscriptionByUser: db.prepare('SELECT * FROM push_subscriptions WHERE user_login = ? LIMIT 1'), 178 + deleteSubscription: db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?'), 179 + deleteRemindersByUser: db.prepare('DELETE FROM scheduled_reminders WHERE user_login = ?'), 180 + insertReminder: db.prepare( 181 + 'INSERT INTO scheduled_reminders (user_login, fire_at, encrypted_payload, subscription_endpoint) VALUES (?, ?, ?, ?)' 182 + ), 183 + getReminders: db.prepare('SELECT * FROM scheduled_reminders WHERE user_login = ? ORDER BY fire_at'), 184 + getDueReminders: db.prepare( 185 + 'SELECT r.*, s.p256dh, s.auth FROM scheduled_reminders r JOIN push_subscriptions s ON r.subscription_endpoint = s.endpoint WHERE r.fire_at <= ? AND r.sent = 0' 186 + ), 187 + markReminderSent: db.prepare('UPDATE scheduled_reminders SET sent = 1 WHERE id = ?'), 188 + cleanupOldReminders: db.prepare('DELETE FROM scheduled_reminders WHERE sent = 1 AND fire_at < ?'), 189 + countSubscriptions: db.prepare('SELECT COUNT(*) as count FROM push_subscriptions'), 190 + countReminders: db.prepare('SELECT COUNT(*) as count FROM scheduled_reminders'), 191 + }; 192 + 193 + const app = express(); 194 + app.use(express.json({ limit: '1mb' })); 195 + 196 + // Fake Tailscale identity middleware (check header) 197 + app.use((req: Req, _res, next) => { 198 + const login = req.headers['tailscale-user-login'] as string | undefined; 199 + if (login) { 200 + req.tsUser = { login, name: login, profilePic: null }; 201 + } else { 202 + req.tsUser = null; 203 + } 204 + next(); 205 + }); 206 + 207 + // GET /api/push/vapid-key 208 + app.get('/api/push/vapid-key', (_req: Req, res: Res) => { 209 + const keys = stmts.getVapidKeys.get() as { public_key: string } | undefined; 210 + res.json({ publicKey: keys?.public_key || '' }); 211 + }); 212 + 213 + // POST /api/push/subscribe 214 + app.post('/api/push/subscribe', (req: Req, res: Res) => { 215 + if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 216 + 217 + const { endpoint, keys } = req.body as { 218 + endpoint?: string; 219 + keys?: { p256dh?: string; auth?: string }; 220 + }; 221 + 222 + if (!endpoint || typeof endpoint !== 'string') { 223 + res.status(400).json({ error: 'endpoint is required' }); return; 224 + } 225 + if (!keys?.p256dh || !keys?.auth) { 226 + res.status(400).json({ error: 'keys.p256dh and keys.auth are required' }); return; 227 + } 228 + 229 + stmts.upsertSubscription.run(req.tsUser.login, endpoint, keys.p256dh, keys.auth); 230 + res.status(201).json({ ok: true }); 231 + }); 232 + 233 + // DELETE /api/push/subscribe 234 + app.delete('/api/push/subscribe', (req: Req, res: Res) => { 235 + const { endpoint } = req.body as { endpoint?: string }; 236 + if (!endpoint || typeof endpoint !== 'string') { 237 + res.status(400).json({ error: 'endpoint is required' }); return; 238 + } 239 + stmts.deleteSubscription.run(endpoint); 240 + res.status(204).send(); 241 + }); 242 + 243 + // POST /api/push/schedule 244 + app.post('/api/push/schedule', (req: Req, res: Res) => { 245 + if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 246 + 247 + const { reminders } = req.body as { 248 + reminders?: Array<{ fireAt?: number; encryptedPayload?: string }>; 249 + }; 250 + 251 + if (!Array.isArray(reminders)) { 252 + res.status(400).json({ error: 'reminders must be an array' }); return; 253 + } 254 + 255 + for (const r of reminders) { 256 + if (typeof r.fireAt !== 'number' || typeof r.encryptedPayload !== 'string') { 257 + res.status(400).json({ error: 'Each reminder must have numeric fireAt and string encryptedPayload' }); 258 + return; 259 + } 260 + } 261 + 262 + const sub = stmts.getSubscriptionByUser.get(req.tsUser.login) as 263 + { endpoint: string } | undefined; 264 + if (!sub) { 265 + res.status(400).json({ error: 'No push subscription found for this user. Subscribe first.' }); 266 + return; 267 + } 268 + 269 + db.transaction(() => { 270 + stmts.deleteRemindersByUser.run(req.tsUser!.login); 271 + for (const r of reminders) { 272 + stmts.insertReminder.run(req.tsUser!.login, r.fireAt, r.encryptedPayload, sub.endpoint); 273 + } 274 + })(); 275 + 276 + res.json({ ok: true, count: reminders.length }); 277 + }); 278 + 279 + // ICS proxy cache 280 + const icsCache = new Map<string, { body: string; fetchedAt: number }>(); 281 + const ICS_CACHE_TTL_MS = 15 * 60 * 1000; 282 + 283 + // GET /api/ics-proxy 284 + app.get('/api/ics-proxy', async (req: Req, res: Res) => { 285 + if (!req.tsUser) { res.status(403).json({ error: 'Authentication required' }); return; } 286 + 287 + const url = req.query['url'] as string | undefined; 288 + if (!url) { res.status(400).json({ error: 'url query parameter is required' }); return; } 289 + 290 + if (!isValidIcsUrl(url)) { 291 + res.status(400).json({ error: 'Invalid URL: must be https and not a private/localhost address' }); 292 + return; 293 + } 294 + 295 + const cached = icsCache.get(url); 296 + if (cached && Date.now() - cached.fetchedAt < ICS_CACHE_TTL_MS) { 297 + res.type('text/calendar').send(cached.body); 298 + return; 299 + } 300 + 301 + try { 302 + const controller = new AbortController(); 303 + const timeout = setTimeout(() => controller.abort(), 15000); 304 + const response = await fetch(url, { 305 + signal: controller.signal, 306 + headers: { 'Accept': 'text/calendar, text/plain' }, 307 + }); 308 + clearTimeout(timeout); 309 + 310 + if (!response.ok) { 311 + res.status(502).json({ error: `Upstream returned ${response.status}` }); 312 + return; 313 + } 314 + 315 + const body = await response.text(); 316 + icsCache.set(url, { body, fetchedAt: Date.now() }); 317 + res.type('text/calendar').send(body); 318 + } catch (err: unknown) { 319 + const message = err instanceof Error ? err.message : 'Unknown error'; 320 + res.status(502).json({ error: `Failed to fetch ICS feed: ${message}` }); 321 + } 322 + }); 323 + 324 + // Internal test endpoints for verifying DB state 325 + app.get('/test/subscriptions', (_req: Req, res: Res) => { 326 + res.json(stmts.countSubscriptions.get()); 327 + }); 328 + 329 + app.get('/test/reminders/:user', (req: Req, res: Res) => { 330 + res.json(stmts.getReminders.all(req.params['user'])); 331 + }); 332 + 333 + app.get('/test/due-reminders/:timestamp', (req: Req, res: Res) => { 334 + res.json(stmts.getDueReminders.all(Number(req.params['timestamp']))); 335 + }); 336 + 337 + app.post('/test/mark-sent/:id', (req: Req, res: Res) => { 338 + stmts.markReminderSent.run(Number(req.params['id'])); 339 + res.json({ ok: true }); 340 + }); 341 + 342 + app.post('/test/cleanup/:timestamp', (req: Req, res: Res) => { 343 + const result = stmts.cleanupOldReminders.run(Number(req.params['timestamp'])); 344 + res.json({ deleted: result.changes }); 345 + }); 346 + 347 + server = createServer(app); 348 + await new Promise<void>((resolve) => { 349 + server.listen(0, () => { 350 + const addr = server.address(); 351 + baseUrl = `http://localhost:${typeof addr === 'object' && addr ? addr.port : 0}`; 352 + resolve(); 353 + }); 354 + }); 355 + }); 356 + 357 + afterAll(() => { 358 + server?.close(); 359 + db?.close(); 360 + }); 361 + 362 + // Helper: make a request with fake Tailscale identity 363 + function authHeaders(login = 'testuser'): Record<string, string> { 364 + return { 365 + 'Content-Type': 'application/json', 366 + 'Tailscale-User-Login': login, 367 + }; 368 + } 369 + 370 + // --- VAPID key management --- 371 + 372 + describe('VAPID key management', () => { 373 + it('returns the public VAPID key', async () => { 374 + const res = await fetch(`${baseUrl}/api/push/vapid-key`); 375 + expect(res.status).toBe(200); 376 + const data = await res.json() as { publicKey: string }; 377 + expect(data.publicKey).toBe(TEST_VAPID_PUBLIC); 378 + }); 379 + 380 + it('returns the same key on subsequent calls (persistence)', async () => { 381 + const res1 = await fetch(`${baseUrl}/api/push/vapid-key`); 382 + const data1 = await res1.json() as { publicKey: string }; 383 + const res2 = await fetch(`${baseUrl}/api/push/vapid-key`); 384 + const data2 = await res2.json() as { publicKey: string }; 385 + expect(data1.publicKey).toBe(data2.publicKey); 386 + }); 387 + }); 388 + 389 + // --- Push subscription CRUD --- 390 + 391 + describe('push subscription CRUD', () => { 392 + const testEndpoint = 'https://fcm.googleapis.com/fcm/send/test-subscription-id'; 393 + const testKeys = { 394 + p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8p8REfXSs', 395 + auth: 'tBHItJI5svbpC7--wsKK6Q', 396 + }; 397 + 398 + it('rejects subscribe without authentication', async () => { 399 + const res = await fetch(`${baseUrl}/api/push/subscribe`, { 400 + method: 'POST', 401 + headers: { 'Content-Type': 'application/json' }, 402 + body: JSON.stringify({ endpoint: testEndpoint, keys: testKeys }), 403 + }); 404 + expect(res.status).toBe(403); 405 + }); 406 + 407 + it('rejects subscribe without endpoint', async () => { 408 + const res = await fetch(`${baseUrl}/api/push/subscribe`, { 409 + method: 'POST', 410 + headers: authHeaders(), 411 + body: JSON.stringify({ keys: testKeys }), 412 + }); 413 + expect(res.status).toBe(400); 414 + }); 415 + 416 + it('rejects subscribe without keys', async () => { 417 + const res = await fetch(`${baseUrl}/api/push/subscribe`, { 418 + method: 'POST', 419 + headers: authHeaders(), 420 + body: JSON.stringify({ endpoint: testEndpoint }), 421 + }); 422 + expect(res.status).toBe(400); 423 + }); 424 + 425 + it('rejects subscribe with missing p256dh', async () => { 426 + const res = await fetch(`${baseUrl}/api/push/subscribe`, { 427 + method: 'POST', 428 + headers: authHeaders(), 429 + body: JSON.stringify({ endpoint: testEndpoint, keys: { auth: testKeys.auth } }), 430 + }); 431 + expect(res.status).toBe(400); 432 + }); 433 + 434 + it('creates a push subscription', async () => { 435 + const res = await fetch(`${baseUrl}/api/push/subscribe`, { 436 + method: 'POST', 437 + headers: authHeaders(), 438 + body: JSON.stringify({ endpoint: testEndpoint, keys: testKeys }), 439 + }); 440 + expect(res.status).toBe(201); 441 + const data = await res.json() as { ok: boolean }; 442 + expect(data.ok).toBe(true); 443 + }); 444 + 445 + it('upserts on duplicate endpoint', async () => { 446 + // Subscribe again with same endpoint but different user 447 + const res = await fetch(`${baseUrl}/api/push/subscribe`, { 448 + method: 'POST', 449 + headers: authHeaders('otheruser'), 450 + body: JSON.stringify({ endpoint: testEndpoint, keys: testKeys }), 451 + }); 452 + expect(res.status).toBe(201); 453 + 454 + // Verify only 1 subscription exists for this endpoint 455 + const countRes = await fetch(`${baseUrl}/test/subscriptions`); 456 + const count = await countRes.json() as { count: number }; 457 + expect(count.count).toBe(1); 458 + }); 459 + 460 + it('deletes a push subscription', async () => { 461 + // Re-subscribe as testuser first 462 + await fetch(`${baseUrl}/api/push/subscribe`, { 463 + method: 'POST', 464 + headers: authHeaders(), 465 + body: JSON.stringify({ endpoint: testEndpoint, keys: testKeys }), 466 + }); 467 + 468 + const res = await fetch(`${baseUrl}/api/push/subscribe`, { 469 + method: 'DELETE', 470 + headers: { 'Content-Type': 'application/json' }, 471 + body: JSON.stringify({ endpoint: testEndpoint }), 472 + }); 473 + expect(res.status).toBe(204); 474 + 475 + // Verify it's gone 476 + const countRes = await fetch(`${baseUrl}/test/subscriptions`); 477 + const count = await countRes.json() as { count: number }; 478 + expect(count.count).toBe(0); 479 + }); 480 + 481 + it('rejects delete without endpoint', async () => { 482 + const res = await fetch(`${baseUrl}/api/push/subscribe`, { 483 + method: 'DELETE', 484 + headers: { 'Content-Type': 'application/json' }, 485 + body: JSON.stringify({}), 486 + }); 487 + expect(res.status).toBe(400); 488 + }); 489 + }); 490 + 491 + // --- Reminder scheduling --- 492 + 493 + describe('reminder scheduling', () => { 494 + const testEndpoint = 'https://fcm.googleapis.com/fcm/send/reminder-test'; 495 + const testKeys = { 496 + p256dh: 'BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8p8REfXSs', 497 + auth: 'tBHItJI5svbpC7--wsKK6Q', 498 + }; 499 + 500 + beforeEach(async () => { 501 + // Clean state: remove all subscriptions and reminders 502 + db.exec('DELETE FROM scheduled_reminders'); 503 + db.exec('DELETE FROM push_subscriptions'); 504 + 505 + // Create a subscription for testing 506 + await fetch(`${baseUrl}/api/push/subscribe`, { 507 + method: 'POST', 508 + headers: authHeaders('scheduser'), 509 + body: JSON.stringify({ endpoint: testEndpoint, keys: testKeys }), 510 + }); 511 + }); 512 + 513 + it('rejects schedule without authentication', async () => { 514 + const res = await fetch(`${baseUrl}/api/push/schedule`, { 515 + method: 'POST', 516 + headers: { 'Content-Type': 'application/json' }, 517 + body: JSON.stringify({ reminders: [] }), 518 + }); 519 + expect(res.status).toBe(403); 520 + }); 521 + 522 + it('rejects schedule without a subscription', async () => { 523 + const res = await fetch(`${baseUrl}/api/push/schedule`, { 524 + method: 'POST', 525 + headers: authHeaders('nosub-user'), 526 + body: JSON.stringify({ reminders: [{ fireAt: 1000, encryptedPayload: 'test' }] }), 527 + }); 528 + expect(res.status).toBe(400); 529 + const data = await res.json() as { error: string }; 530 + expect(data.error).toContain('No push subscription'); 531 + }); 532 + 533 + it('rejects schedule with invalid reminders field', async () => { 534 + const res = await fetch(`${baseUrl}/api/push/schedule`, { 535 + method: 'POST', 536 + headers: authHeaders('scheduser'), 537 + body: JSON.stringify({ reminders: 'not-an-array' }), 538 + }); 539 + expect(res.status).toBe(400); 540 + }); 541 + 542 + it('rejects reminders with missing fireAt', async () => { 543 + const res = await fetch(`${baseUrl}/api/push/schedule`, { 544 + method: 'POST', 545 + headers: authHeaders('scheduser'), 546 + body: JSON.stringify({ reminders: [{ encryptedPayload: 'test' }] }), 547 + }); 548 + expect(res.status).toBe(400); 549 + }); 550 + 551 + it('rejects reminders with missing encryptedPayload', async () => { 552 + const res = await fetch(`${baseUrl}/api/push/schedule`, { 553 + method: 'POST', 554 + headers: authHeaders('scheduser'), 555 + body: JSON.stringify({ reminders: [{ fireAt: 1000 }] }), 556 + }); 557 + expect(res.status).toBe(400); 558 + }); 559 + 560 + it('schedules reminders successfully', async () => { 561 + const nowSec = Math.floor(Date.now() / 1000); 562 + const reminders = [ 563 + { fireAt: nowSec + 300, encryptedPayload: 'encrypted-payload-1' }, 564 + { fireAt: nowSec + 600, encryptedPayload: 'encrypted-payload-2' }, 565 + ]; 566 + 567 + const res = await fetch(`${baseUrl}/api/push/schedule`, { 568 + method: 'POST', 569 + headers: authHeaders('scheduser'), 570 + body: JSON.stringify({ reminders }), 571 + }); 572 + expect(res.status).toBe(200); 573 + const data = await res.json() as { ok: boolean; count: number }; 574 + expect(data.ok).toBe(true); 575 + expect(data.count).toBe(2); 576 + 577 + // Verify in DB 578 + const dbRes = await fetch(`${baseUrl}/test/reminders/scheduser`); 579 + const stored = await dbRes.json() as Array<{ fire_at: number; encrypted_payload: string }>; 580 + expect(stored).toHaveLength(2); 581 + expect(stored[0]!.encrypted_payload).toBe('encrypted-payload-1'); 582 + expect(stored[1]!.encrypted_payload).toBe('encrypted-payload-2'); 583 + }); 584 + 585 + it('replaces existing reminders on re-schedule', async () => { 586 + const nowSec = Math.floor(Date.now() / 1000); 587 + 588 + // Schedule first batch 589 + await fetch(`${baseUrl}/api/push/schedule`, { 590 + method: 'POST', 591 + headers: authHeaders('scheduser'), 592 + body: JSON.stringify({ 593 + reminders: [ 594 + { fireAt: nowSec + 100, encryptedPayload: 'old-1' }, 595 + { fireAt: nowSec + 200, encryptedPayload: 'old-2' }, 596 + ], 597 + }), 598 + }); 599 + 600 + // Schedule second batch (should replace first) 601 + await fetch(`${baseUrl}/api/push/schedule`, { 602 + method: 'POST', 603 + headers: authHeaders('scheduser'), 604 + body: JSON.stringify({ 605 + reminders: [{ fireAt: nowSec + 500, encryptedPayload: 'new-1' }], 606 + }), 607 + }); 608 + 609 + const dbRes = await fetch(`${baseUrl}/test/reminders/scheduser`); 610 + const stored = await dbRes.json() as Array<{ encrypted_payload: string }>; 611 + expect(stored).toHaveLength(1); 612 + expect(stored[0]!.encrypted_payload).toBe('new-1'); 613 + }); 614 + 615 + it('accepts empty reminders array (clears all)', async () => { 616 + const nowSec = Math.floor(Date.now() / 1000); 617 + 618 + // Schedule some reminders first 619 + await fetch(`${baseUrl}/api/push/schedule`, { 620 + method: 'POST', 621 + headers: authHeaders('scheduser'), 622 + body: JSON.stringify({ 623 + reminders: [{ fireAt: nowSec + 100, encryptedPayload: 'test' }], 624 + }), 625 + }); 626 + 627 + // Clear with empty array 628 + const res = await fetch(`${baseUrl}/api/push/schedule`, { 629 + method: 'POST', 630 + headers: authHeaders('scheduser'), 631 + body: JSON.stringify({ reminders: [] }), 632 + }); 633 + expect(res.status).toBe(200); 634 + 635 + const dbRes = await fetch(`${baseUrl}/test/reminders/scheduser`); 636 + const stored = await dbRes.json() as Array<unknown>; 637 + expect(stored).toHaveLength(0); 638 + }); 639 + }); 640 + 641 + // --- Reminder scheduler logic (DB-level) --- 642 + 643 + describe('reminder scheduler logic', () => { 644 + const testEndpoint = 'https://fcm.googleapis.com/fcm/send/scheduler-test'; 645 + 646 + beforeEach(async () => { 647 + db.exec('DELETE FROM scheduled_reminders'); 648 + db.exec('DELETE FROM push_subscriptions'); 649 + 650 + // Create subscription 651 + db.prepare('INSERT INTO push_subscriptions (user_login, endpoint, p256dh, auth) VALUES (?, ?, ?, ?)').run( 652 + 'schedtest', testEndpoint, 'test-p256dh', 'test-auth' 653 + ); 654 + }); 655 + 656 + it('finds due reminders', async () => { 657 + const past = Math.floor(Date.now() / 1000) - 60; 658 + const future = Math.floor(Date.now() / 1000) + 3600; 659 + 660 + db.prepare('INSERT INTO scheduled_reminders (user_login, fire_at, encrypted_payload, subscription_endpoint) VALUES (?, ?, ?, ?)').run( 661 + 'schedtest', past, 'past-payload', testEndpoint 662 + ); 663 + db.prepare('INSERT INTO scheduled_reminders (user_login, fire_at, encrypted_payload, subscription_endpoint) VALUES (?, ?, ?, ?)').run( 664 + 'schedtest', future, 'future-payload', testEndpoint 665 + ); 666 + 667 + const nowSec = Math.floor(Date.now() / 1000); 668 + const dueRes = await fetch(`${baseUrl}/test/due-reminders/${nowSec}`); 669 + const due = await dueRes.json() as Array<{ encrypted_payload: string }>; 670 + expect(due).toHaveLength(1); 671 + expect(due[0]!.encrypted_payload).toBe('past-payload'); 672 + }); 673 + 674 + it('marks reminders as sent', async () => { 675 + const past = Math.floor(Date.now() / 1000) - 60; 676 + const result = db.prepare('INSERT INTO scheduled_reminders (user_login, fire_at, encrypted_payload, subscription_endpoint) VALUES (?, ?, ?, ?)').run( 677 + 'schedtest', past, 'payload', testEndpoint 678 + ); 679 + const id = Number(result.lastInsertRowid); 680 + 681 + await fetch(`${baseUrl}/test/mark-sent/${id}`, { method: 'POST' }); 682 + 683 + // Should no longer appear as due 684 + const nowSec = Math.floor(Date.now() / 1000); 685 + const dueRes = await fetch(`${baseUrl}/test/due-reminders/${nowSec}`); 686 + const due = await dueRes.json() as Array<unknown>; 687 + expect(due).toHaveLength(0); 688 + }); 689 + 690 + it('cleans up old sent reminders', async () => { 691 + const eightDaysAgo = Math.floor(Date.now() / 1000) - 8 * 24 * 60 * 60; 692 + const twoDaysAgo = Math.floor(Date.now() / 1000) - 2 * 24 * 60 * 60; 693 + 694 + // Old sent reminder (should be cleaned) 695 + db.prepare('INSERT INTO scheduled_reminders (user_login, fire_at, encrypted_payload, subscription_endpoint, sent) VALUES (?, ?, ?, ?, 1)').run( 696 + 'schedtest', eightDaysAgo, 'old-payload', testEndpoint 697 + ); 698 + // Recent sent reminder (should NOT be cleaned) 699 + db.prepare('INSERT INTO scheduled_reminders (user_login, fire_at, encrypted_payload, subscription_endpoint, sent) VALUES (?, ?, ?, ?, 1)').run( 700 + 'schedtest', twoDaysAgo, 'recent-payload', testEndpoint 701 + ); 702 + 703 + const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60; 704 + const cleanRes = await fetch(`${baseUrl}/test/cleanup/${sevenDaysAgo}`, { method: 'POST' }); 705 + const cleanData = await cleanRes.json() as { deleted: number }; 706 + expect(cleanData.deleted).toBe(1); 707 + 708 + // Verify only the recent one remains 709 + const remaining = db.prepare('SELECT * FROM scheduled_reminders').all() as Array<{ encrypted_payload: string }>; 710 + expect(remaining).toHaveLength(1); 711 + expect(remaining[0]!.encrypted_payload).toBe('recent-payload'); 712 + }); 713 + 714 + it('cascade deletes reminders when subscription is deleted', () => { 715 + // Enable foreign keys for this test 716 + db.pragma('foreign_keys = ON'); 717 + 718 + const past = Math.floor(Date.now() / 1000) - 60; 719 + db.prepare('INSERT INTO scheduled_reminders (user_login, fire_at, encrypted_payload, subscription_endpoint) VALUES (?, ?, ?, ?)').run( 720 + 'schedtest', past, 'payload', testEndpoint 721 + ); 722 + 723 + // Delete the subscription 724 + db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(testEndpoint); 725 + 726 + // Reminders should be cascade deleted 727 + const remaining = db.prepare('SELECT * FROM scheduled_reminders').all(); 728 + expect(remaining).toHaveLength(0); 729 + }); 730 + }); 731 + 732 + // --- ICS proxy URL validation via HTTP --- 733 + 734 + describe('ICS proxy route', () => { 735 + it('rejects unauthenticated requests', async () => { 736 + const res = await fetch(`${baseUrl}/api/ics-proxy?url=https://example.com/cal.ics`); 737 + expect(res.status).toBe(403); 738 + }); 739 + 740 + it('rejects missing url parameter', async () => { 741 + const res = await fetch(`${baseUrl}/api/ics-proxy`, { 742 + headers: { 'Tailscale-User-Login': 'testuser' }, 743 + }); 744 + expect(res.status).toBe(400); 745 + }); 746 + 747 + it('rejects http URLs', async () => { 748 + const res = await fetch(`${baseUrl}/api/ics-proxy?url=${encodeURIComponent('http://example.com/cal.ics')}`, { 749 + headers: { 'Tailscale-User-Login': 'testuser' }, 750 + }); 751 + expect(res.status).toBe(400); 752 + }); 753 + 754 + it('rejects localhost URLs', async () => { 755 + const res = await fetch(`${baseUrl}/api/ics-proxy?url=${encodeURIComponent('https://localhost/cal.ics')}`, { 756 + headers: { 'Tailscale-User-Login': 'testuser' }, 757 + }); 758 + expect(res.status).toBe(400); 759 + }); 760 + 761 + it('rejects private IP URLs', async () => { 762 + const res = await fetch(`${baseUrl}/api/ics-proxy?url=${encodeURIComponent('https://192.168.1.1/cal.ics')}`, { 763 + headers: { 'Tailscale-User-Login': 'testuser' }, 764 + }); 765 + expect(res.status).toBe(400); 766 + }); 767 + 768 + it('rejects 10.x.x.x private range', async () => { 769 + const res = await fetch(`${baseUrl}/api/ics-proxy?url=${encodeURIComponent('https://10.0.0.1/cal.ics')}`, { 770 + headers: { 'Tailscale-User-Login': 'testuser' }, 771 + }); 772 + expect(res.status).toBe(400); 773 + }); 774 + });