thermograph/push.py
Emi Griffith 2f289f1cb6 Add PWA + Web Push delivery for weather alerts (#95)
Make the app installable and deliver existing alert notifications as OS
push, alongside the in-app bell.

Backend:
- PushSubscription model (per-device endpoint + keys, owned by a user) and
  register/unregister/test endpoints under /api/v2/push, cookie-auth scoped
  to the user like the subscription routes.
- push.py: VAPID key management (env -> data/vapid.json -> generated) and a
  pywebpush send helper that reports gone endpoints for pruning. No DB coupling.
- notify.py: after creating an in-app Notification, dispatch Web Push to the
  user's devices (guarded — a push failure never affects the in-app write;
  endpoints reported gone are pruned).
- Serve the .webmanifest with the correct media type.

Frontend:
- manifest.webmanifest + 192/maskable icons; <link rel="manifest"> on all pages.
- sw.js: push + notificationclick handlers (push-only; no fetch caching, so it
  doesn't fight the existing IndexedDB cache). Registered globally in nav.js in
  secure contexts.
- push-client.js + a "Notifications on this device" toggle and test-send on the
  /alerts page, subscribing through the existing cookie-aware apiFetch.

Push and service workers require a secure context, so this is active over HTTPS
(or http://localhost) and cleanly no-ops on a plain-HTTP LAN origin.
2026-07-15 23:21:06 +00:00

121 lines
4.8 KiB
Python

"""Web Push (VAPID) — key management and the low-level send.
This is the transport under the notification engine: notify.py decides *what* to
say and *to whom*; this module signs and delivers it to a browser push service.
Keys are resolved once, in this order (mirrors the self-signed-cert flow in
run.sh — generated on first use, reused thereafter):
1. env — ``THERMOGRAPH_VAPID_PRIVATE_KEY`` + ``THERMOGRAPH_VAPID_PUBLIC_KEY``,
both base64url-raw (the private key is the 32-byte scalar, the public key the
65-byte uncompressed point — the standard VAPID interchange format). Set both
in production so every worker/deploy signs with the same identity.
2. ``data/vapid.json`` — a gitignored keypair written on first run.
3. freshly generated, then written to (2).
The private key signs the VAPID JWT; the public key is the ``applicationServerKey``
the browser needs at subscribe time (served via GET /push/vapid-key).
Nothing here touches the database — ``send()`` returns a status string and the
caller (which owns the session) prunes rows the push service says are gone.
"""
import base64
import json
import logging
import os
import threading
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from pywebpush import WebPushException, webpush
log = logging.getLogger("thermograph.push")
_DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data"))
_VAPID_PATH = os.environ.get("THERMOGRAPH_VAPID_FILE") or os.path.join(_DATA_DIR, "vapid.json")
# The VAPID "sub" claim — a contact the push service can reach about our traffic.
_CONTACT = os.environ.get("THERMOGRAPH_VAPID_CONTACT", "mailto:admin@thermograph.app")
_lock = threading.Lock()
_keys = None # cached {"private_key": str, "public_key": str} (base64url-raw)
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def _generate() -> dict:
"""A fresh P-256 keypair as base64url-raw: 32-byte private scalar + 65-byte
uncompressed public point (the format pywebpush's from_string and the browser
applicationServerKey both accept)."""
priv = ec.generate_private_key(ec.SECP256R1())
scalar = priv.private_numbers().private_value.to_bytes(32, "big")
raw_pub = priv.public_key().public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
return {"private_key": _b64url(scalar), "public_key": _b64url(raw_pub)}
def _load() -> dict:
"""Resolve the keypair once (env → file → generate) and cache it."""
global _keys
if _keys is not None:
return _keys
with _lock:
if _keys is not None:
return _keys
env_priv = os.environ.get("THERMOGRAPH_VAPID_PRIVATE_KEY")
env_pub = os.environ.get("THERMOGRAPH_VAPID_PUBLIC_KEY")
if env_priv and env_pub:
_keys = {"private_key": env_priv.strip(), "public_key": env_pub.strip()}
return _keys
try:
with open(_VAPID_PATH, encoding="utf-8") as f:
data = json.load(f)
if data.get("private_key") and data.get("public_key"):
_keys = {"private_key": data["private_key"], "public_key": data["public_key"]}
return _keys
except (OSError, ValueError):
pass
_keys = _generate()
try:
os.makedirs(_DATA_DIR, exist_ok=True)
with open(_VAPID_PATH, "w", encoding="utf-8") as f:
json.dump(_keys, f)
os.chmod(_VAPID_PATH, 0o600)
except OSError:
log.warning("could not persist VAPID keys to %s; using an in-memory pair", _VAPID_PATH)
return _keys
def public_key() -> str:
"""The applicationServerKey (base64url raw point) the browser subscribes with."""
return _load()["public_key"]
def send(subscription_info: dict, payload: dict) -> str:
"""Deliver one push. Returns 'ok', 'gone' (prune the row), or 'error'.
`subscription_info` is the browser shape: {endpoint, keys:{p256dh, auth}}.
Never raises — a bad send must not break the caller's transaction.
"""
keys = _load()
try:
webpush(
subscription_info=subscription_info,
data=json.dumps(payload),
vapid_private_key=keys["private_key"],
vapid_claims={"sub": _CONTACT},
ttl=86400,
)
return "ok"
except WebPushException as e:
status = getattr(getattr(e, "response", None), "status_code", None)
if status in (404, 410):
return "gone" # endpoint retired — caller should delete it
log.warning("web push failed (status=%s): %s", status, e)
return "error"
except Exception: # noqa: BLE001 - network/encoding errors must not propagate
log.warning("web push send raised", exc_info=True)
return "error"