Push notifications: period soon/late reminders, test button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 09:43:41 +01:00
parent ce66b857db
commit bb19bc0aeb
12 changed files with 872 additions and 21 deletions

View file

@ -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";
};
```

181
app.py
View file

@ -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()

View file

@ -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
'';
});

View file

@ -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 -" ];

209
notify.js Normal file
View file

@ -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 };

View file

@ -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
'';

View file

@ -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", () => {

View file

@ -14,7 +14,7 @@
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/styles.css?v=1.1.0">
<link rel="stylesheet" href="/styles.css?v=1.2.0">
</head>
<body>
<header class="topbar">
@ -74,6 +74,11 @@
</section>
<section class="view" id="view-settings" aria-labelledby="tab-settings" hidden>
<div class="card" id="notify-card">
<h2>Notifications</h2>
<p class="subtitle">A reminder before your period is due, and a note if it is late.</p>
<div id="notify-body" class="notify-body"></div>
</div>
<div class="card" id="share-card">
<h2>Sharing</h2>
<label class="switch-row">
@ -125,7 +130,7 @@
</form>
</dialog>
<script src="/cycle.js?v=1.1.0"></script>
<script src="/app.js?v=1.1.0"></script>
<script src="/cycle.js?v=1.2.0"></script>
<script src="/app.js?v=1.2.0"></script>
</body>
</html>

View file

@ -307,6 +307,19 @@ main {
.switch-row small { color: var(--muted); font-size: 0.85rem; }
.switch-row input { flex: none; width: 24px; height: 24px; accent-color: var(--period); }
.notify-body { display: grid; gap: 14px; justify-items: start; }
.notify-note { margin: 0; color: var(--ink-2); font-size: 0.92rem; }
.notify-status { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 600; }
.status-dot { width: 10px; height: 10px; border-radius: 999px; background: var(--fertile); }
.remind-row { display: flex; align-items: center; gap: 8px; color: var(--ink-2); font-size: 0.92rem; }
.remind-row select {
min-height: 40px;
padding: 6px 10px;
border: 1.5px solid var(--line);
border-radius: 10px;
background: var(--surface);
}
.seed-form { display: grid; gap: 12px; }
.seed-form label { display: grid; gap: 4px; color: var(--ink-2); font-size: 0.88rem; font-weight: 600; }
.seed-form input {

29
static/sw.js Normal file
View file

@ -0,0 +1,29 @@
// Service worker: only shows push notifications. It deliberately caches
// nothing, so the app always loads fresh from the server.
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", event => event.waitUntil(self.clients.claim()));
self.addEventListener("push", event => {
let message = { title: "Flowtrack", body: "" };
try {
message = { ...message, ...event.data.json() };
} catch {
// An empty or unreadable push still has to show something on iOS.
}
event.waitUntil(self.registration.showNotification(message.title, {
body: message.body,
icon: "/icon-192.png",
badge: "/icon-192.png",
tag: message.tag,
data: { url: message.url || "/" }
}));
});
self.addEventListener("notificationclick", event => {
event.notification.close();
event.waitUntil((async () => {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
if (windows.length) return windows[0].focus();
return self.clients.openWindow(event.notification.data.url);
})());
});

View file

@ -1,5 +1,7 @@
import base64
import json
import sqlite3
import sys
import tempfile
import threading
import unittest
@ -138,6 +140,21 @@ class ServerTests(unittest.TestCase):
}})
self.assertEqual((status, result), (200, {"added": 2}))
def test_push_is_off_without_a_helper(self):
_, state = self.request("GET", "/api/state", user="kayla")
self.assertFalse(state["push"]["available"])
self.assertEqual(self.request("POST", "/api/push/test", user="kayla")[0], 503)
_, state = self.request("GET", "/api/state", user="fred")
self.assertIsNone(state["push"])
def test_remind_days_setting(self):
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"remindDays": 3})[1], {"shareDetails": False, "remindDays": 3})
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"remindDays": 9})[0], 400)
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"remindDays": True})[0], 400)
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"other": 1})[0], 400)
_, state = self.request("GET", "/api/state", user="kayla")
self.assertEqual(state["push"]["remindDays"], 3)
def test_static_files(self):
status, body = self.request("GET", "/")
self.assertEqual(status, 200)
@ -146,5 +163,62 @@ class ServerTests(unittest.TestCase):
self.assertEqual(self.request("GET", "/manifest.webmanifest")[0], 200)
FAKE_NOTIFY = """#!{python}
import json, sqlite3, sys
command, database = sys.argv[1], sys.argv[3]
if command == "init":
with sqlite3.connect(database) as db:
db.execute("INSERT OR IGNORE INTO settings VALUES ('vapid_public', 'PUBLIC')")
print(json.dumps({{"publicKey": "PUBLIC"}}))
else:
print(json.dumps({{"sent": 1, "failed": 0, "removed": 0, "errors": [], "contact": sys.argv[5]}}))
"""
APPLE_SUBSCRIPTION = {
"endpoint": "https://web.push.apple.com/QGuQyavXutnMH",
"keys": {"p256dh": base64.urlsafe_b64encode(b"\x04" + b"k" * 64).decode().rstrip("="), "auth": base64.urlsafe_b64encode(b"a" * 16).decode().rstrip("=")},
}
class PushServerTests(ServerTests):
def setUp(self):
super().setUp()
script = Path(self.temp.name) / "notify"
script.write_text(FAKE_NOTIFY.format(python=sys.executable))
script.chmod(0o755)
self.server.notify = str(script)
self.server.push_contact = "https://flowtrack.example.org"
self.server.run_notify("init")
# Every other server test also runs again with push switched on.
test_push_is_off_without_a_helper = None
def test_push_state(self):
_, state = self.request("GET", "/api/state", user="kayla")
self.assertEqual(state["push"], {"available": True, "publicKey": "PUBLIC", "remindDays": 2, "devices": 0})
def test_subscribe_test_and_unsubscribe(self):
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=APPLE_SUBSCRIPTION), (200, {"devices": 1}))
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=APPLE_SUBSCRIPTION), (200, {"devices": 1}))
status, result = self.request("POST", "/api/push/test", user="kayla")
self.assertEqual((status, result["sent"], result["contact"]), (200, 1, "https://flowtrack.example.org"))
self.assertEqual(self.request("POST", "/api/push/test", user="fred")[0], 403)
self.assertEqual(self.request("POST", "/api/push/unsubscribe", user="kayla", body={"endpoint": APPLE_SUBSCRIPTION["endpoint"]}), (200, {"devices": 0}))
def test_only_real_push_services(self):
for endpoint in (
"https://127.0.0.1/push",
"http://web.push.apple.com/x",
"https://evilpush.apple.com.example.org/x",
"https://router.nordhammer.it/api",
):
body = {**APPLE_SUBSCRIPTION, "endpoint": endpoint}
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=body)[0], 400, endpoint)
bad_keys = {**APPLE_SUBSCRIPTION, "keys": {"p256dh": "short", "auth": APPLE_SUBSCRIPTION["keys"]["auth"]}}
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body=bad_keys)[0], 400)
self.assertEqual(self.request("POST", "/api/push/subscribe", user="fred", body=APPLE_SUBSCRIPTION)[0], 403)
self.assertEqual(self.request("POST", "/api/push/subscribe", user="kayla", body={**APPLE_SUBSCRIPTION, "endpoint": "https://fcm.googleapis.com/fcm/send/abc"})[0], 200)
if __name__ == "__main__":
unittest.main()

152
tests/test_push.js Normal file
View file

@ -0,0 +1,152 @@
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { DatabaseSync } = require("node:sqlite");
const push = require("../notify.js");
const cycle = require("../static/cycle.js");
const { b64url } = push;
// Decrypts like the receiving browser does (RFC 8291), using its private key.
function decrypt(body, receiver, auth) {
const salt = body.subarray(0, 16);
const keyLength = body.readUInt8(20);
const senderKey = body.subarray(21, 21 + keyLength);
const shared = receiver.computeSecret(senderKey);
const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0"), receiver.getPublicKey(), senderKey]);
const ikm = Buffer.from(crypto.hkdfSync("sha256", shared, auth, keyInfo, 32));
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 record = body.subarray(21 + keyLength);
const decipher = crypto.createDecipheriv("aes-128-gcm", cek, nonce);
decipher.setAuthTag(record.subarray(record.length - 16));
const plain = Buffer.concat([decipher.update(record.subarray(0, record.length - 16)), decipher.final()]);
assert.equal(plain[plain.length - 1], 2, "last-record delimiter");
return plain.subarray(0, plain.length - 1).toString();
}
function device() {
const receiver = crypto.createECDH("prime256v1");
receiver.generateKeys();
const auth = crypto.randomBytes(16);
return { receiver, auth, p256dh: b64url.encode(receiver.getPublicKey()), authText: b64url.encode(auth) };
}
function verifyVapid(authorization, publicKey, audience) {
const [, token, key] = authorization.match(/^vapid t=([^,]+), k=(.+)$/);
assert.equal(key, publicKey);
const [header, claims, signature] = token.split(".");
const raw = b64url.decode(publicKey);
const verifier = crypto.createPublicKey({
key: { kty: "EC", crv: "P-256", x: b64url.encode(raw.subarray(1, 33)), y: b64url.encode(raw.subarray(33)) },
format: "jwk"
});
assert.ok(crypto.verify("sha256", Buffer.from(`${header}.${claims}`), { key: verifier, dsaEncoding: "ieee-p1363" }, b64url.decode(signature)));
const parsed = JSON.parse(b64url.decode(claims));
assert.equal(parsed.aud, audience);
assert.ok(parsed.exp > Date.now() / 1000 && parsed.exp <= Date.now() / 1000 + 24 * 3600, "exp within 24h");
return parsed;
}
function logPeriods(starts, length = 5) {
const days = {};
for (const start of starts) {
for (let offset = 0; offset < length; offset += 1) days[cycle.toIso(cycle.toDay(start) + offset)] = { flow: "medium", symptoms: [] };
}
return days;
}
(async () => {
// Encryption round trip, including a payload longer than one AES block.
{
const phone = device();
const message = JSON.stringify({ title: "Period expected soon", body: "x".repeat(300) });
const body = push.encrypt(message, phone.p256dh, phone.authText);
assert.equal(body.readUInt32BE(16), 4096);
assert.equal(decrypt(body, phone.receiver, phone.auth), message);
}
// Reminder rules: regular 28-day cycles with the last one starting 1 Oct,
// so the next is expected 29 Oct.
{
const days = logPeriods(["2026-07-09", "2026-08-06", "2026-09-03", "2026-10-01"]);
const at = today => push.dueReminder(cycle.summarize(days, today), 2);
assert.equal(at("2026-10-26"), null);
assert.equal(at("2026-10-27").kind, "soon");
assert.match(at("2026-10-27").body, /in about 2 days, around Thu 29 Oct/);
assert.match(at("2026-10-28").body, /tomorrow/);
assert.equal(at("2026-10-29"), null, "due today is neither soon nor late");
assert.equal(at("2026-10-30").kind, "late");
assert.match(at("2026-10-30").body, /expected on Thu 29 Oct/);
assert.equal(at("2026-10-30").cycle, "2026-10-01");
assert.equal(push.dueReminder(cycle.summarize(days, "2026-10-27"), 1), null, "respects remind days");
assert.equal(push.dueReminder(cycle.summarize({}, "2026-10-27"), 2), null);
assert.equal(push.dueReminder(cycle.summarize(days, "2026-10-03"), 7), null, "nothing during a period");
}
// End to end: app.py creates the schema, `daily` sends one encrypted push
// to a fake push service, and a second run the same day sends nothing.
{
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "flowtrack-push-"));
const database = path.join(dir, "flowtrack.sqlite3");
execFileSync(process.env.PYTHON || "python3", ["-c", `import sys; sys.path.insert(0, ${JSON.stringify(path.join(__dirname, ".."))}); import app; app.Store(${JSON.stringify(database)}).initialize()`]);
const received = [];
const server = http.createServer((request, response) => {
const chunks = [];
request.on("data", chunk => chunks.push(chunk));
request.on("end", () => {
received.push({ url: request.url, headers: request.headers, body: Buffer.concat(chunks) });
response.writeHead(request.url.endsWith("/gone") ? 410 : 201).end();
});
});
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const { publicKey } = await push.main(["init", "--database", database]);
assert.equal(b64url.decode(publicKey).length, 65);
assert.equal((await push.main(["init", "--database", database])).publicKey, publicKey, "keys are stable");
const phone = device();
const db = new DatabaseSync(database);
const subscribe = db.prepare("INSERT INTO push_subscriptions (endpoint, p256dh, auth, user, created_at) VALUES (?, ?, ?, 'kayla', 'now')");
subscribe.run(`${origin}/push/phone`, phone.p256dh, phone.authText);
subscribe.run(`${origin}/push/gone`, phone.p256dh, phone.authText);
const log = db.prepare("INSERT INTO days (day, flow, symptoms, updated_at) VALUES (?, 'medium', '[]', 'now')");
for (const day of Object.keys(logPeriods(["2026-08-06", "2026-09-03", "2026-10-01"]))) log.run(day);
const args = ["daily", "--database", database, "--contact", "https://flowtrack.example.org", "--today", "2026-10-27"];
const first = await push.main(args);
assert.deepEqual({ due: first.due, sent: first.sent, removed: first.removed }, { due: "soon", sent: 1, removed: 1 });
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM push_subscriptions").get().n, 1, "410 drops the subscription");
const delivered = received.find(request => request.url === "/push/phone");
assert.equal(delivered.headers["content-encoding"], "aes128gcm");
assert.equal(delivered.headers.ttl, "86400");
const claims = verifyVapid(delivered.headers.authorization, publicKey, origin);
assert.equal(claims.sub, "https://flowtrack.example.org");
const payload = JSON.parse(decrypt(delivered.body, phone.receiver, phone.auth));
assert.equal(payload.title, "Period expected soon");
const again = await push.main(args);
assert.equal(again.alreadySent, true);
assert.equal(received.length, 2, "no second send");
const test = await push.main(["test", "--database", database, "--contact", "https://flowtrack.example.org"]);
assert.equal(test.sent, 1);
assert.equal(JSON.parse(decrypt(received[2].body, phone.receiver, phone.auth)).tag, "test");
db.close();
server.close();
fs.rmSync(dir, { recursive: true });
}
console.log("push tests passed");
})().catch(error => {
console.error(error);
process.exit(1);
});