Files
hub/push.js

145 lines
9.0 KiB
JavaScript

// 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 mail = require('./mail');
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 = { mail: true, mailSeen: null, 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 (u.role === 'admin' && checkEvents) { // the Microsoft client secret (for Mail) is about to expire
const ss = mail.secretStatus();
if (ss && ss.days <= 30) {
const bucket = ss.days <= 0 ? 0 : [1, 3, 7, 14, 30].find(t => ss.days <= t), key = `mailsecret:${ss.expires}:${bucket}`;
if (!st.sent.includes(key)) {
st.sent.push(key); dirty = true;
await send(dir, st, { title: ss.days <= 0 ? 'Mail sign-in has expired' : `Mail sign-in expires in ${ss.days} day${ss.days === 1 ? '' : 's'}`, body: 'Make a new client secret in Microsoft Entra and paste it in Settings -> Microsoft app.', url: '#/settings', tag: 'mailsecret' });
}
}
}
if (checkEvents && st.mail && users.can(u, 'mail')) { // new unread mail (the first look only notes what is already there)
try {
const now2 = (await mail.newest()).filter(m => !m.read), seen = st.mailSeen;
if (!Array.isArray(seen)) { st.mailSeen = now2.map(m => m.id); dirty = true; }
else {
const fresh = now2.filter(m => !seen.includes(m.id));
if (fresh.length) {
st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true;
await send(dir, st, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail' });
}
}
} catch (e) { console.warn('[push] mail 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 = () => ({ mail: st.mail, 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 (typeof b.mail === 'boolean') st.mail = b.mail;
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 };