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:
Scott Idem
2026-05-05 19:38:58 -04:00
parent 0b96772fa6
commit ddf44a2aee
14 changed files with 350 additions and 13 deletions

View File

@@ -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(() => {});
}

View File

@@ -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">

View File

@@ -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);

View File

@@ -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);