flowtrack/notify.js
rope bb19bc0aeb Push notifications: period soon/late reminders, test button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:43:41 +01:00

209 lines
8.6 KiB
JavaScript

#!/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 };