From c1f4a220f92c632de1f21cf4131a641061b61bd0 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:56:01 -0400 Subject: [PATCH] Web Push reminders for tasks and events (Settings -> Reminders) Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/build.yml | 1 + package-lock.json | 181 ++++++++++++++++++++++++++++++++++++- package.json | 5 +- public/app.js | 3 +- public/index.html | 1 + public/reminders.js | 44 +++++++++ public/sw.js | 17 +++- push.js | 119 ++++++++++++++++++++++++ server.js | 7 +- tools/push-test.js | 57 ++++++++++++ 10 files changed, 426 insertions(+), 9 deletions(-) create mode 100644 public/reminders.js create mode 100644 push.js create mode 100644 tools/push-test.js diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 01ab1f6..39eb558 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -20,6 +20,7 @@ jobs: - run: npm audit --omit=dev --audit-level=high - run: node tools/smoke-test.js - run: node tools/tasks-test.js # repeating tasks and subtasks + - run: node tools/push-test.js # reminders: when they are sent, never twice - run: node tools/users-test.js # accounts: private data, tab limits, invites, migration - run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself - run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery diff --git a/package-lock.json b/package-lock.json index 055e1ea..376cb99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { - "name": "project", + "name": "ne-hub", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "project", + "name": "ne-hub", "version": "1.0.0", "license": "ISC", "dependencies": { - "@simplewebauthn/server": "^14.0.3" + "@simplewebauthn/server": "^14.0.3", + "web-push": "^3.6.7" } }, "node_modules/@hexagon/base64": { @@ -278,6 +279,27 @@ "node": ">=20.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/asn1js": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz", @@ -292,6 +314,114 @@ "node": ">=12.0.0" } }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -316,6 +446,32 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -339,6 +495,25 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } } } } diff --git a/package.json b/package.json index 22ee15f..6408253 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "start": "node server.js", - "test": "for t in smoke tasks users plex player lyrics signal webmail; do node tools/$t-test.js || exit 1; done", + "test": "for t in smoke tasks push users plex player lyrics signal webmail; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], @@ -13,6 +13,7 @@ "license": "ISC", "type": "commonjs", "dependencies": { - "@simplewebauthn/server": "^14.0.3" + "@simplewebauthn/server": "^14.0.3", + "web-push": "^3.6.7" } } diff --git a/public/app.js b/public/app.js index eb874d7..a9b17ef 100644 --- a/public/app.js +++ b/public/app.js @@ -1680,7 +1680,8 @@ function viewSettings() {

Signal alerts

How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.

-

Install

iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.

`; +
${remindersHtml()}

Install

iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.

`; + remindersInit(); // only show what this person may use const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); }; hide(!allowed('mail'), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out'); diff --git a/public/index.html b/public/index.html index a54c478..a9d50a4 100644 --- a/public/index.html +++ b/public/index.html @@ -55,6 +55,7 @@ + diff --git a/public/reminders.js b/public/reminders.js new file mode 100644 index 0000000..20cd4b5 --- /dev/null +++ b/public/reminders.js @@ -0,0 +1,44 @@ +// Settings -> Reminders: turn on push notifications for tasks due today and events about to start (Web Push). +const remSupported = () => 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; +const b64ToBytes = s => { const p = '='.repeat((4 - s.length % 4) % 4), r = atob((s + p).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...r].map(c => c.charCodeAt(0))); }; +const myTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + +function remindersHtml() { + return `

Reminders

Get a notification for tasks due today and events about to start, even when the app is closed. Turn it on for each device you want reminders on.

