Push notifications: period soon/late reminders, test button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ce66b857db
commit
bb19bc0aeb
12 changed files with 872 additions and 21 deletions
181
app.py
181
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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue