Files
Cortex-Inara/cortex/push_utils.py
Scott Idem ddf44a2aee feat: web push notifications (VAPID)
- push_utils.py: subscription storage + send helper (auto-prunes 410 endpoints)
- routers/push.py: GET /api/push/vapid-key (public), POST/DELETE /api/push/subscribe
- sw.js: push event listener shows notification; notificationclick focuses/opens tab
- app.js: subscribe/unsubscribe flow + "Enable notifications" toggle in settings dropdown
- tools/notify.py: web_push orchestrator tool (user-level, no admin required)
- VAPID keys in .env; pywebpush added to requirements.txt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:38:58 -04:00

116 lines
3.4 KiB
Python

"""
Web Push (VAPID) helpers.
Subscriptions are stored per-user at:
home/{user}/push_subscriptions.json → list of {endpoint, keys:{p256dh, auth}}
send_push(username, title, body, url) iterates all stored subscriptions for that
user and fires a push. Stale endpoints (410 Gone) are pruned automatically.
"""
import asyncio
import base64
import json
import logging
from pathlib import Path
from config import settings
logger = logging.getLogger(__name__)
def _subs_path(username: str) -> Path:
return settings.home_root() / username / "push_subscriptions.json"
def load_subscriptions(username: str) -> list[dict]:
path = _subs_path(username)
if not path.exists():
return []
try:
return json.loads(path.read_text())
except Exception:
return []
def _save_subscriptions(username: str, subs: list[dict]) -> None:
path = _subs_path(username)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(subs, indent=2))
def add_subscription(username: str, sub: dict) -> None:
"""Upsert a subscription by endpoint URL."""
subs = load_subscriptions(username)
endpoint = sub.get("endpoint", "")
subs = [s for s in subs if s.get("endpoint") != endpoint]
subs.append(sub)
_save_subscriptions(username, subs)
def remove_subscription(username: str, endpoint: str) -> bool:
subs = load_subscriptions(username)
new_subs = [s for s in subs if s.get("endpoint") != endpoint]
if len(new_subs) == len(subs):
return False
_save_subscriptions(username, new_subs)
return True
def _get_private_key_pem() -> str:
"""Decode the base64-encoded PEM private key from settings."""
raw = settings.vapid_private_key_b64.strip()
if not raw:
raise RuntimeError("VAPID_PRIVATE_KEY_B64 is not set in .env")
return base64.b64decode(raw).decode()
def _send_one(sub: dict, payload: dict) -> bool:
"""Send a push to a single subscription. Returns False if the endpoint is stale (410)."""
from pywebpush import webpush, WebPushException
try:
webpush(
subscription_info=sub,
data=json.dumps(payload),
vapid_private_key=_get_private_key_pem(),
vapid_claims={"sub": settings.vapid_contact},
)
return True
except WebPushException as e:
if e.response is not None and e.response.status_code == 410:
logger.info("push endpoint gone (410), pruning: %s", sub.get("endpoint", "")[:60])
return False
logger.warning("push failed: %s", e)
return True # keep the sub; might be transient
async def send_push(username: str, title: str, body: str, url: str = "") -> dict:
"""
Send a push notification to all subscriptions for username.
Returns {"sent": n, "pruned": m}.
"""
if not settings.vapid_public_key or not settings.vapid_private_key_b64:
return {"error": "VAPID keys not configured"}
subs = load_subscriptions(username)
if not subs:
return {"error": f"No push subscriptions for {username}"}
payload = {"title": title, "body": body, "url": url}
keep = []
sent = 0
pruned = 0
for sub in subs:
alive = await asyncio.to_thread(_send_one, sub, payload)
if alive:
keep.append(sub)
sent += 1
else:
pruned += 1
if pruned:
_save_subscriptions(username, keep)
return {"sent": sent, "pruned": pruned}