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

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