+
Checking…
`; +} + +async function remindersInit() { + const box = document.getElementById('remlist'); if (!box) return; + if (!(allowed('tasks') || allowed('calendar'))) return document.getElementById('sec-rem').remove(); + if (!remSupported()) { box.innerHTML = `
Not available here${/iPhone|iPad/.test(navigator.userAgent) ? 'On iPhone and iPad, add NE Hub to the Home Screen first (Share → Add to Home Screen), then open it from there.' : 'This browser does not support push notifications.'}
`; return; } + let info; try { info = await jfetch('/api/push/key'); } catch (e) { box.innerHTML = `
${esc(e.message)}
`; return; } + const reg = await navigator.serviceWorker.ready, sub = await reg.pushManager.getSubscription(); + const on = !!sub && Notification.permission === 'granted'; + const post = (p, body) => jfetch('/api/push/' + p, { method: 'POST', body: JSON.stringify({ tz: myTz(), ...body }) }); + box.innerHTML = ` + ${allowed('tasks') ? ` +
Morning time
` : ''} + ${allowed('calendar') ? ` +
How early
` : ''} +
Send a test to every device that is on
`; + const on1 = document.getElementById('remon'); + on1.onchange = async () => { + try { + if (on1.checked) { + const perm = Notification.permission === 'default' ? await Notification.requestPermission() : Notification.permission; + if (perm !== 'granted') { on1.checked = false; return toast('Notifications were not allowed'); } + const s = (await reg.pushManager.getSubscription()) || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToBytes(info.publicKey) }); + await post('subscribe', { subscription: s.toJSON() }); toast('Reminders are on for this device'); + } else { + const s = await reg.pushManager.getSubscription(); + if (s) { await post('unsubscribe', { endpoint: s.endpoint }); await s.unsubscribe(); } + toast('Reminders are off for this device'); + } + remindersInit(); + } catch (e) { on1.checked = !on1.checked; toast(e.message); } + }; + const pref = (id, key, get) => { const el = document.getElementById(id); if (el) el.onchange = () => post('prefs', { [key]: get(el) }).catch(e => toast(e.message)); }; + pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value); + document.getElementById('remtest').onclick = () => post('test', {}).then(() => toast('Test sent')).catch(e => toast(e.message)); +} diff --git a/public/sw.js b/public/sw.js index 452a2ec..02dca94 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v7'; +const CACHE = 'hub-v8'; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; // Install: keep the icons, and (when signed in) the page itself with the scripts and styles it names, so the app opens offline. @@ -29,3 +29,18 @@ self.addEventListener('fetch', e => { return r; }).catch(() => caches.match(e.request))); }); + +// Push reminders (see push.js on the server) +self.addEventListener('push', e => { + let d = {}; try { d = e.data.json(); } catch {} + e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, data: { url: d.url || '#/home' } })); +}); +self.addEventListener('notificationclick', e => { + e.notification.close(); + const url = '/' + ((e.notification.data && e.notification.data.url) || ''); + e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(cs => { + const c = cs.find(x => new URL(x.url).origin === self.location.origin); + if (c) { c.focus(); return c.navigate(url); } + return self.clients.openWindow(url); + })); +}); diff --git a/push.js b/push.js new file mode 100644 index 0000000..f0162d3 --- /dev/null +++ b/push.js @@ -0,0 +1,119 @@ +// Web Push reminders: a notification on a person's devices for tasks due today and events about to start, +// even when the app is closed. Keys are made once and kept in /data/vapid.json; each person's devices and preferences +// live in their own folder (push.json). iPhone and iPad only deliver these to the installed home-screen app. +const fs = require('fs'); +const path = require('path'); +const webpush = require('web-push'); +const atomicWrite = require('./atomic'); +const users = require('./users'); +const tasks = require('./tasks'); +const cal = require('./cal'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 20000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); +const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; + +let vapid = null; +function keys() { + if (vapid) return vapid; + const f = path.join(DATA_DIR, 'vapid.json'); + try { vapid = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { vapid = webpush.generateVAPIDKeys(); fs.mkdirSync(DATA_DIR, { recursive: true }); atomicWrite(f, JSON.stringify(vapid)); } + webpush.setVapidDetails(process.env.VAPID_SUBJECT || (process.env.PUBLIC_URL || 'mailto:admin@localhost.invalid'), vapid.publicKey, vapid.privateKey); + return vapid; +} + +const DEFAULTS = { tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' }; +const file = dir => path.join(dir, 'push.json'); +const load = dir => { try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(file(dir), 'utf8')) }; } catch { return { ...DEFAULTS, subs: [], sent: [] }; } }; +const save = (dir, o) => atomicWrite(file(dir), JSON.stringify(o)); +const validTz = tz => { try { new Intl.DateTimeFormat('en', { timeZone: tz }); return true; } catch { return false; } }; +/** The person's own wall clock: { day: 'YYYY-MM-DD', hm: 'HH:MM' } */ +function localNow(tz, at = new Date()) { + const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(at).map(x => [x.type, x.value])); + return { day: `${p.year}-${p.month}-${p.day}`, hm: `${p.hour}:${p.minute}` }; +} + +async function send(dir, st, payload) { + keys(); + let changed = false; + await Promise.all(st.subs.map(async s => { + try { await webpush.sendNotification(s.sub, JSON.stringify(payload), { TTL: 3600 }); } + catch (e) { if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; } else console.warn('[push] send failed:', e.statusCode || e.message); } + })); + if (changed) { st.subs = st.subs.filter(s => !s.dead); save(dir, st); } +} + +let lastEvents = 0; +/** One pass over everyone: runs every minute. */ +async function tick(now = new Date(), force = false) { + const checkEvents = force || now.getTime() - lastEvents >= 4.5 * 60e3; + if (checkEvents) lastEvents = now.getTime(); + for (const u of users.all()) { + if (u.disabled) continue; + const dir = users.dirOf(u.id), st = load(dir); + if (!st.subs.length) continue; + await users.runAs(u, async () => { + const lt = localNow(st.tz, now); let dirty = false; + if (st.tasks && users.can(u, 'tasks') && st.tasksDay !== lt.day && lt.hm >= st.tasksAt) { + st.tasksDay = lt.day; dirty = true; + try { + const due = (await tasks.allTasks()).filter(t => !t.done && t.due && t.due <= lt.day); + if (due.length) { + const over = due.filter(t => t.due < lt.day).length; + await send(dir, st, { title: due.length === 1 ? due[0].title : due.length + ' tasks need attention', body: due.length === 1 ? (over ? 'Overdue' : 'Due today') : due.slice(0, 3).map(t => t.title).join(', ') + (due.length > 3 ? '…' : '') + (over ? ` (${over} overdue)` : ''), url: '#/tasks', tag: 'tasks-' + lt.day }); + } + } catch (e) { console.warn('[push] tasks check failed:', e.message); } + } + if (checkEvents && st.events && users.can(u, 'calendar')) { + try { + const from = now.getTime(), to = from + (st.eventMins + 5) * 60e3; + const fd = new Date(from - 864e5).toISOString().slice(0, 10), td = new Date(to + 864e5).toISOString().slice(0, 10); + const r = await cal.collectEvents({ from, to, fd, td }); + for (const e of r.events) { + if (e.allDay || e.start < from || e.start > to) continue; + const key = (e.uid || e.title) + '@' + e.start; + if (st.sent.includes(key)) continue; + st.sent.push(key); dirty = true; + const mins = Math.max(1, Math.round((e.start - from) / 6e4)); + await send(dir, st, { title: e.title, body: `Starts in ${mins} min${e.location ? ' · ' + e.location : ''}`, url: '#/calendar', tag: key }); + } + if (st.sent.length > 300) { st.sent = st.sent.slice(-200); dirty = true; } + } catch (e) { console.warn('[push] events check failed:', e.message); } + } + if (dirty) save(dir, st); + }); + } +} +let timer = null; +function start() { if (!timer) { timer = setInterval(() => tick().catch(e => console.warn('[push]', e.message)), 60e3); timer.unref(); } } + +async function handle(req, res) { + const p = new URL(req.url, 'http://x').pathname, dir = users.dir(), st = load(dir); + const pub = () => ({ tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length }); + if (p === '/api/push/key' && req.method === 'GET') return json(res, 200, { publicKey: keys().publicKey, ...pub() }); + if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); + const b = JSON.parse((await readBody(req)) || '{}'); + if (p === '/api/push/subscribe') { + const s = b.subscription; + if (!s || !/^https:\/\//.test(s.endpoint || '') || !s.keys || !s.keys.p256dh || !s.keys.auth) return json(res, 400, { error: 'That is not a valid push subscription' }); + st.subs = st.subs.filter(x => x.sub.endpoint !== s.endpoint).concat({ sub: { endpoint: s.endpoint, keys: { p256dh: s.keys.p256dh, auth: s.keys.auth } }, created: Date.now() }); + if (b.tz && validTz(b.tz)) st.tz = b.tz; + save(dir, st); return json(res, 200, { ok: true, ...pub() }); + } + if (p === '/api/push/unsubscribe') { st.subs = st.subs.filter(x => x.sub.endpoint !== b.endpoint); save(dir, st); return json(res, 200, { ok: true, ...pub() }); } + if (p === '/api/push/prefs') { + if (typeof b.tasks === 'boolean') st.tasks = b.tasks; + if (typeof b.events === 'boolean') st.events = b.events; + if (/^([01]\d|2[0-3]):[0-5]\d$/.test(b.tasksAt || '')) st.tasksAt = b.tasksAt; + if ([5, 10, 15, 30, 60].includes(b.eventMins)) st.eventMins = b.eventMins; + if (b.tz && validTz(b.tz)) st.tz = b.tz; + save(dir, st); return json(res, 200, { ok: true, ...pub() }); + } + if (p === '/api/push/test') { + if (!st.subs.length) return json(res, 400, { error: 'No device is signed up for reminders yet' }); + await send(dir, st, { title: 'NE Hub reminders are on', body: 'You will see task and event reminders here.', url: '#/home', tag: 'test' }); + return json(res, 200, { ok: true }); + } + json(res, 404, { error: 'Not found' }); +} +module.exports = { handle, start, tick, localNow }; diff --git a/server.js b/server.js index 7c8f597..4cd5f72 100644 --- a/server.js +++ b/server.js @@ -37,6 +37,7 @@ const search = require('./search'); const users = require('./users'); const admin = require('./admin'); const backup = require('./backup'); +const push = require('./push'); const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL) const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL) const vault = require('./vault')('nehub-plex'); @@ -99,7 +100,7 @@ function serveStatic(req, res) { if (err) { res.writeHead(404); return res.end('Not found'); } // The pages link to /app.js?v= etc. A new release changes the address, so no proxy cache ("Cache Assets" in // Nginx Proxy Manager, a CDN, the browser) can hand out an old copy. - if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell|calendar-views|music-extras|file-history)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); + if (p === '/index.html' || p === '/login.html') data = Buffer.from(String(data).replace(/(src|href)="(\/(?:app|webauthn|login|links|palette|shell|calendar-views|music-extras|file-history|reminders)\.js|\/style\.css)"/g, `$1="$2?v=${BUILD}"`)); const headers = { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }; if (p.startsWith('/icons/')) headers['cache-control'] = 'public, max-age=604800'; // icons and splash screens rarely change else { // everything else: the browser may keep a copy but must check with us first, so a new version is never missed @@ -113,7 +114,7 @@ function serveStatic(req, res) { } // Changes whenever the app's files change, so an open page can tell that a newer version is available. -const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'calendar-views.js', 'music-extras.js', 'file-history.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); +const BUILD = (() => { try { return crypto.createHash('sha1').update(['app.js', 'links.js', 'palette.js', 'shell.js', 'calendar-views.js', 'music-extras.js', 'file-history.js', 'reminders.js', 'index.html', 'style.css', 'webauthn.js', 'login.js', 'login.html', 'sw.js'].map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })(); // ---- OnlyOffice in-app editing (JWT-signed editor config + save-back callback to Seafile) const JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || ''; @@ -414,6 +415,7 @@ function storageInfo() { startedAt: STARTED }; } const STARTED = Date.now(); +push.start(); lyrics.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.LYRICS_API_URL || '' }); signal.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.SIGNAL_API_URL || '' }); { const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted: ${i.mounted === null ? 'unknown' : i.mounted ? (i.source.kind + ' ' + i.source.name) : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`); @@ -514,6 +516,7 @@ async function handleRequest(req, res) { if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server'); if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this'); if (pathname === '/api/ddg') return ddgAnswer(req, res); + if (pathname.startsWith('/api/push/')) return push.handle(req, res); if (pathname === '/api/audit') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify({ events: require('./audit').recent(200) })); } if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); } if (pathname === '/api/settings') return settings.handle(req, res); diff --git a/tools/push-test.js b/tools/push-test.js new file mode 100644 index 0000000..ddad2bc --- /dev/null +++ b/tools/push-test.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Push reminders: who gets told, when, and never twice. The push service itself is replaced by a recorder. +const fs = require('fs'), os = require('os'), path = require('path'); +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-push-')); +process.env.DATA_DIR = dir; process.env.COOKIE_SECRET = 'push-test-secret-'.repeat(3); +let failed = 0; +const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; }; + +const webpush = require('web-push'); +const sent = []; let dead = false; +webpush.sendNotification = async (sub, payload) => { if (dead) throw Object.assign(new Error('gone'), { statusCode: 410 }); sent.push({ sub, ...JSON.parse(payload) }); }; +const users = require('../users'), push = require('../push'); + +(async () => { + const u = users.create({ name: 'Pat', role: 'user', tabs: ['tasks', 'calendar'] }); + const d = users.dirOf(u.id); + const TZ = 'America/New_York', now = new Date('2026-10-22T13:00:00Z'); // 09:00 in New York + const { day, hm } = push.localNow(TZ, now); + ok('the person\'s own clock is used (09:00 in New York at 13:00 UTC)', day === '2026-10-22' && hm === '09:00', { day, hm }); + const writePush = o => fs.writeFileSync(path.join(d, 'push.json'), JSON.stringify({ tz: TZ, tasksAt: '08:00', eventMins: 15, tasks: true, events: true, subs: [{ sub: { endpoint: 'https://push.example/abc', keys: { p256dh: 'x', auth: 'y' } } }], ...o })); + fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [ + { id: 't1', list: 'inbox', title: 'Pay rent', due: day, done: false }, { id: 't2', list: 'inbox', title: 'Old thing', due: '2026-10-01', done: false }, + { id: 't3', list: 'inbox', title: 'Done thing', due: day, done: true }, { id: 't4', list: 'inbox', title: 'Later', due: '2026-12-01', done: false }] })); + + writePush({}); await push.tick(new Date('2026-10-22T11:00:00Z')); // 07:00 local: too early + ok('nothing is sent before the morning time', sent.length === 0, sent); + await push.tick(now); + ok('at the morning time one summary is sent for tasks due or overdue', sent.length === 1 && /2 tasks/.test(sent[0].title) && /1 overdue/.test(sent[0].body) && !/Done thing|Later/.test(sent[0].body), sent); + await push.tick(new Date(now.getTime() + 60e3)); + ok('and not again the same day', sent.length === 1, sent.length); + await push.tick(new Date(now.getTime() + 864e5)); + ok('but again the next day', sent.length === 2, sent.length); + + sent.length = 0; writePush({ tasks: false, subs: [] }); fs.writeFileSync(path.join(d, 'push.json'), JSON.stringify({ tz: TZ, subs: [] })); + await push.tick(new Date(now.getTime() + 2 * 864e5)); + ok('a person with no device signed up is never contacted', sent.length === 0, sent); + + // events: a local event starting in 10 minutes + const soon = new Date(Date.now() + 10 * 60e3), pad = n => String(n).padStart(2, '0'); + const st = `${soon.getUTCFullYear()}-${pad(soon.getUTCMonth() + 1)}-${pad(soon.getUTCDate())}T${pad(soon.getUTCHours())}:${pad(soon.getUTCMinutes())}`; + fs.writeFileSync(path.join(d, 'events.json'), JSON.stringify([{ uid: 'e1', fields: { title: 'Dentist', location: 'Main St', notes: '', allDay: false, start: st, end: st.replace(/T(\d\d)/, (m, h) => 'T' + pad((+h + 1) % 24)), tz: 'UTC', repeat: 'none' }, created: 1, updated: 1 }])); + writePush({ tasks: false }); sent.length = 0; + await push.tick(new Date(Date.now() - 3 * 36e5), true); + ok('an event hours away sends nothing', sent.length === 0, sent); + await push.tick(new Date(), true); + ok('an event about to start is announced with its place', sent.some(s => s.title === 'Dentist' && /Main St/.test(s.body)), sent); + await push.tick(new Date(Date.now() + 60e3), true); + ok('and only once', sent.filter(s => s.title === 'Dentist').length === 1, sent); + + // a subscription the push service says is gone is dropped + dead = true; writePush({ tasks: true, tasksAt: '00:00', tasksDay: '' }); fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'X', due: '2000-01-01', done: false }] })); + await push.tick(new Date()); + ok('a device that has unsubscribed (410) is removed', JSON.parse(fs.readFileSync(path.join(d, 'push.json'), 'utf8')).subs.length === 0, fs.readFileSync(path.join(d, 'push.json'), 'utf8')); + + fs.rmSync(dir, { recursive: true, force: true }); + console.log(failed ? `\n${failed} check(s) failed` : '\nAll push checks passed'); process.exit(failed ? 1 : 0); +})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });