anproto personal data server

feat: add web push subscriptions and VAPID support

Changed files
+418 -4
+4
.gitignore
··· 1 + subscriptions.json 2 + subscriptions.json.tmp 3 + vapid.json 4 + vapid.json.tmp
+16
README.md
··· 6 6 7 7 --- 8 8 MIT 9 + 10 + ## Web Push (server) 11 + 12 + This server exposes minimal Web Push subscription + notification plumbing: 13 + 14 + - `GET /push/vapidPublicKey` returns `{ publicKey }` (base64url) for `pushManager.subscribe({ applicationServerKey })`. 15 + - `POST /push/subscribe` stores a browser PushSubscription (JSON body). 16 + - `POST /push/test` triggers a test push to all stored subscriptions. 17 + 18 + When a *new* ANProto message is added via WebSocket, the server sends a push with payload `{ type: "latest" }` so the service worker can wake up and fetch `/latest`. 19 + 20 + By default it sends `{ type: "anproto", sigs: ["...==", ...] }` (batched + throttled), so the service worker can process specific messages and/or still fetch `/latest` as a fallback. 21 + 22 + Notes: 23 + - The server writes `subscriptions.json` and `vapid.json` locally. 24 + - Set `VAPID_SUBJECT` (e.g. `mailto:you@example.com` or `https://your.site`) if you don’t want the default.
+398 -4
serve.js
··· 1 1 import db from './db.json' with { type: 'json'} 2 2 import { serveDir } from 'https://deno.land/std/http/file_server.ts' 3 3 import { apds } from './apds.js' 4 + import { encode, decode } from './lib/base64.js' 4 5 5 6 await apds.start('apdsv1') 6 7 8 + const encoder = new TextEncoder() 9 + 10 + const subscriptionsPath = new URL('./subscriptions.json', import.meta.url) 11 + const subscriptionsTmpPath = new URL('./subscriptions.json.tmp', import.meta.url) 12 + 13 + const vapidPath = new URL('./vapid.json', import.meta.url) 14 + const vapidTmpPath = new URL('./vapid.json.tmp', import.meta.url) 15 + 16 + const toBase64Url = (b64) => 17 + b64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '') 18 + 19 + const fromBase64Url = (b64url) => { 20 + const b64 = b64url.replaceAll('-', '+').replaceAll('_', '/') 21 + const pad = '='.repeat((4 - (b64.length % 4)) % 4) 22 + return b64 + pad 23 + } 24 + 25 + const base64UrlEncodeBytes = (bytes) => toBase64Url(encode(bytes)) 26 + const base64UrlDecodeBytes = (b64url) => decode(fromBase64Url(b64url)) 27 + 28 + const jsonHeaders = () => { 29 + const header = new Headers() 30 + header.append("Content-Type", "application/json") 31 + header.append("Access-Control-Allow-Origin", "*") 32 + return header 33 + } 34 + 35 + const corsPreflight = () => { 36 + const header = jsonHeaders() 37 + header.append('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') 38 + header.append('Access-Control-Allow-Headers', 'Content-Type') 39 + header.append('Access-Control-Max-Age', '86400') 40 + return new Response(null, { status: 204, headers: header }) 41 + } 42 + 43 + const readSubscriptions = async () => { 44 + try { 45 + const txt = await Deno.readTextFile(subscriptionsPath) 46 + const parsed = JSON.parse(txt) 47 + if (!parsed || typeof parsed !== 'object') return { subscriptions: {} } 48 + if (!parsed.subscriptions || typeof parsed.subscriptions !== 'object') return { subscriptions: {} } 49 + return parsed 50 + } catch { 51 + return { subscriptions: {} } 52 + } 53 + } 54 + 55 + const writeSubscriptions = async (data) => { 56 + const payload = JSON.stringify(data, null, 2) 57 + await Deno.writeTextFile(subscriptionsTmpPath, payload) 58 + await Deno.rename(subscriptionsTmpPath, subscriptionsPath) 59 + } 60 + 61 + const readVapid = async () => { 62 + try { 63 + const txt = await Deno.readTextFile(vapidPath) 64 + const parsed = JSON.parse(txt) 65 + if (!parsed || typeof parsed !== 'object') return undefined 66 + if (typeof parsed.publicKey !== 'string') return undefined 67 + if (!parsed.privateJwk || typeof parsed.privateJwk !== 'object') return undefined 68 + if (typeof parsed.subject !== 'string') return undefined 69 + return parsed 70 + } catch { 71 + return undefined 72 + } 73 + } 74 + 75 + const writeVapid = async (data) => { 76 + const payload = JSON.stringify(data, null, 2) 77 + await Deno.writeTextFile(vapidTmpPath, payload) 78 + await Deno.rename(vapidTmpPath, vapidPath) 79 + } 80 + 81 + const ensureVapid = async () => { 82 + const existing = await readVapid() 83 + if (existing) return existing 84 + 85 + const subject = Deno.env.get('VAPID_SUBJECT') || 'mailto:admin@localhost' 86 + const keypair = await crypto.subtle.generateKey( 87 + { name: 'ECDSA', namedCurve: 'P-256' }, 88 + true, 89 + ['sign', 'verify'] 90 + ) 91 + const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', keypair.publicKey)) 92 + const publicKey = base64UrlEncodeBytes(publicRaw) 93 + const privateJwk = await crypto.subtle.exportKey('jwk', keypair.privateKey) 94 + 95 + const data = { subject, publicKey, privateJwk } 96 + await writeVapid(data) 97 + return data 98 + } 99 + 7 100 if (!await apds.pubkey()) { 8 101 const keypair = await apds.generate() 9 102 await apds.put('keypair', keypair) ··· 12 105 console.log('apdsbot started, your pubkey: ' + await apds.pubkey()) 13 106 } 14 107 108 + const vapid = await ensureVapid() 109 + console.log('Web Push VAPID public key: ' + vapid.publicKey) 110 + 111 + let pendingPush = false 112 + let nextAllowedPushAt = 0 113 + let flushTimer = undefined 114 + const queuedPushSigs = new Set() 115 + 116 + const broadcastPush = async (payloadObj) => { 117 + const store = await readSubscriptions() 118 + const endpoints = Object.keys(store.subscriptions ?? {}) 119 + if (endpoints.length === 0) return 120 + 121 + const dead = new Set() 122 + const results = await Promise.allSettled( 123 + endpoints.map(async (endpoint) => { 124 + const entry = store.subscriptions[endpoint] 125 + if (!entry?.subscription) return 126 + try { 127 + const res = await sendWebPush(entry.subscription, payloadObj) 128 + if (res.status === 404 || res.status === 410) dead.add(endpoint) 129 + } catch { 130 + // Best-effort. 131 + } 132 + }) 133 + ) 134 + void results 135 + 136 + if (dead.size > 0) { 137 + for (const endpoint of dead) delete store.subscriptions[endpoint] 138 + store.updatedAt = Date.now() 139 + await writeSubscriptions(store) 140 + } 141 + } 142 + 143 + const scheduleFlush = (delayMs = 0) => { 144 + if (flushTimer !== undefined) return 145 + flushTimer = setTimeout(() => { 146 + flushTimer = undefined 147 + void flushPushQueue() 148 + }, delayMs) 149 + } 150 + 151 + const flushPushQueue = async () => { 152 + if (pendingPush) return scheduleFlush(500) 153 + 154 + const now = Date.now() 155 + if (now < nextAllowedPushAt) return scheduleFlush(nextAllowedPushAt - now) 156 + 157 + const sigs = Array.from(queuedPushSigs).slice(0, 25) 158 + if (sigs.length === 0) return 159 + for (const sig of sigs) queuedPushSigs.delete(sig) 160 + 161 + pendingPush = true 162 + nextAllowedPushAt = now + 2000 163 + try { 164 + await broadcastPush({ type: 'anproto', sigs, ts: now }) 165 + } finally { 166 + pendingPush = false 167 + if (queuedPushSigs.size > 0) scheduleFlush(0) 168 + } 169 + } 170 + 171 + const queuePushSig = (sig) => { 172 + if (typeof sig !== 'string' || sig.length === 0) return 173 + queuedPushSigs.add(sig) 174 + scheduleFlush(200) 175 + } 176 + 177 + const concatBytes = (...parts) => { 178 + const total = parts.reduce((sum, p) => sum + p.length, 0) 179 + const out = new Uint8Array(total) 180 + let offset = 0 181 + for (const p of parts) { 182 + out.set(p, offset) 183 + offset += p.length 184 + } 185 + return out 186 + } 187 + 188 + const hmacSha256 = async (keyBytes, dataBytes) => { 189 + const key = await crypto.subtle.importKey( 190 + 'raw', 191 + keyBytes, 192 + { name: 'HMAC', hash: 'SHA-256' }, 193 + false, 194 + ['sign'] 195 + ) 196 + const sig = await crypto.subtle.sign('HMAC', key, dataBytes) 197 + return new Uint8Array(sig) 198 + } 199 + 200 + const hkdfExtract = async (salt, ikm) => await hmacSha256(salt, ikm) 201 + 202 + const hkdfExpand = async (prk, info, length) => { 203 + const hashLen = 32 204 + const n = Math.ceil(length / hashLen) 205 + let t = new Uint8Array(0) 206 + let okm = new Uint8Array(0) 207 + for (let i = 1; i <= n; i++) { 208 + const input = concatBytes(t, info, new Uint8Array([i])) 209 + t = await hmacSha256(prk, input) 210 + okm = concatBytes(okm, t) 211 + } 212 + return okm.slice(0, length) 213 + } 214 + 215 + const readDerLength = (bytes, offset) => { 216 + let length = bytes[offset] 217 + if ((length & 0x80) === 0) { 218 + return { length, read: 1 } 219 + } 220 + const numBytes = length & 0x7f 221 + length = 0 222 + for (let i = 0; i < numBytes; i++) { 223 + length = (length << 8) | bytes[offset + 1 + i] 224 + } 225 + return { length, read: 1 + numBytes } 226 + } 227 + 228 + const ecdsaDerToJose = (derSignature, size = 32) => { 229 + const bytes = derSignature instanceof Uint8Array ? derSignature : new Uint8Array(derSignature) 230 + let offset = 0 231 + if (bytes[offset++] !== 0x30) throw new Error('Invalid DER signature (no sequence)') 232 + const seqLen = readDerLength(bytes, offset) 233 + offset += seqLen.read 234 + if (bytes[offset++] !== 0x02) throw new Error('Invalid DER signature (no r)') 235 + const rLen = readDerLength(bytes, offset) 236 + offset += rLen.read 237 + let r = bytes.slice(offset, offset + rLen.length) 238 + offset += rLen.length 239 + if (bytes[offset++] !== 0x02) throw new Error('Invalid DER signature (no s)') 240 + const sLen = readDerLength(bytes, offset) 241 + offset += sLen.read 242 + let s = bytes.slice(offset, offset + sLen.length) 243 + 244 + while (r.length > 0 && r[0] === 0x00 && r.length > size) r = r.slice(1) 245 + while (s.length > 0 && s[0] === 0x00 && s.length > size) s = s.slice(1) 246 + if (r.length > size || s.length > size) throw new Error('Invalid DER signature (r/s too large)') 247 + 248 + const out = new Uint8Array(size * 2) 249 + out.set(r, size - r.length) 250 + out.set(s, size * 2 - s.length) 251 + return out 252 + } 253 + 254 + const createVapidJwt = async (endpoint) => { 255 + const url = new URL(endpoint) 256 + const aud = `${url.protocol}//${url.host}` 257 + const exp = Math.floor(Date.now() / 1000) + (12 * 60 * 60) 258 + const header = base64UrlEncodeBytes(encoder.encode(JSON.stringify({ typ: 'JWT', alg: 'ES256' }))) 259 + const payload = base64UrlEncodeBytes(encoder.encode(JSON.stringify({ aud, exp, sub: vapid.subject }))) 260 + const data = `${header}.${payload}` 261 + 262 + const privateKey = await crypto.subtle.importKey( 263 + 'jwk', 264 + vapid.privateJwk, 265 + { name: 'ECDSA', namedCurve: 'P-256' }, 266 + false, 267 + ['sign'] 268 + ) 269 + const derSig = new Uint8Array( 270 + await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, encoder.encode(data)) 271 + ) 272 + const joseSig = ecdsaDerToJose(derSig) 273 + const signature = base64UrlEncodeBytes(joseSig) 274 + return `${data}.${signature}` 275 + } 276 + 277 + const encryptWebPushPayload = async (subscription, payloadBytes) => { 278 + const receiverPublicKey = base64UrlDecodeBytes(subscription.keys.p256dh) 279 + const authSecret = base64UrlDecodeBytes(subscription.keys.auth) 280 + 281 + const ephemeral = await crypto.subtle.generateKey( 282 + { name: 'ECDH', namedCurve: 'P-256' }, 283 + true, 284 + ['deriveBits'] 285 + ) 286 + const senderPublicKey = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey)) 287 + 288 + const receiverKey = await crypto.subtle.importKey( 289 + 'raw', 290 + receiverPublicKey, 291 + { name: 'ECDH', namedCurve: 'P-256' }, 292 + false, 293 + [] 294 + ) 295 + const sharedSecret = new Uint8Array( 296 + await crypto.subtle.deriveBits({ name: 'ECDH', public: receiverKey }, ephemeral.privateKey, 256) 297 + ) 298 + 299 + const prk = await hkdfExtract(authSecret, sharedSecret) 300 + const authInfo = encoder.encode('Content-Encoding: auth\u0000') 301 + const ikm = await hkdfExpand(prk, authInfo, 32) 302 + 303 + const salt = crypto.getRandomValues(new Uint8Array(16)) 304 + const prk2 = await hkdfExtract(salt, ikm) 305 + 306 + const context = concatBytes( 307 + new Uint8Array([0x00, 0x41]), 308 + receiverPublicKey, 309 + new Uint8Array([0x00, 0x41]), 310 + senderPublicKey 311 + ) 312 + 313 + const cekInfo = concatBytes(encoder.encode('Content-Encoding: aes128gcm\u0000'), context) 314 + const nonceInfo = concatBytes(encoder.encode('Content-Encoding: nonce\u0000'), context) 315 + const cek = await hkdfExpand(prk2, cekInfo, 16) 316 + const nonce = await hkdfExpand(prk2, nonceInfo, 12) 317 + 318 + const key = await crypto.subtle.importKey('raw', cek, { name: 'AES-GCM' }, false, ['encrypt']) 319 + const padding = new Uint8Array([0x00, 0x00]) 320 + const plaintext = concatBytes(padding, payloadBytes) 321 + const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, plaintext)) 322 + 323 + return { salt, senderPublicKey, ciphertext } 324 + } 325 + 326 + const sendWebPush = async (subscription, payloadObj) => { 327 + const payloadBytes = encoder.encode(JSON.stringify(payloadObj)) 328 + const { salt, senderPublicKey, ciphertext } = await encryptWebPushPayload(subscription, payloadBytes) 329 + const jwt = await createVapidJwt(subscription.endpoint) 330 + 331 + const header = new Headers() 332 + header.set('TTL', '300') 333 + header.set('Content-Type', 'application/octet-stream') 334 + header.set('Content-Encoding', 'aes128gcm') 335 + header.set('Encryption', `salt=${base64UrlEncodeBytes(salt)}`) 336 + header.set( 337 + 'Crypto-Key', 338 + `dh=${base64UrlEncodeBytes(senderPublicKey)}; p256ecdsa=${vapid.publicKey}` 339 + ) 340 + header.set('Authorization', `vapid t=${jwt}, k=${vapid.publicKey}`) 341 + 342 + const res = await fetch(subscription.endpoint, { 343 + method: 'POST', 344 + headers: header, 345 + body: ciphertext 346 + }) 347 + return res 348 + } 349 + 15 350 const apdsbot = async (ws) => { 16 351 ws.onopen = async () => { 17 352 setTimeout(async () => { ··· 42 377 } 43 378 if (m.data.length != 44) { 44 379 await apds.make(m.data) 45 - await apds.add(m.data) 380 + const added = await apds.add(m.data) 46 381 const opened = await apds.open(m.data) 47 382 if (opened) { 48 383 const content = await apds.get(opened.substring(13)) ··· 58 393 console.log('no previous') 59 394 ws.send(yaml.previous) 60 395 } 396 + } 397 + if (added) { 398 + queuePushSig(m.data) 61 399 } 62 400 } 63 401 } ··· 68 406 69 407 const directory = async (r) => { 70 408 const url = new URL(r.url) 409 + if (r.method === 'OPTIONS') { 410 + return corsPreflight() 411 + } 412 + 413 + if (url.pathname === '/push/subscribe') { 414 + const header = jsonHeaders() 415 + if (r.method !== 'POST') { 416 + return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header }) 417 + } 418 + 419 + let body 420 + try { 421 + body = await r.json() 422 + } catch { 423 + return new Response(JSON.stringify({ error: 'Invalid JSON' }), { status: 400, headers: header }) 424 + } 425 + 426 + const subscription = body?.subscription ?? body 427 + const endpoint = subscription?.endpoint 428 + const p256dh = subscription?.keys?.p256dh 429 + const auth = subscription?.keys?.auth 430 + 431 + if (!endpoint || typeof endpoint !== 'string') { 432 + return new Response(JSON.stringify({ error: 'Missing subscription.endpoint' }), { status: 400, headers: header }) 433 + } 434 + if (!p256dh || !auth) { 435 + return new Response(JSON.stringify({ error: 'Missing subscription.keys' }), { status: 400, headers: header }) 436 + } 437 + 438 + const store = await readSubscriptions() 439 + store.subscriptions ??= {} 440 + store.subscriptions[endpoint] = { 441 + subscription, 442 + updatedAt: Date.now() 443 + } 444 + store.updatedAt = Date.now() 445 + await writeSubscriptions(store) 446 + 447 + return new Response(JSON.stringify({ ok: true }), { status: 201, headers: header }) 448 + } 449 + 450 + if (url.pathname === '/push/vapidPublicKey') { 451 + const header = jsonHeaders() 452 + if (r.method !== 'GET') { 453 + return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header }) 454 + } 455 + return new Response(JSON.stringify({ publicKey: vapid.publicKey }), { status: 200, headers: header }) 456 + } 457 + 458 + if (url.pathname === '/push/test') { 459 + const header = jsonHeaders() 460 + if (r.method !== 'POST') { 461 + return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header }) 462 + } 463 + await broadcastPush({ type: 'test', ts: Date.now() }) 464 + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: header }) 465 + } 466 + 71 467 const key = url.pathname.substring(1) 72 - const header = new Headers() 73 - header.append("Content-Type", "application/json") 74 - header.append("Access-Control-Allow-Origin", "*") 468 + const header = jsonHeaders() 75 469 const q = await apds.query(key) 76 470 if (db[key]) { 77 471 const ar = db[key]