Web Push reminders for tasks and events (Settings -> Reminders)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 07:56:01 -04:00
1 parent a0f1efc4ef
commit c1f4a220f9
10 files changed
+426 -9

No files matched your search

+1
View File
@@ -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
+178 -3
View File
@@ -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"
}
}
}
}
+3 -2
View File
@@ -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"
}
}
+2 -1
View File
@@ -1680,7 +1680,8 @@ function viewSettings() {
</div><div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div><h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
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');
+1
View File
@@ -55,6 +55,7 @@
<script src="/webauthn.js"></script>
<script src="/links.js"></script>
<script src="/palette.js"></script>
<script src="/reminders.js"></script>
<script src="/file-history.js"></script>
<script src="/music-extras.js"></script>
<script src="/calendar-views.js"></script>
+44
View File
@@ -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 `<div id="sec-rem"><h2>Reminders</h2><p class="sub">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.</p>
<div class="list" id="remlist"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>`;
}
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 = `<div class="row"><span class="grow"><b>Not available here</b><small style="white-space:normal">${/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.'}</small></span></div>`; return; }
let info; try { info = await jfetch('/api/push/key'); } catch (e) { box.innerHTML = `<div class="row"><span class="grow"><small>${esc(e.message)}</small></span></div>`; 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 = `<label class="row" style="cursor:pointer"><span class="grow"><b>Reminders on this device</b><small id="remst" style="white-space:normal">${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' : ''}</small></span><input type="checkbox" id="remon" ${on ? 'checked' : ''} ${Notification.permission === 'denied' ? 'disabled' : ''}></label>
${allowed('tasks') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Tasks due today</b><small>One summary each morning</small></span><input type="checkbox" id="remt" ${info.tasks ? 'checked' : ''}></label>
<div class="row"><span class="grow"><b>Morning time</b></span><input type="time" id="remat" value="${esc(info.tasksAt)}" style="width:auto"></div>` : ''}
${allowed('calendar') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Upcoming events</b><small>Shortly before they start</small></span><input type="checkbox" id="reme" ${info.events ? 'checked' : ''}></label>
<div class="row"><span class="grow"><b>How early</b></span><select id="remm" style="width:auto">${[5, 10, 15, 30, 60].map(m => `<option value="${m}" ${m === info.eventMins ? 'selected' : ''}>${m} min</option>`).join('')}</select></div>` : ''}
<div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>`;
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));
}
+16 -1
View File
@@ -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);
}));
});
+119
View File
@@ -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 };
+5 -2
View File
@@ -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=<build> 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);
+57
View File
@@ -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); });