#!/usr/bin/env node // Web Push for Flowtrack: VAPID keys, payload encryption (RFC 8291) and the // daily "period soon / late" check. Dependency-free: Node's own crypto and // SQLite. The schema is owned by app.py, which always initializes it first. "use strict"; const crypto = require("node:crypto"); const path = require("node:path"); const Cycle = require(path.join(__dirname, "static", "cycle.js")); const b64url = { encode: bytes => Buffer.from(bytes).toString("base64url"), decode: text => Buffer.from(text, "base64url") }; const formatDay = number => new Date(number * 86400000).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: "UTC" }); const localIso = () => { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; }; function openDatabase(file) { const { DatabaseSync } = require("node:sqlite"); const db = new DatabaseSync(file); db.exec("PRAGMA busy_timeout=10000"); return db; } const setting = (db, key) => { const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key); return row ? row.value : null; }; // Generated once and kept in the database: browsers bind each subscription // to this key, so replacing it would silently break every subscribed device. function vapidKeys(db) { if (!setting(db, "vapid_private")) { const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" }); const jwk = privateKey.export({ format: "jwk" }); const insert = db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)"); insert.run("vapid_private", JSON.stringify(jwk)); insert.run("vapid_public", b64url.encode(Buffer.concat([Buffer.from([4]), b64url.decode(jwk.x), b64url.decode(jwk.y)]))); } return { privateKey: crypto.createPrivateKey({ key: JSON.parse(setting(db, "vapid_private")), format: "jwk" }), publicKey: setting(db, "vapid_public") }; } function vapidAuthorization(endpoint, keys, contact, now = Date.now()) { const header = b64url.encode(JSON.stringify({ typ: "JWT", alg: "ES256" })); const claims = b64url.encode(JSON.stringify({ aud: new URL(endpoint).origin, exp: Math.floor(now / 1000) + 12 * 3600, sub: contact })); const signature = crypto.sign("sha256", Buffer.from(`${header}.${claims}`), { key: keys.privateKey, dsaEncoding: "ieee-p1363" }); return `vapid t=${header}.${claims}.${b64url.encode(signature)}, k=${keys.publicKey}`; } // RFC 8291 "aes128gcm": one record holding the whole payload. function encrypt(payload, subscriberKey, authSecret) { const receiver = b64url.decode(subscriberKey); const auth = b64url.decode(authSecret); const sender = crypto.createECDH("prime256v1"); const senderKey = sender.generateKeys(); const shared = sender.computeSecret(receiver); const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0"), receiver, senderKey]); const ikm = Buffer.from(crypto.hkdfSync("sha256", shared, auth, keyInfo, 32)); const salt = crypto.randomBytes(16); const cek = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16)); const nonce = Buffer.from(crypto.hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12)); const cipher = crypto.createCipheriv("aes-128-gcm", cek, nonce); // 0x02 marks the last (and only) record. const body = Buffer.concat([cipher.update(Buffer.concat([Buffer.from(payload), Buffer.from([2])])), cipher.final(), cipher.getAuthTag()]); const header = Buffer.alloc(21); salt.copy(header, 0); header.writeUInt32BE(4096, 16); header.writeUInt8(senderKey.length, 20); return Buffer.concat([header, senderKey, body]); } async function sendAll(db, contact, message) { const keys = vapidKeys(db); const subscriptions = db.prepare("SELECT endpoint, p256dh, auth FROM push_subscriptions").all(); const result = { sent: 0, failed: 0, removed: 0, errors: [] }; const payload = JSON.stringify(message); for (const subscription of subscriptions) { try { const response = await fetch(subscription.endpoint, { method: "POST", headers: { Authorization: vapidAuthorization(subscription.endpoint, keys, contact), "Content-Encoding": "aes128gcm", "Content-Type": "application/octet-stream", TTL: String(message.ttl || 86400), Urgency: "normal" }, body: encrypt(payload, subscription.p256dh, subscription.auth), signal: AbortSignal.timeout(20000) }); if (response.ok) { result.sent += 1; } else if (response.status === 404 || response.status === 410) { // The device unsubscribed or the app was removed from the home screen. db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(subscription.endpoint); result.removed += 1; } else { result.failed += 1; result.errors.push(`${new URL(subscription.endpoint).host}: ${response.status} ${(await response.text()).slice(0, 200)}`); } } catch (error) { result.failed += 1; result.errors.push(`${new URL(subscription.endpoint).host}: ${error.message}`); } } return result; } // Which reminder, if any, is due today. Each kind is sent at most once per // cycle (keyed on the cycle's start date), so a missed timer run or a second // run on the same day never repeats it. function dueReminder(summary, remindDays) { const { current, predictions, late, today } = summary; if (!current || current.onPeriod) return null; const cycle = Cycle.toIso(current.start); if (late > 0) { return { kind: "late", cycle, title: "Period is late", body: `It was expected on ${formatDay(today - late)}. Log it in Flowtrack when it starts.` }; } const next = predictions[0]; const until = next.start - today; if (until < 1 || until > remindDays) return null; return { kind: "soon", cycle, title: "Period expected soon", body: until === 1 ? `Your period is expected tomorrow, ${formatDay(next.start)}.` : `Your period is expected in about ${until} days, around ${formatDay(next.start)}.` }; } async function daily(db, contact, todayIso) { const days = {}; for (const row of db.prepare("SELECT day, flow, symptoms FROM days").all()) { days[row.day] = { flow: row.flow, symptoms: JSON.parse(row.symptoms) }; } const remindDays = Number(setting(db, "remind_days")) || 2; const reminder = dueReminder(Cycle.summarize(days, todayIso), remindDays); if (!reminder) return { due: null }; if (db.prepare("SELECT 1 FROM notifications_sent WHERE kind = ? AND cycle = ?").get(reminder.kind, reminder.cycle)) { return { due: reminder.kind, alreadySent: true }; } const result = await sendAll(db, contact, { title: reminder.title, body: reminder.body, tag: reminder.kind, url: "/" }); // Only mark it sent once a device got it, so subscribing later the same // day still delivers it on the next run. if (result.sent) { db.prepare("INSERT OR IGNORE INTO notifications_sent (kind, cycle, sent_at) VALUES (?, ?, ?)") .run(reminder.kind, reminder.cycle, new Date().toISOString()); } return { due: reminder.kind, ...result }; } function parseArgs(argv) { const [command, ...rest] = argv; const options = { command }; for (let index = 0; index < rest.length; index += 2) { if (!rest[index].startsWith("--")) throw new Error(`Unexpected argument ${rest[index]}`); options[rest[index].slice(2)] = rest[index + 1]; } return options; } async function main(argv) { const options = parseArgs(argv); if (!options.database) throw new Error("--database is required"); const db = openDatabase(options.database); try { if (options.command === "init") return { publicKey: vapidKeys(db).publicKey }; if (!options.contact) throw new Error("--contact is required"); if (options.command === "test") { return await sendAll(db, options.contact, { title: "Flowtrack", body: "Test notification: reminders will arrive like this.", tag: "test", url: "/", ttl: 600 }); } if (options.command === "daily") return await daily(db, options.contact, options.today || localIso()); throw new Error("usage: flowtrack-notify init|test|daily --database DB [--contact URL] [--today YYYY-MM-DD]"); } finally { db.close(); } } if (require.main === module) { main(process.argv.slice(2)).then( result => { console.log(JSON.stringify(result)); }, error => { console.error(error.message); process.exitCode = 1; } ); } module.exports = { b64url, encrypt, vapidKeys, vapidAuthorization, dueReminder, main };