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() {
Install iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.
`;
+ Play test sound
${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.
+
`;
+}
+
+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 = `Reminders on this device ${Notification.permission === 'denied' ? 'Blocked in the browser settings for this site; allow notifications there first' : on ? 'On' : 'Off'}${info.devices ? ' · ' + info.devices + (info.devices === 1 ? ' device' : ' devices') + ' in total' : ''}
+ ${allowed('tasks') ? `Tasks due today One summary each morning
+ Morning time
` : ''}
+ ${allowed('calendar') ? `Upcoming events Shortly before they start
+ How early ${[5, 10, 15, 30, 60].map(m => `${m} min `).join('')}
` : ''}
+ Send a test to every device that is on Test
`;
+ 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); });