209 lines
8.6 KiB
JavaScript
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 };
|