flowtrack/app.py
rope bb19bc0aeb Push notifications: period soon/late reminders, test button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:43:41 +01:00

580 lines
24 KiB
Python

#!/usr/bin/env python3
"""Small, dependency-free server for a private period tracker."""
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
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
DAY_PATH = re.compile(r"^/api/days/(\d{4}-\d{2}-\d{2})$")
MAX_BODY = 64 * 1024
MAX_BATCH = 400
FLOWS = ("spotting", "period", "light", "medium", "heavy")
PERIOD_FLOWS = frozenset(("period", "light", "medium", "heavy"))
SYMPTOMS = (
("cramps", "Cramps"),
("headache", "Headache"),
("bloating", "Bloating"),
("tender-breasts", "Tender breasts"),
("acne", "Acne"),
("fatigue", "Fatigue"),
("back-pain", "Back pain"),
("nausea", "Nausea"),
("cravings", "Cravings"),
("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")
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def parse_day(value: object) -> date | None:
"""A calendar date that is not in the future.
One day of slack covers a phone that is ahead of the server's timezone.
"""
if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
return None
try:
parsed = date.fromisoformat(value)
except ValueError:
return None
if parsed.year < 1990 or parsed > date.today() + timedelta(days=1):
return None
return parsed
def clean_entry(value: object) -> dict | None:
"""Validate one day's log; returns None when it is malformed."""
if not isinstance(value, dict):
return None
flow = value.get("flow")
symptoms = value.get("symptoms", [])
if flow is not None and flow not in FLOWS:
return None
if not isinstance(symptoms, list) or not all(item in SYMPTOM_IDS for item in symptoms):
return 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)
def connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=10)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA busy_timeout=10000")
return connection
def initialize(self) -> None:
with closing(self.connect()) as db, db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS days (
day TEXT PRIMARY KEY,
flow TEXT,
symptoms TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
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)
);
"""
)
def days(self) -> dict[str, dict]:
with closing(self.connect()) as db, db:
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 setting(self, key: str) -> str | None:
with closing(self.connect()) as db, db:
row = db.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
return row["value"] if row else 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(
"""
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.
if entry["flow"] is None and not entry["symptoms"]:
db.execute("DELETE FROM days WHERE day = ?", (day,))
return
db.execute(
"""
INSERT INTO days (day, flow, symptoms, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(day) DO UPDATE SET
flow = excluded.flow,
symptoms = excluded.symptoms,
updated_at = excluded.updated_at
""",
(day, entry["flow"], json.dumps(entry["symptoms"]), changed_at),
)
def set_day(self, day: str, entry: dict) -> None:
with closing(self.connect()) as db, db:
self._write(db, day, entry, utc_now())
def fill_days(self, entries: dict[str, dict]) -> int:
"""Add logs for days that have none yet; existing logs always win."""
changed_at = utc_now()
added = 0
with closing(self.connect()) as db, db:
db.execute("BEGIN IMMEDIATE")
for day, entry in sorted(entries.items()):
if db.execute("SELECT 1 FROM days WHERE day = ?", (day,)).fetchone():
continue
self._write(db, day, entry, changed_at)
added += 1
return added
def backup(self, destination: Path, keep: int) -> Path:
"""Write today's consistent snapshot and prune all but the newest `keep`."""
destination.mkdir(parents=True, exist_ok=True)
target = destination / f"flowtrack-{date.today().isoformat()}.sqlite3"
partial = target.with_suffix(".partial")
with closing(self.connect()) as source, closing(sqlite3.connect(partial)) as copy:
source.backup(copy)
partial.replace(target)
snapshots = sorted(destination.glob("flowtrack-*.sqlite3"))
for old in snapshots[:-keep] if keep > 0 else []:
old.unlink()
return target
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,
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):
server: TrackerServer
def log_message(self, format: str, *args: object) -> None:
logging.info("%s - %s", self.address_string(), format % args)
def end_headers(self) -> None:
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
)
super().end_headers()
def send_body(self, body: bytes, content_type: str, status: HTTPStatus = HTTPStatus.OK, extra: dict | None = None) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
for name, value in (extra or {}).items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
def json_response(self, payload: dict, status: HTTPStatus = HTTPStatus.OK) -> None:
self.send_body(json.dumps(payload, separators=(",", ":")).encode(), "application/json; charset=utf-8", status)
def error_json(self, message: str, status: HTTPStatus) -> None:
self.json_response({"error": message}, status)
def role(self) -> tuple[str, str | None]:
# nginx overwrites Remote-User with the Authelia identity, so it can
# be trusted here; the app itself only listens on loopback.
user = self.headers.get("Remote-User", "").strip() or self.server.dev_user or ""
if user in self.server.editors:
return user, "editor"
if user in self.server.viewers:
return user, "viewer"
return user, None
def visible_days(self, role: str) -> dict[str, dict]:
days = self.server.store.days()
if role == "editor" or self.server.store.share_details():
return days
# Without sharing a viewer sees which days were period days, nothing else.
return {
day: {"flow": "period", "symptoms": []}
for day, entry in days.items()
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)
return None
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = -1
if length <= 0 or length > MAX_BODY:
self.error_json("Invalid request size", HTTPStatus.BAD_REQUEST)
return None
try:
return json.loads(self.rfile.read(length))
except (json.JSONDecodeError, UnicodeDecodeError):
self.error_json("Invalid JSON", HTTPStatus.BAD_REQUEST)
return None
def do_GET(self) -> None:
path = urlparse(self.path).path
if path == "/health":
self.json_response({"status": "ok"})
return
if not path.startswith("/api/"):
self.serve_static(path)
return
user, role = self.role()
if role is None:
self.error_json("Not allowed", HTTPStatus.FORBIDDEN)
return
if path == "/api/state":
self.json_response(
{
"user": user,
"role": role,
"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
if path in ("/api/export.json", "/api/export.csv"):
if role != "editor":
self.error_json("Only the owner can export", HTTPStatus.FORBIDDEN)
return
self.export(path.endswith(".csv"))
return
self.error_json("Not found", HTTPStatus.NOT_FOUND)
def do_PUT(self) -> None:
path = urlparse(self.path).path
_, role = self.role()
if role != "editor":
self.error_json("Read-only access", HTTPStatus.FORBIDDEN)
return
match = DAY_PATH.fullmatch(path)
if match:
if parse_day(match.group(1)) is None:
self.error_json("Invalid or future date", HTTPStatus.BAD_REQUEST)
return
payload = self.read_json()
if payload is None:
return
entry = clean_entry(payload)
if entry is None:
self.error_json("Invalid day log", HTTPStatus.BAD_REQUEST)
return
self.server.store.set_day(match.group(1), entry)
self.json_response({"day": match.group(1), **entry})
return
if path == "/api/settings":
payload = self.read_json()
if payload is None:
return
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
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)
def do_POST(self) -> None:
path = urlparse(self.path).path
_, role = self.role()
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
payload = self.read_json()
if payload is None:
return
days = payload.get("days") if isinstance(payload, dict) else None
if not isinstance(days, dict) or not days or len(days) > MAX_BATCH:
self.error_json("days must be a non-empty object", HTTPStatus.BAD_REQUEST)
return
entries = {}
for day, value in days.items():
entry = clean_entry(value)
if parse_day(day) is None or entry is None:
self.error_json(f"Invalid log for {day}", HTTPStatus.BAD_REQUEST)
return
entries[day] = entry
self.json_response({"added": self.server.store.fill_days(entries)})
def export(self, as_csv: bool) -> None:
days = self.server.store.days()
stamp = date.today().isoformat()
if as_csv:
labels = dict(SYMPTOMS)
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(["date", "flow", "symptoms"])
for day, entry in days.items():
writer.writerow([day, entry["flow"] or "", "; ".join(labels[item] for item in entry["symptoms"])])
body, content_type = buffer.getvalue().encode(), "text/csv; charset=utf-8"
else:
body = json.dumps({"exportedAt": utc_now(), "days": days}, indent=2).encode()
content_type = "application/json; charset=utf-8"
extension = "csv" if as_csv else "json"
self.send_body(body, content_type, extra={
"Content-Disposition": f'attachment; filename="flowtrack-{stamp}.{extension}"'
})
def serve_static(self, request_path: str) -> None:
relative = "index.html" if request_path == "/" else unquote(request_path.lstrip("/"))
if not relative or ".." in Path(relative).parts:
self.send_error(HTTPStatus.NOT_FOUND)
return
target = (STATIC_DIR / relative).resolve()
try:
target.relative_to(STATIC_DIR.resolve())
except ValueError:
self.send_error(HTTPStatus.NOT_FOUND)
return
if not target.is_file():
self.send_error(HTTPStatus.NOT_FOUND)
return
body = target.read_bytes()
content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
self.send_response(HTTPStatus.OK)
textual = content_type.startswith("text/") or content_type in {
"application/javascript",
"application/json",
"application/manifest+json",
"image/svg+xml",
}
self.send_header("Content-Type", f"{content_type}; charset=utf-8" if textual else content_type)
self.send_header("Content-Length", str(len(body)))
# Revalidate code on every load so a deploy reaches the home-screen app
# the next time it opens.
self.send_header("Cache-Control", "no-cache" if textual else "public, max-age=86400")
self.end_headers()
self.wfile.write(body)
def main() -> None:
parser = argparse.ArgumentParser(description="Private period tracker")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8093)
parser.add_argument("--database", default="./flowtrack.sqlite3")
parser.add_argument("--editor", action="append", default=[], help="user who owns and edits the data (repeatable)")
parser.add_argument("--viewer", action="append", default=[], help="user with read-only access (repeatable)")
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")
Path(args.database).parent.mkdir(parents=True, exist_ok=True)
store = Store(args.database)
store.initialize()
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, 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()
if __name__ == "__main__":
main()