2026-10-18 08:37:13 +01:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Small, dependency-free server for a private period tracker."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
2026-10-18 09:43:41 +01:00
|
|
|
import base64
|
|
|
|
|
import binascii
|
2026-10-18 08:37:13 +01:00
|
|
|
import csv
|
|
|
|
|
import io
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import mimetypes
|
2026-10-18 09:43:41 +01:00
|
|
|
import os
|
2026-10-18 08:37:13 +01:00
|
|
|
import re
|
|
|
|
|
import sqlite3
|
2026-10-18 09:43:41 +01:00
|
|
|
import subprocess
|
2026-10-18 08:37:13 +01:00
|
|
|
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)
|
2026-10-18 09:43:41 +01:00
|
|
|
# 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
|
2026-10-18 08:37:13 +01:00
|
|
|
|
|
|
|
|
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))}
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 09:43:41 +01:00
|
|
|
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"]}
|
|
|
|
|
|
|
|
|
|
|
2026-10-18 08:37:13 +01:00
|
|
|
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');
|
2026-10-18 09:43:41 +01:00
|
|
|
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)
|
|
|
|
|
);
|
2026-10-18 08:37:13 +01:00
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
2026-10-18 09:43:41 +01:00
|
|
|
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:
|
2026-10-18 08:37:13 +01:00
|
|
|
with closing(self.connect()) as db, db:
|
2026-10-18 09:43:41 +01:00
|
|
|
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"
|
2026-10-18 08:37:13 +01:00
|
|
|
|
2026-10-18 09:43:41 +01:00
|
|
|
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:
|
2026-10-18 08:37:13 +01:00
|
|
|
with closing(self.connect()) as db, db:
|
|
|
|
|
db.execute(
|
2026-10-18 09:43:41 +01:00
|
|
|
"""
|
|
|
|
|
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()),
|
2026-10-18 08:37:13 +01:00
|
|
|
)
|
|
|
|
|
|
2026-10-18 09:43:41 +01:00
|
|
|
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]
|
|
|
|
|
|
2026-10-18 08:37:13 +01:00
|
|
|
@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
|
|
|
|
|
|
2026-10-18 09:43:41 +01:00
|
|
|
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,
|
|
|
|
|
):
|
2026-10-18 08:37:13 +01:00
|
|
|
super().__init__(address, Handler)
|
|
|
|
|
self.store = store
|
|
|
|
|
self.editors = editors
|
|
|
|
|
self.viewers = viewers
|
|
|
|
|
self.dev_user = dev_user
|
2026-10-18 09:43:41 +01:00
|
|
|
# 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)
|
2026-10-18 08:37:13 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 09:43:41 +01:00
|
|
|
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()})
|
|
|
|
|
|
2026-10-18 08:37:13 +01:00
|
|
|
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),
|
2026-10-18 09:43:41 +01:00
|
|
|
"push": self.push_state() if role == "editor" else None,
|
2026-10-18 08:37:13 +01:00
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
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
|
2026-10-18 09:43:41 +01:00
|
|
|
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)
|
2026-10-18 08:37:13 +01:00
|
|
|
if not isinstance(share, bool):
|
|
|
|
|
self.error_json("shareDetails must be boolean", HTTPStatus.BAD_REQUEST)
|
|
|
|
|
return
|
2026-10-18 09:43:41 +01:00
|
|
|
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()})
|
2026-10-18 08:37:13 +01:00
|
|
|
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
|
2026-10-18 09:43:41 +01:00
|
|
|
if path in ("/api/push/subscribe", "/api/push/unsubscribe", "/api/push/test"):
|
|
|
|
|
self.handle_push(path)
|
|
|
|
|
return
|
2026-10-18 08:37:13 +01:00
|
|
|
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")
|
2026-10-18 09:43:41 +01:00
|
|
|
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")
|
2026-10-18 08:37:13 +01:00
|
|
|
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
|
2026-10-18 09:43:41 +01:00
|
|
|
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")
|
2026-10-18 08:37:13 +01:00
|
|
|
logging.info("Listening on http://%s:%s", args.host, args.port)
|
|
|
|
|
server.serve_forever()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|