OverviewFilesCommitsIssuesDocsReleasesPeers

apds commits / 5a5480d

revert notifications code
5a5480de1ef4e54be60582c64acc3425c2db1c67
by Everett Bogue <ev@evbogue.com> · 2025-12-14T22:42:56.000Z

Changed files (1) +4 -443

serve.js +4 -443
……@@ -1,102 +1,9 @@
11import db from './db.json' with { type: 'json'}
22import { serveDir } from 'https://deno.land/std/http/file_server.ts'
33import { apds } from './apds.js'
4import { encode, decode } from './lib/base64.js'
54
65await apds.start('apdsv1')
76
8const encoder = new TextEncoder()
9
10const subscriptionsPath = new URL('./subscriptions.json', import.meta.url)
11const subscriptionsTmpPath = new URL('./subscriptions.json.tmp', import.meta.url)
12
13const vapidPath = new URL('./vapid.json', import.meta.url)
14const vapidTmpPath = new URL('./vapid.json.tmp', import.meta.url)
15
16const toBase64Url = (b64) =>
17 b64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
18
19const fromBase64Url = (b64url) => {
20 const b64 = b64url.replaceAll('-', '+').replaceAll('_', '/')
21 const pad = '='.repeat((4 - (b64.length % 4)) % 4)
22 return b64 + pad
23}
24
25const base64UrlEncodeBytes = (bytes) => toBase64Url(encode(bytes))
26const base64UrlDecodeBytes = (b64url) => decode(fromBase64Url(b64url))
27
28const 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
35const 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
43const 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
55const 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
61const 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
75const 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
81const 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
1007if (!await apds.pubkey()) {
1018 const keypair = await apds.generate()
1029 await apds.put('keypair', keypair)
……@@ -105,292 +12,6 @@ if (!await apds.pubkey()) {
10512 console.log('apdsbot started, your pubkey: ' + await apds.pubkey())
10613}
10714
108const vapid = await ensureVapid()
109console.log('Web Push VAPID public key: ' + vapid.publicKey)
110
111let pendingPush = false
112let nextAllowedPushAt = 0
113let flushTimer = undefined
114const queuedPushSigs = new Set()
115
116const redactEndpoint = (endpoint) => {
117 try {
118 const u = new URL(endpoint)
119 const p = u.pathname || ''
120 const suffix = p.length > 14 ? p.slice(-14) : p
121 return `${u.origin}${suffix ? '/…' + suffix : ''}`
122 } catch {
123 const s = String(endpoint || '')
124 return s.length > 32 ? s.slice(0, 32) + '…' : s
125 }
126}
127
128const broadcastPushWithResults = async (payloadObj) => {
129 const store = await readSubscriptions()
130 const endpoints = Object.keys(store.subscriptions ?? {})
131
132 const dead = new Set()
133 const results = await Promise.all(
134 endpoints.map(async (endpoint) => {
135 const entry = store.subscriptions?.[endpoint]
136 const subscription = entry?.subscription
137 if (!subscription) {
138 return { endpoint: redactEndpoint(endpoint), skipped: true }
139 }
140
141 try {
142 const res = await sendWebPush(subscription, payloadObj)
143 const status = res.status
144 let body
145 if (!res.ok) {
146 try {
147 body = (await res.text()).slice(0, 2000)
148 } catch {}
149 }
150 if (status === 404 || status === 410) dead.add(endpoint)
151 if (!res.ok) {
152 console.log('webpush send failed', { endpoint: redactEndpoint(endpoint), status, body })
153 }
154 return { endpoint: redactEndpoint(endpoint), status, ok: res.ok, body }
155 } catch (err) {
156 console.log('webpush send error', { endpoint: redactEndpoint(endpoint), err: String(err?.message || err) })
157 return { endpoint: redactEndpoint(endpoint), error: String(err?.message || err) }
158 }
159 })
160 )
161
162 if (dead.size > 0) {
163 for (const endpoint of dead) delete store.subscriptions[endpoint]
164 store.updatedAt = Date.now()
165 await writeSubscriptions(store)
166 }
167
168 const okCount = results.filter(r => r.ok).length
169 const failCount = results.length - okCount
170 return {
171 stored: endpoints.length,
172 ok: okCount,
173 failed: failCount,
174 pruned: dead.size,
175 results
176 }
177}
178
179const broadcastPush = async (payloadObj) => {
180 await broadcastPushWithResults(payloadObj)
181}
182
183const scheduleFlush = (delayMs = 0) => {
184 if (flushTimer !== undefined) return
185 flushTimer = setTimeout(() => {
186 flushTimer = undefined
187 void flushPushQueue()
188 }, delayMs)
189}
190
191const flushPushQueue = async () => {
192 if (pendingPush) return scheduleFlush(500)
193
194 const now = Date.now()
195 if (now < nextAllowedPushAt) return scheduleFlush(nextAllowedPushAt - now)
196
197 const sigs = Array.from(queuedPushSigs).slice(0, 25)
198 if (sigs.length === 0) return
199 for (const sig of sigs) queuedPushSigs.delete(sig)
200
201 pendingPush = true
202 nextAllowedPushAt = now + 2000
203 try {
204 await broadcastPush({ type: 'anproto', sigs, ts: now })
205 } finally {
206 pendingPush = false
207 if (queuedPushSigs.size > 0) scheduleFlush(0)
208 }
209}
210
211const queuePushSig = (sig) => {
212 if (typeof sig !== 'string' || sig.length === 0) return
213 queuedPushSigs.add(sig)
214 scheduleFlush(200)
215}
216
217const concatBytes = (...parts) => {
218 const total = parts.reduce((sum, p) => sum + p.length, 0)
219 const out = new Uint8Array(total)
220 let offset = 0
221 for (const p of parts) {
222 out.set(p, offset)
223 offset += p.length
224 }
225 return out
226}
227
228const hmacSha256 = async (keyBytes, dataBytes) => {
229 const key = await crypto.subtle.importKey(
230 'raw',
231 keyBytes,
232 { name: 'HMAC', hash: 'SHA-256' },
233 false,
234 ['sign']
235 )
236 const sig = await crypto.subtle.sign('HMAC', key, dataBytes)
237 return new Uint8Array(sig)
238}
239
240const hkdfExtract = async (salt, ikm) => await hmacSha256(salt, ikm)
241
242const hkdfExpand = async (prk, info, length) => {
243 const hashLen = 32
244 const n = Math.ceil(length / hashLen)
245 let t = new Uint8Array(0)
246 let okm = new Uint8Array(0)
247 for (let i = 1; i <= n; i++) {
248 const input = concatBytes(t, info, new Uint8Array([i]))
249 t = await hmacSha256(prk, input)
250 okm = concatBytes(okm, t)
251 }
252 return okm.slice(0, length)
253}
254
255const readDerLength = (bytes, offset) => {
256 let length = bytes[offset]
257 if ((length & 0x80) === 0) {
258 return { length, read: 1 }
259 }
260 const numBytes = length & 0x7f
261 length = 0
262 for (let i = 0; i < numBytes; i++) {
263 length = (length << 8) | bytes[offset + 1 + i]
264 }
265 return { length, read: 1 + numBytes }
266}
267
268const ecdsaSigToJose = (signature, size = 32) => {
269 const bytes = signature instanceof Uint8Array ? signature : new Uint8Array(signature)
270
271 // Some runtimes already return the JOSE (raw r||s) format for ECDSA signatures.
272 if (bytes.length === size * 2 && bytes[0] !== 0x30) return bytes
273
274 let offset = 0
275 if (bytes[offset++] !== 0x30) throw new Error('Invalid DER signature (no sequence)')
276 const seqLen = readDerLength(bytes, offset)
277 offset += seqLen.read
278 if (bytes[offset++] !== 0x02) throw new Error('Invalid DER signature (no r)')
279 const rLen = readDerLength(bytes, offset)
280 offset += rLen.read
281 let r = bytes.slice(offset, offset + rLen.length)
282 offset += rLen.length
283 if (bytes[offset++] !== 0x02) throw new Error('Invalid DER signature (no s)')
284 const sLen = readDerLength(bytes, offset)
285 offset += sLen.read
286 let s = bytes.slice(offset, offset + sLen.length)
287
288 while (r.length > 0 && r[0] === 0x00 && r.length > size) r = r.slice(1)
289 while (s.length > 0 && s[0] === 0x00 && s.length > size) s = s.slice(1)
290 if (r.length > size || s.length > size) throw new Error('Invalid DER signature (r/s too large)')
291
292 const out = new Uint8Array(size * 2)
293 out.set(r, size - r.length)
294 out.set(s, size * 2 - s.length)
295 return out
296}
297
298const createVapidJwt = async (endpoint) => {
299 const url = new URL(endpoint)
300 const aud = `${url.protocol}//${url.host}`
301 const exp = Math.floor(Date.now() / 1000) + (12 * 60 * 60)
302 const header = base64UrlEncodeBytes(encoder.encode(JSON.stringify({ typ: 'JWT', alg: 'ES256' })))
303 const payload = base64UrlEncodeBytes(encoder.encode(JSON.stringify({ aud, exp, sub: vapid.subject })))
304 const data = `${header}.${payload}`
305
306 const privateKey = await crypto.subtle.importKey(
307 'jwk',
308 vapid.privateJwk,
309 { name: 'ECDSA', namedCurve: 'P-256' },
310 false,
311 ['sign']
312 )
313 const derSig = new Uint8Array(
314 await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, encoder.encode(data))
315 )
316 const joseSig = ecdsaSigToJose(derSig)
317 const signature = base64UrlEncodeBytes(joseSig)
318 return `${data}.${signature}`
319}
320
321const encryptWebPushPayload = async (subscription, payloadBytes) => {
322 const receiverPublicKey = base64UrlDecodeBytes(subscription.keys.p256dh)
323 const authSecret = base64UrlDecodeBytes(subscription.keys.auth)
324
325 const ephemeral = await crypto.subtle.generateKey(
326 { name: 'ECDH', namedCurve: 'P-256' },
327 true,
328 ['deriveBits']
329 )
330 const senderPublicKey = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey))
331
332 const receiverKey = await crypto.subtle.importKey(
333 'raw',
334 receiverPublicKey,
335 { name: 'ECDH', namedCurve: 'P-256' },
336 false,
337 []
338 )
339 const sharedSecret = new Uint8Array(
340 await crypto.subtle.deriveBits({ name: 'ECDH', public: receiverKey }, ephemeral.privateKey, 256)
341 )
342
343 const prk = await hkdfExtract(authSecret, sharedSecret)
344 const authInfo = encoder.encode('Content-Encoding: auth\u0000')
345 const ikm = await hkdfExpand(prk, authInfo, 32)
346
347 const salt = crypto.getRandomValues(new Uint8Array(16))
348 const prk2 = await hkdfExtract(salt, ikm)
349
350 const context = concatBytes(
351 new Uint8Array([0x00, 0x41]),
352 receiverPublicKey,
353 new Uint8Array([0x00, 0x41]),
354 senderPublicKey
355 )
356
357 const cekInfo = concatBytes(encoder.encode('Content-Encoding: aes128gcm\u0000'), context)
358 const nonceInfo = concatBytes(encoder.encode('Content-Encoding: nonce\u0000'), context)
359 const cek = await hkdfExpand(prk2, cekInfo, 16)
360 const nonce = await hkdfExpand(prk2, nonceInfo, 12)
361
362 const key = await crypto.subtle.importKey('raw', cek, { name: 'AES-GCM' }, false, ['encrypt'])
363 const padding = new Uint8Array([0x00, 0x00])
364 const plaintext = concatBytes(padding, payloadBytes)
365 const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, plaintext))
366
367 return { salt, senderPublicKey, ciphertext }
368}
369
370const sendWebPush = async (subscription, payloadObj) => {
371 const payloadBytes = encoder.encode(JSON.stringify(payloadObj))
372 const { salt, senderPublicKey, ciphertext } = await encryptWebPushPayload(subscription, payloadBytes)
373 const jwt = await createVapidJwt(subscription.endpoint)
374
375 const header = new Headers()
376 header.set('TTL', '300')
377 header.set('Content-Type', 'application/octet-stream')
378 header.set('Content-Encoding', 'aes128gcm')
379 header.set('Encryption', `salt=${base64UrlEncodeBytes(salt)}`)
380 header.set(
381 'Crypto-Key',
382 `dh=${base64UrlEncodeBytes(senderPublicKey)}; p256ecdsa=${vapid.publicKey}`
383 )
384 header.set('Authorization', `vapid t=${jwt}, k=${vapid.publicKey}`)
385
386 const res = await fetch(subscription.endpoint, {
387 method: 'POST',
388 headers: header,
389 body: ciphertext
390 })
391 return res
392}
393
39415const apdsbot = async (ws) => {
39516 ws.onopen = async () => {
39617 setTimeout(async () => {
……@@ -421,7 +42,7 @@ const apdsbot = async (ws) => {
42142 }
42243 if (m.data.length != 44) {
42344 await apds.make(m.data)
424 const added = await apds.add(m.data)
45 await apds.add(m.data)
42546 const opened = await apds.open(m.data)
42647 if (opened) {
42748 const content = await apds.get(opened.substring(13))
……@@ -438,9 +59,6 @@ const apdsbot = async (ws) => {
43859 ws.send(yaml.previous)
43960 }
44061 }
441 if (added) {
442 queuePushSig(m.data)
443 }
44462 }
44563 }
44664 ws.onclose = () => {
……@@ -450,67 +68,10 @@ const apdsbot = async (ws) => {
45068
45169const directory = async (r) => {
45270 const url = new URL(r.url)
453 if (r.method === 'OPTIONS') {
454 return corsPreflight()
455 }
456
457 if (url.pathname === '/push/subscribe') {
458 const header = jsonHeaders()
459 if (r.method !== 'POST') {
460 return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header })
461 }
462
463 let body
464 try {
465 body = await r.json()
466 } catch {
467 return new Response(JSON.stringify({ error: 'Invalid JSON' }), { status: 400, headers: header })
468 }
469
470 const subscription = body?.subscription ?? body
471 const endpoint = subscription?.endpoint
472 const p256dh = subscription?.keys?.p256dh
473 const auth = subscription?.keys?.auth
474
475 if (!endpoint || typeof endpoint !== 'string') {
476 return new Response(JSON.stringify({ error: 'Missing subscription.endpoint' }), { status: 400, headers: header })
477 }
478 if (!p256dh || !auth) {
479 return new Response(JSON.stringify({ error: 'Missing subscription.keys' }), { status: 400, headers: header })
480 }
481
482 const store = await readSubscriptions()
483 store.subscriptions ??= {}
484 store.subscriptions[endpoint] = {
485 subscription,
486 updatedAt: Date.now()
487 }
488 store.updatedAt = Date.now()
489 await writeSubscriptions(store)
490
491 return new Response(JSON.stringify({ ok: true, stored: Object.keys(store.subscriptions).length }), { status: 201, headers: header })
492 }
493
494 if (url.pathname === '/push/vapidPublicKey') {
495 const header = jsonHeaders()
496 if (r.method !== 'GET') {
497 return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header })
498 }
499 return new Response(JSON.stringify({ publicKey: vapid.publicKey }), { status: 200, headers: header })
500 }
501
502 if (url.pathname === '/push/test') {
503 const header = jsonHeaders()
504 if (r.method !== 'POST') {
505 return new Response(JSON.stringify({ error: 'Method Not Allowed' }), { status: 405, headers: header })
506 }
507 const now = Date.now()
508 const report = await broadcastPushWithResults({ type: 'test', ts: now })
509 return new Response(JSON.stringify({ ok: true, ts: now, ...report }), { status: 200, headers: header })
510 }
511
51271 const key = url.pathname.substring(1)
513 const header = jsonHeaders()
72 const header = new Headers()
73 header.append("Content-Type", "application/json")
74 header.append("Access-Control-Allow-Origin", "*")
51475 const q = await apds.query(key)
51576 if (db[key]) {
51677 const ar = db[key]