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>
This commit is contained in:
@@ -17,7 +17,8 @@ from starlette.responses import RedirectResponse, JSONResponse
|
||||
from auth_utils import COOKIE_NAME, decode_token
|
||||
|
||||
# Paths that don't require a session cookie
|
||||
_PUBLIC = {"/login", "/logout", "/health", "/manifest.json", "/sw.js", "/favicon.ico"}
|
||||
_PUBLIC = {"/login", "/logout", "/health", "/manifest.json", "/sw.js", "/favicon.ico",
|
||||
"/api/push/vapid-key"}
|
||||
|
||||
# Path prefixes that are always public (setup flow + webhooks + Google OAuth)
|
||||
_PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/", "/auth/google")
|
||||
|
||||
@@ -89,6 +89,12 @@ class Settings(BaseSettings):
|
||||
jwt_secret: str = "change-me-in-dotenv" # override in .env: JWT_SECRET=<random>
|
||||
jwt_expire_days: int = 30
|
||||
|
||||
# Web Push (VAPID) — for browser push notifications
|
||||
# Generate once with py_vapid; see push_utils.py for key format details
|
||||
vapid_public_key: str = "" # base64url-encoded uncompressed EC point (for browser)
|
||||
vapid_private_key_b64: str = "" # base64-encoded PEM private key (single-line .env storage)
|
||||
vapid_contact: str = "mailto:admin@example.com"
|
||||
|
||||
# SMTP — for sending invite emails
|
||||
smtp_server: str = ""
|
||||
smtp_port: int = 465
|
||||
|
||||
@@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag
|
||||
from config import settings
|
||||
from auth_middleware import SessionAuthMiddleware
|
||||
from routers import chat, google_chat, nextcloud_talk, files, distill, auth, orchestrator
|
||||
from routers import ui, onboarding, settings, help, auth_google, local_llm
|
||||
from routers import ui, onboarding, settings, help, auth_google, local_llm, push
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -34,6 +34,7 @@ app.include_router(files.router)
|
||||
app.include_router(distill.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(orchestrator.router)
|
||||
app.include_router(push.router)
|
||||
|
||||
# Static files — must be mounted BEFORE ui.router so /static/* is matched first.
|
||||
# ui.router has a wildcard /{username}/{persona} that would otherwise catch /static/style.css etc.
|
||||
|
||||
115
cortex/push_utils.py
Normal file
115
cortex/push_utils.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
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}
|
||||
@@ -22,5 +22,8 @@ httpx>=0.27.0
|
||||
# OpenAI-compatible client — tool calling for OpenRouter / LiteLLM / any OAI-compat host
|
||||
openai>=1.0.0
|
||||
|
||||
# Web Push / VAPID — browser push notifications
|
||||
pywebpush>=2.0.0
|
||||
|
||||
# anthropic SDK not needed — using claude CLI subprocess for auth
|
||||
# anthropic>=0.40.0
|
||||
|
||||
60
cortex/routers/push.py
Normal file
60
cortex/routers/push.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Web Push endpoints.
|
||||
|
||||
GET /api/push/vapid-key → public VAPID key for browser PushManager.subscribe()
|
||||
POST /api/push/subscribe → save a push subscription for the logged-in user
|
||||
DELETE /api/push/subscribe → remove a subscription by endpoint
|
||||
"""
|
||||
import jwt
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth_utils import COOKIE_NAME, decode_token
|
||||
from config import settings
|
||||
import push_utils
|
||||
|
||||
router = APIRouter(prefix="/api/push")
|
||||
|
||||
|
||||
def _require_user(request: Request) -> str:
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
try:
|
||||
return decode_token(token)
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid session")
|
||||
|
||||
|
||||
@router.get("/vapid-key")
|
||||
async def get_vapid_key() -> dict:
|
||||
"""Return the VAPID public key. Public endpoint — needed before login to subscribe."""
|
||||
key = settings.vapid_public_key
|
||||
if not key:
|
||||
raise HTTPException(status_code=503, detail="Push notifications not configured")
|
||||
return {"public_key": key}
|
||||
|
||||
|
||||
class SubscribeRequest(BaseModel):
|
||||
subscription: dict # full PushSubscription JSON from browser
|
||||
|
||||
|
||||
class UnsubscribeRequest(BaseModel):
|
||||
endpoint: str
|
||||
|
||||
|
||||
@router.post("/subscribe")
|
||||
async def subscribe(req: SubscribeRequest, request: Request) -> dict:
|
||||
username = _require_user(request)
|
||||
sub = req.subscription
|
||||
if not sub.get("endpoint"):
|
||||
raise HTTPException(status_code=400, detail="subscription.endpoint is required")
|
||||
push_utils.add_subscription(username, sub)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/subscribe")
|
||||
async def unsubscribe(req: UnsubscribeRequest, request: Request) -> dict:
|
||||
username = _require_user(request)
|
||||
found = push_utils.remove_subscription(username, req.endpoint)
|
||||
return {"ok": True, "found": found}
|
||||
@@ -1806,7 +1806,93 @@
|
||||
if (stored) resumeSession(stored, true).catch(clear_stored_session);
|
||||
}
|
||||
|
||||
// ── Service worker registration ───────────────────────────────
|
||||
// ── Service worker + Web Push ────────────────────────────────
|
||||
const pushBtn = document.getElementById('push-btn');
|
||||
const pushBtnLabel = document.getElementById('push-btn-label');
|
||||
|
||||
function _urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
async function _getPushSubscription() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
return reg.pushManager.getSubscription();
|
||||
}
|
||||
|
||||
async function _syncPushBtn() {
|
||||
if (!('PushManager' in window) || !('serviceWorker' in navigator)) return;
|
||||
pushBtn.style.display = '';
|
||||
const sub = await _getPushSubscription();
|
||||
if (sub) {
|
||||
pushBtnLabel.textContent = 'Notifications on';
|
||||
pushBtn.classList.add('push-active');
|
||||
} else {
|
||||
pushBtnLabel.textContent = 'Enable notifications';
|
||||
pushBtn.classList.remove('push-active');
|
||||
}
|
||||
}
|
||||
|
||||
async function _subscribePush() {
|
||||
try {
|
||||
const keyRes = await fetch('/api/push/vapid-key');
|
||||
if (!keyRes.ok) { showToast('Push not configured on server'); return; }
|
||||
const { public_key } = await keyRes.json();
|
||||
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== 'granted') { showToast('Notification permission denied'); return; }
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: _urlBase64ToUint8Array(public_key),
|
||||
});
|
||||
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subscription: sub.toJSON() }),
|
||||
});
|
||||
|
||||
showToast('Push notifications enabled');
|
||||
await _syncPushBtn();
|
||||
} catch (e) {
|
||||
showToast('Could not enable push: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function _unsubscribePush() {
|
||||
try {
|
||||
const sub = await _getPushSubscription();
|
||||
if (!sub) { await _syncPushBtn(); return; }
|
||||
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
});
|
||||
|
||||
await sub.unsubscribe();
|
||||
showToast('Notifications disabled');
|
||||
await _syncPushBtn();
|
||||
} catch (e) {
|
||||
showToast('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (pushBtn) {
|
||||
pushBtn.addEventListener('click', async () => {
|
||||
settings_dd_el.classList.remove('open');
|
||||
const sub = await _getPushSubscription();
|
||||
if (sub) await _unsubscribePush();
|
||||
else await _subscribePush();
|
||||
});
|
||||
_syncPushBtn();
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@
|
||||
<a href="/settings" class="hdr-dd-item">
|
||||
<svg data-lucide="user" class="btn-icon"></svg> Account
|
||||
</a>
|
||||
<button id="push-btn" class="hdr-dd-item" style="display:none">
|
||||
<svg data-lucide="bell" class="btn-icon"></svg>
|
||||
<span id="push-btn-label">Enable notifications</span>
|
||||
</button>
|
||||
<div class="hdr-dd-divider"></div>
|
||||
<form method="POST" action="/logout" style="margin:0">
|
||||
<button type="submit" class="hdr-dd-item">
|
||||
|
||||
@@ -266,6 +266,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hdr-dd-item:hover { background: var(--border); }
|
||||
.hdr-dd-item.push-active { color: var(--accent); }
|
||||
|
||||
.hdr-dd-divider {
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'cortex-v1';
|
||||
const CACHE = 'cortex-v2';
|
||||
|
||||
const PRECACHE = [
|
||||
'/static/style.css',
|
||||
@@ -28,6 +28,37 @@ self.addEventListener('activate', evt => {
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('push', evt => {
|
||||
let data = { title: 'Cortex', body: '', url: '/' };
|
||||
if (evt.data) {
|
||||
try { data = { ...data, ...evt.data.json() }; } catch (_) {}
|
||||
}
|
||||
evt.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/static/icon-192.png',
|
||||
badge: '/static/icon-192.png',
|
||||
data: { url: data.url },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', evt => {
|
||||
evt.notification.close();
|
||||
const url = evt.notification.data?.url || '/';
|
||||
evt.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
|
||||
for (const c of list) {
|
||||
if (c.url.includes(self.location.origin) && 'focus' in c) {
|
||||
c.navigate(url);
|
||||
return c.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) return clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', evt => {
|
||||
const url = new URL(evt.request.url);
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ from tools.scratch import (
|
||||
scratch_append as _scratch_append,
|
||||
scratch_clear as _scratch_clear,
|
||||
)
|
||||
from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send
|
||||
from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send, web_push as _web_push
|
||||
|
||||
# ── Declaration imports ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -89,7 +89,7 @@ TOOL_CATEGORIES: dict[str, list[str]] = {
|
||||
"Cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"],
|
||||
"Reminders": ["reminders_add", "reminders_list", "reminders_remove", "reminders_clear"],
|
||||
"Scratchpad": ["scratch_read", "scratch_write", "scratch_append", "scratch_clear"],
|
||||
"Notifications": ["email_send", "nc_talk_send"],
|
||||
"Notifications": ["web_push", "email_send", "nc_talk_send"],
|
||||
"Aether Journals": [
|
||||
"ae_journal_list", "ae_journal_search",
|
||||
"ae_journal_entries_list", "ae_journal_entry_read",
|
||||
@@ -142,6 +142,7 @@ _CALLABLES: dict[str, callable] = {
|
||||
"scratch_clear": _scratch_clear,
|
||||
"email_send": _email_send,
|
||||
"nc_talk_send": _nc_talk_send,
|
||||
"web_push": _web_push,
|
||||
}
|
||||
|
||||
# ── Role-based access control ─────────────────────────────────────────────────
|
||||
|
||||
@@ -67,6 +67,16 @@ async def email_send(to: str, subject: str, body: str) -> str:
|
||||
return "Failed to send email — check SMTP configuration in .env."
|
||||
|
||||
|
||||
async def web_push(title: str, body: str, url: str = "") -> str:
|
||||
"""Send a browser push notification to the current user's registered devices."""
|
||||
import push_utils
|
||||
username = get_user()
|
||||
result = await push_utils.send_push(username, title, body, url)
|
||||
if "error" in result:
|
||||
return f"Push failed: {result['error']}"
|
||||
return f"Push sent to {result['sent']} device(s) for {username} (pruned {result['pruned']} stale)."
|
||||
|
||||
|
||||
async def nc_talk_send(message: str) -> str:
|
||||
"""Send a message to the user via their configured notification channel.
|
||||
|
||||
@@ -84,6 +94,24 @@ async def nc_talk_send(message: str) -> str:
|
||||
|
||||
|
||||
DECLARATIONS = [
|
||||
types.FunctionDeclaration(
|
||||
name="web_push",
|
||||
description=(
|
||||
"Send a browser push notification to the current user. Works even when the "
|
||||
"Cortex tab is not open. Use for completing long tasks, reminders that fire "
|
||||
"in the background, or anything the user should see immediately. "
|
||||
"url is optional — if set, clicking the notification opens that URL."
|
||||
),
|
||||
parameters=types.Schema(
|
||||
type=types.Type.OBJECT,
|
||||
properties={
|
||||
"title": types.Schema(type=types.Type.STRING, description="Notification title (short)"),
|
||||
"body": types.Schema(type=types.Type.STRING, description="Notification body text"),
|
||||
"url": types.Schema(type=types.Type.STRING, description="Optional URL to open on click"),
|
||||
},
|
||||
required=["title", "body"],
|
||||
),
|
||||
),
|
||||
types.FunctionDeclaration(
|
||||
name="email_send",
|
||||
description=(
|
||||
|
||||
Reference in New Issue
Block a user