diff --git a/README.md b/README.md index 68e355d..b611d46 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,25 @@ boundary. The logic lives in `static/cycle.js` and is tested in `tests/test_cycle.js`. +## Notifications + +Web Push, sent by `notify.js` (Node, no dependencies: built-in crypto and +SQLite). It reuses `static/cycle.js`, so reminders always match the app. + +- `flowtrack-notify daily` runs once a day. It sends "period expected soon" + once per cycle, `remindDays` (1-7, default 2) before the predicted start, + and "period is late" once per cycle after the expected day passes. +- The owner turns notifications on per device in Settings, where there is + also a test button. On iPhone this only works from the Home Screen app + (iOS 16.4 or later). +- The VAPID key pair is created on first start and stored in the database. + Replacing it invalidates every subscription. +- The server only accepts subscriptions on Apple, Google, Mozilla and + Microsoft push hosts, because it POSTs to whatever endpoint it stores. + +Enable it with `--push-contact https://your.site` (the NixOS module does +this through `push.contact`). + ## Access Nginx must overwrite `Remote-User` with the Authelia username. Users listed @@ -49,6 +68,8 @@ services.flowtrack = { editors = [ "kayla" ]; viewers = [ "fred" ]; backup.directory = "/mnt/disk2/backups/flowtrack"; # nightly, keeps 30 + push.contact = "https://flowtrack.example.org"; + push.time = "*-*-* 09:00:00 Europe/London"; }; ``` diff --git a/app.py b/app.py index 76cdbb0..3895fb5 100644 --- a/app.py +++ b/app.py @@ -4,13 +4,17 @@ from __future__ import annotations import argparse +import base64 +import binascii import csv import io import json import logging import mimetypes +import os import re import sqlite3 +import subprocess from contextlib import closing from datetime import date, datetime, timedelta, timezone from http import HTTPStatus @@ -39,6 +43,10 @@ SYMPTOMS = ( ("mood-swings", "Mood swings"), ) SYMPTOM_IDS = frozenset(symptom_id for symptom_id, _ in SYMPTOMS) +# The server POSTs to whatever endpoint a browser hands it, so only the real +# push services are accepted (Apple, Google, Mozilla, Microsoft). +PUSH_HOSTS = ("push.apple.com", "fcm.googleapis.com", "push.services.mozilla.com", "notify.windows.com") +MAX_REMIND_DAYS = 7 mimetypes.add_type("application/manifest+json", ".webmanifest") @@ -76,6 +84,32 @@ def clean_entry(value: object) -> dict | None: return {"flow": flow, "symptoms": sorted(set(symptoms))} +def decode_key(value: object, length: int) -> bool: + if not isinstance(value, str) or len(value) > 200: + return False + try: + return len(base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))) == length + except (binascii.Error, ValueError): + return False + + +def clean_subscription(value: object) -> dict | None: + """A browser PushSubscription.toJSON() pointing at a known push service.""" + if not isinstance(value, dict) or not isinstance(value.get("keys"), dict): + return None + endpoint = value.get("endpoint") + if not isinstance(endpoint, str) or len(endpoint) > 1000: + return None + url = urlparse(endpoint) + host = url.hostname or "" + if url.scheme != "https" or not any(host == known or host.endswith("." + known) for known in PUSH_HOSTS): + return None + keys = value["keys"] + if not decode_key(keys.get("p256dh"), 65) or not decode_key(keys.get("auth"), 16): + return None + return {"endpoint": endpoint, "p256dh": keys["p256dh"], "auth": keys["auth"]} + + class Store: def __init__(self, path: str | Path): self.path = str(path) @@ -102,6 +136,21 @@ class Store: value TEXT NOT NULL ); INSERT OR IGNORE INTO settings (key, value) VALUES ('share_details', 'false'); + INSERT OR IGNORE INTO settings (key, value) VALUES ('remind_days', '2'); + CREATE TABLE IF NOT EXISTS push_subscriptions ( + endpoint TEXT PRIMARY KEY, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user TEXT NOT NULL, + created_at TEXT NOT NULL + ); + -- One row per reminder kind per cycle, so each is sent once. + CREATE TABLE IF NOT EXISTS notifications_sent ( + kind TEXT NOT NULL, + cycle TEXT NOT NULL, + sent_at TEXT NOT NULL, + PRIMARY KEY (kind, cycle) + ); """ ) @@ -110,18 +159,42 @@ class Store: rows = db.execute("SELECT day, flow, symptoms FROM days ORDER BY day").fetchall() return {row["day"]: {"flow": row["flow"], "symptoms": json.loads(row["symptoms"])} for row in rows} - def share_details(self) -> bool: + def setting(self, key: str) -> str | None: with closing(self.connect()) as db, db: - row = db.execute("SELECT value FROM settings WHERE key = 'share_details'").fetchone() - return row is not None and row["value"] == "true" + row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row else None - def set_share_details(self, enabled: bool) -> None: + def set_setting(self, key: str, value: str) -> None: + with closing(self.connect()) as db, db: + db.execute("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value)) + + def share_details(self) -> bool: + return self.setting("share_details") == "true" + + def remind_days(self) -> int: + try: + return min(MAX_REMIND_DAYS, max(1, int(self.setting("remind_days") or 2))) + except ValueError: + return 2 + + def add_subscription(self, subscription: dict, user: str) -> None: with closing(self.connect()) as db, db: db.execute( - "UPDATE settings SET value = ? WHERE key = 'share_details'", - ("true" if enabled else "false",), + """ + INSERT INTO push_subscriptions (endpoint, p256dh, auth, user, created_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET p256dh = excluded.p256dh, auth = excluded.auth, user = excluded.user + """, + (subscription["endpoint"], subscription["p256dh"], subscription["auth"], user, utc_now()), ) + def remove_subscription(self, endpoint: str) -> None: + with closing(self.connect()) as db, db: + db.execute("DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,)) + + def subscription_count(self) -> int: + with closing(self.connect()) as db, db: + return db.execute("SELECT COUNT(*) FROM push_subscriptions").fetchone()[0] + @staticmethod def _write(db: sqlite3.Connection, day: str, entry: dict, changed_at: str) -> None: # An empty log is the same as no log, so the row goes away. @@ -173,12 +246,36 @@ class Store: class TrackerServer(ThreadingHTTPServer): daemon_threads = True - def __init__(self, address: tuple[str, int], store: Store, editors: set[str], viewers: set[str], dev_user: str | None = None): + def __init__( + self, + address: tuple[str, int], + store: Store, + editors: set[str], + viewers: set[str], + dev_user: str | None = None, + notify: str | None = None, + push_contact: str | None = None, + ): super().__init__(address, Handler) self.store = store self.editors = editors self.viewers = viewers self.dev_user = dev_user + # Push is sent by the flowtrack-notify (Node) helper; without it, or + # without a VAPID contact, the app runs with push switched off. + self.notify = notify if notify and push_contact else None + self.push_contact = push_contact + + def run_notify(self, command: str) -> dict: + completed = subprocess.run( + [self.notify, command, "--database", self.store.path, "--contact", self.push_contact or ""], + capture_output=True, + text=True, + timeout=60, + ) + if completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or f"flowtrack-notify {command} failed") + return json.loads(completed.stdout) class Handler(BaseHTTPRequestHandler): @@ -235,6 +332,46 @@ class Handler(BaseHTTPRequestHandler): if entry["flow"] in PERIOD_FLOWS } + def push_state(self) -> dict: + store = self.server.store + return { + "available": self.server.notify is not None and store.setting("vapid_public") is not None, + "publicKey": store.setting("vapid_public"), + "remindDays": store.remind_days(), + "devices": store.subscription_count(), + } + + def handle_push(self, path: str) -> None: + if not self.push_state()["available"]: + self.error_json("Notifications are not set up on the server", HTTPStatus.SERVICE_UNAVAILABLE) + return + if path == "/api/push/test": + try: + result = self.server.run_notify("test") + except (RuntimeError, subprocess.TimeoutExpired, json.JSONDecodeError) as error: + logging.error("Test notification failed: %s", error) + self.error_json("Sending failed on the server", HTTPStatus.BAD_GATEWAY) + return + self.json_response(result) + return + payload = self.read_json() + if payload is None: + return + if path == "/api/push/subscribe": + subscription = clean_subscription(payload) + if subscription is None: + self.error_json("Not a supported push subscription", HTTPStatus.BAD_REQUEST) + return + self.server.store.add_subscription(subscription, self.role()[0]) + self.json_response({"devices": self.server.store.subscription_count()}) + return + endpoint = payload.get("endpoint") if isinstance(payload, dict) else None + if not isinstance(endpoint, str): + self.error_json("endpoint is required", HTTPStatus.BAD_REQUEST) + return + self.server.store.remove_subscription(endpoint) + self.json_response({"devices": self.server.store.subscription_count()}) + def read_json(self) -> object: if self.headers.get_content_type() != "application/json": self.error_json("Expected application/json", HTTPStatus.UNSUPPORTED_MEDIA_TYPE) @@ -272,6 +409,7 @@ class Handler(BaseHTTPRequestHandler): "shareDetails": self.server.store.share_details(), "symptoms": [{"id": symptom_id, "label": label} for symptom_id, label in SYMPTOMS], "days": self.visible_days(role), + "push": self.push_state() if role == "editor" else None, } ) return @@ -308,12 +446,22 @@ class Handler(BaseHTTPRequestHandler): payload = self.read_json() if payload is None: return - share = payload.get("shareDetails") if isinstance(payload, dict) else None + if not isinstance(payload, dict) or not payload or set(payload) - {"shareDetails", "remindDays"}: + self.error_json("Expected shareDetails and/or remindDays", HTTPStatus.BAD_REQUEST) + return + share = payload.get("shareDetails", False) + remind = payload.get("remindDays", 1) if not isinstance(share, bool): self.error_json("shareDetails must be boolean", HTTPStatus.BAD_REQUEST) return - self.server.store.set_share_details(share) - self.json_response({"shareDetails": share}) + if not isinstance(remind, int) or isinstance(remind, bool) or not 1 <= remind <= MAX_REMIND_DAYS: + self.error_json(f"remindDays must be 1-{MAX_REMIND_DAYS}", HTTPStatus.BAD_REQUEST) + return + if "shareDetails" in payload: + self.server.store.set_setting("share_details", "true" if share else "false") + if "remindDays" in payload: + self.server.store.set_setting("remind_days", str(remind)) + self.json_response({"shareDetails": self.server.store.share_details(), "remindDays": self.server.store.remind_days()}) return self.error_json("Not found", HTTPStatus.NOT_FOUND) @@ -323,6 +471,9 @@ class Handler(BaseHTTPRequestHandler): if role != "editor": self.error_json("Read-only access", HTTPStatus.FORBIDDEN) return + if path in ("/api/push/subscribe", "/api/push/unsubscribe", "/api/push/test"): + self.handle_push(path) + return if path != "/api/days": self.error_json("Not found", HTTPStatus.NOT_FOUND) return @@ -403,6 +554,8 @@ def main() -> None: parser.add_argument("--dev-user", help="identity to assume when no Remote-User header is sent (local testing only)") parser.add_argument("--backup-to", type=Path, help="write a snapshot of the database to this directory and exit") parser.add_argument("--keep", type=int, default=30, help="snapshots to keep with --backup-to") + parser.add_argument("--push-contact", help="VAPID contact (https: or mailto: URL); enables push notifications") + parser.add_argument("--notify", default=os.environ.get("FLOWTRACK_NOTIFY"), help="path to flowtrack-notify") args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") @@ -412,7 +565,13 @@ def main() -> None: if args.backup_to: logging.info("Wrote %s", store.backup(args.backup_to, args.keep)) return - server = TrackerServer((args.host, args.port), store, set(args.editor), set(args.viewer), args.dev_user) + server = TrackerServer( + (args.host, args.port), store, set(args.editor), set(args.viewer), args.dev_user, args.notify, args.push_contact + ) + if server.notify: + # Creates the VAPID key pair on first start; the browser needs its + # public half before it can subscribe. + server.run_notify("init") logging.info("Listening on http://%s:%s", args.host, args.port) server.serve_forever() diff --git a/flake.nix b/flake.nix index 7c1ce7c..68dab10 100644 --- a/flake.nix +++ b/flake.nix @@ -30,7 +30,10 @@ python3 -m py_compile app.py node --check static/app.js node --check static/cycle.js + node --check static/sw.js + node --check notify.js node tests/test_cycle.js + node --disable-warning=ExperimentalWarning tests/test_push.js touch $out ''; }); diff --git a/module.nix b/module.nix index f73e128..b3a0665 100644 --- a/module.nix +++ b/module.nix @@ -20,6 +20,7 @@ let }; userFlags = lib.concatMapStringsSep " " (user: "--editor ${lib.escapeShellArg user}") cfg.editors + " " + lib.concatMapStringsSep " " (user: "--viewer ${lib.escapeShellArg user}") cfg.viewers; + pushFlag = lib.optionalString (cfg.push.contact != null) " --push-contact ${lib.escapeShellArg cfg.push.contact}"; in { options.services.flowtrack = { @@ -45,6 +46,18 @@ in default = [ ]; description = "Remote-User names with read-only access."; }; + push.contact = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "https://flowtrack.example.org"; + description = "VAPID contact URL (https: or mailto:) sent to push services; null disables push notifications."; + }; + push.time = lib.mkOption { + type = lib.types.str; + default = "*-*-* 09:00:00"; + example = "*-*-* 09:00:00 Europe/London"; + description = "systemd OnCalendar expression for the daily reminder check."; + }; backup = { directory = lib.mkOption { type = lib.types.nullOr lib.types.str; @@ -73,12 +86,31 @@ in wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; serviceConfig = hardening // { - ExecStart = "${cfg.package}/bin/flowtrack --host 127.0.0.1 --port ${toString cfg.port} --database ${database} ${userFlags}"; + ExecStart = "${cfg.package}/bin/flowtrack --host 127.0.0.1 --port ${toString cfg.port} --database ${database} ${userFlags}${pushFlag}"; Restart = "on-failure"; RestartSec = 3; }; }; } + (lib.mkIf (cfg.push.contact != null) { + systemd.services.flowtrack-notify = { + description = "Send due Flowtrack reminders"; + after = [ "network-online.target" "flowtrack.service" ]; + wants = [ "network-online.target" ]; + serviceConfig = hardening // { + Type = "oneshot"; + ExecStart = "${cfg.package}/bin/flowtrack-notify daily --database ${database} --contact ${lib.escapeShellArg cfg.push.contact}"; + }; + }; + + systemd.timers.flowtrack-notify = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = cfg.push.time; + Persistent = true; + }; + }; + }) (lib.mkIf (cfg.backup.directory != null) { systemd.tmpfiles.rules = [ "d ${cfg.backup.directory} 0700 flowtrack flowtrack -" ]; diff --git a/notify.js b/notify.js new file mode 100644 index 0000000..c66f7bd --- /dev/null +++ b/notify.js @@ -0,0 +1,209 @@ +#!/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 }; diff --git a/package.nix b/package.nix index 4e89347..dccb368 100644 --- a/package.nix +++ b/package.nix @@ -1,8 +1,8 @@ -{ lib, stdenvNoCC, makeWrapper, python3 }: +{ lib, stdenvNoCC, makeWrapper, python3, nodejs-slim }: stdenvNoCC.mkDerivation { pname = "flowtrack"; - version = "1.1.0"; + version = "1.2.0"; src = ./.; nativeBuildInputs = [ makeWrapper ]; @@ -10,10 +10,14 @@ stdenvNoCC.mkDerivation { installPhase = '' runHook preInstall mkdir -p $out/share/flowtrack $out/bin - cp app.py $out/share/flowtrack/app.py + cp app.py notify.js $out/share/flowtrack/ cp -r static $out/share/flowtrack/static + # node:sqlite still prints an ExperimentalWarning on every start. + makeWrapper ${nodejs-slim}/bin/node $out/bin/flowtrack-notify \ + --add-flags "--disable-warning=ExperimentalWarning $out/share/flowtrack/notify.js" makeWrapper ${python3}/bin/python3 $out/bin/flowtrack \ - --add-flags "$out/share/flowtrack/app.py" + --add-flags "$out/share/flowtrack/app.py" \ + --set-default FLOWTRACK_NOTIFY $out/bin/flowtrack-notify runHook postInstall ''; diff --git a/static/app.js b/static/app.js index a14e3bf..5e86d6f 100644 --- a/static/app.js +++ b/static/app.js @@ -14,7 +14,10 @@ days: {}, view: "today", month: null, - sheetDay: null + sheetDay: null, + push: null, + // This device's PushSubscription: undefined until checked, then null or set. + device: undefined }; const $ = id => document.getElementById(id); @@ -79,10 +82,12 @@ role: payload.role, shareDetails: payload.shareDetails, symptoms: payload.symptoms, - days: payload.days + days: payload.days, + push: payload.push }); if (!$("banner").classList.contains("error")) showBanner(""); render(); + if (state.device === undefined) await checkDevice(); } catch (error) { showBanner(error.message === "Not allowed" ? "This account does not have access to Flowtrack." : error.message, true); } @@ -507,7 +512,151 @@ // ---- Settings ------------------------------------------------------------ + // ---- Notifications ------------------------------------------------------- + + const isIos = () => /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + const installed = () => window.navigator.standalone === true || window.matchMedia("(display-mode: standalone)").matches; + const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; + const keyBytes = key => Uint8Array.from(atob(key.replace(/-/g, "+").replace(/_/g, "/")), char => char.charCodeAt(0)); + const keyText = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + // serviceWorker.ready never settles if registration failed, so give up. + const workerReady = () => Promise.race([ + navigator.serviceWorker.ready, + new Promise((_, reject) => setTimeout(() => reject(new Error("the notification worker did not start")), 8000)) + ]); + + async function checkDevice() { + if (!editable() || !state.push || !state.push.available || !pushSupported()) { + state.device = null; + return; + } + try { + const registration = await workerReady(); + let subscription = await registration.pushManager.getSubscription(); + // A subscription made for a different server key can never be + // delivered to, so drop it rather than show notifications as on. + if (subscription && subscription.options.applicationServerKey && + keyText(subscription.options.applicationServerKey) !== state.push.publicKey) { + await subscription.unsubscribe(); + subscription = null; + } + // Re-register on every launch so the server copy survives a restore. + if (subscription) await sendJson("POST", "/api/push/subscribe", subscription.toJSON()); + state.device = subscription; + } catch { + state.device = null; + } + render(); + } + + async function enablePush() { + try { + // iOS only shows the permission prompt straight from a tap, so this + // must be the first thing awaited in the click handler. + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + showBanner("Notifications were not allowed.", true); + render(); + return; + } + const registration = await workerReady(); + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: keyBytes(state.push.publicKey) + }); + const { devices } = await sendJson("POST", "/api/push/subscribe", subscription.toJSON()); + state.device = subscription; + state.push.devices = devices; + showBanner("Notifications are on. Send a test to check."); + } catch (error) { + showBanner(`Couldn't turn on notifications: ${error.message}`, true); + } + render(); + } + + async function disablePush() { + try { + const { devices } = await sendJson("POST", "/api/push/unsubscribe", { endpoint: state.device.endpoint }); + await state.device.unsubscribe(); + state.device = null; + state.push.devices = devices; + showBanner("Notifications are off on this device."); + } catch (error) { + showBanner(`Couldn't turn off notifications: ${error.message}`, true); + } + render(); + } + + async function testPush(button) { + button.disabled = true; + try { + const result = await api("/api/push/test", { method: "POST" }); + if (result.sent) showBanner(`Test sent to ${plural(result.sent, "device")}. It should arrive within a few seconds.`); + else showBanner(result.errors[0] ? `Test failed: ${result.errors[0]}` : "No devices are signed up for notifications.", true); + } catch (error) { + showBanner(`Test failed: ${error.message}`, true); + } finally { + button.disabled = false; + } + } + + async function setRemindDays(remindDays) { + try { + await sendJson("PUT", "/api/settings", { remindDays }); + state.push.remindDays = remindDays; + showBanner(`You'll be reminded ${plural(remindDays, "day")} before.`); + render(); + } catch (error) { + showBanner(`Couldn't save: ${error.message}`, true); + } + } + + function renderNotifications() { + const body = $("notify-body"); + const note = text => h("p", { class: "notify-note" }, text); + if (!state.push || !state.push.available) { + body.replaceChildren(note("Notifications are not set up on the server yet.")); + return; + } + if (!pushSupported()) { + body.replaceChildren(note(isIos() && !installed() + ? "On iPhone, notifications only work from the Home Screen app. In Safari tap Share, then Add to Home Screen, and open Flowtrack from there." + : "This browser does not support notifications.")); + return; + } + if (Notification.permission === "denied") { + body.replaceChildren(note(isIos() + ? "Notifications are blocked. Turn them on in the iPhone Settings app under Notifications, Flowtrack." + : "Notifications are blocked for this site in the browser settings.")); + return; + } + if (state.device === undefined) { + body.replaceChildren(note("Checking…")); + return; + } + const days = h("select", { "aria-label": "Days before" }, range(1, 7).map(count => + h("option", { value: String(count), selected: count === state.push.remindDays }, String(count)) + )); + days.addEventListener("change", () => setRemindDays(Number(days.value))); + const remind = h("label", { class: "remind-row" }, "Remind me", days, state.push.remindDays === 1 ? "day before" : "days before"); + if (!state.device) { + body.replaceChildren( + h("button", { type: "button", class: "button", onclick: enablePush }, "Turn on notifications"), + remind + ); + return; + } + const test = h("button", { type: "button", class: "button" }, "Send test notification"); + test.addEventListener("click", () => testPush(test)); + body.replaceChildren( + h("p", { class: "notify-status" }, h("span", { class: "status-dot" }), "On for this device"), + remind, + h("div", { class: "button-row" }, test, h("button", { type: "button", class: "button secondary", onclick: disablePush }, "Turn off")) + ); + } + function renderSettings(summary) { + renderNotifications(); $("share-toggle").checked = state.shareDetails; const hint = summary.periodSamples ? `Leave "Ended" empty to use the average length so far, ${plural(summary.periodLength, "day")}.` @@ -572,6 +721,7 @@ } }); seedForm($("seed-form")); + if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js").catch(() => {}); // The home-screen app is often resumed rather than reopened, so refresh // whenever it comes back to the foreground (this also rolls "today" over). document.addEventListener("visibilitychange", () => { diff --git a/static/index.html b/static/index.html index 40b6dea..80cfa02 100644 --- a/static/index.html +++ b/static/index.html @@ -14,7 +14,7 @@ - +
A reminder before your period is due, and a note if it is late.
+ +