a demonstration replicated social networking web app built with anproto wiredove.net/
social ed25519 protocols
at master 446 lines 15 kB view raw
1import webpush from 'npm:web-push@3.6.7' 2import { apds } from 'https://esm.sh/gh/evbogue/apds@d9326cb/apds.js' 3 4const DEFAULTS = { 5 dataDir: './data', 6 subsFile: './data/subscriptions.json', 7 stateFile: './data/state.json', 8 configFile: './config.json', 9 vapidSubject: 'mailto:ops@wiredove.net', 10 pushIconUrl: 'https://wiredove.net/dovepurple_sm.png', 11 feedRowsUpstream: 'https://pub.wiredove.net', 12} 13 14async function readJsonFile(path, fallback) { 15 try { 16 const raw = await Deno.readTextFile(path) 17 return JSON.parse(raw) 18 } catch { 19 return fallback 20 } 21} 22 23async function writeJsonFile(path, value) { 24 const raw = JSON.stringify(value, null, 2) 25 await Deno.writeTextFile(path, raw) 26} 27 28async function ensureVapidConfig(configPath, subject) { 29 const fallback = { 30 vapidPublicKey: '', 31 vapidPrivateKey: '', 32 vapidSubject: subject, 33 } 34 const config = await readJsonFile(configPath, fallback) 35 36 if (!config.vapidPublicKey || !config.vapidPrivateKey) { 37 const keys = webpush.generateVAPIDKeys() 38 const nextConfig = { 39 vapidPublicKey: keys.publicKey, 40 vapidPrivateKey: keys.privateKey, 41 vapidSubject: config.vapidSubject || subject, 42 } 43 await writeJsonFile(configPath, nextConfig) 44 return nextConfig 45 } 46 47 if (!config.vapidSubject) { 48 config.vapidSubject = subject 49 await writeJsonFile(configPath, config) 50 } 51 52 return config 53} 54 55function subscriptionId(endpoint) { 56 return btoa(endpoint).replaceAll('=', '') 57} 58 59async function parsePostText(text) { 60 if (!text || typeof text !== 'string') return {} 61 62 const raw = text.trim() 63 let yamlBlock = '' 64 let bodyText = '' 65 66 if (raw.startsWith('---')) { 67 const lines = raw.split('\n') 68 const endIndex = lines.indexOf('---', 1) 69 if (endIndex !== -1) { 70 yamlBlock = lines.slice(1, endIndex).join('\n') 71 bodyText = lines.slice(endIndex + 1).join('\n') 72 } 73 } 74 75 let name 76 let yamlBody 77 try { 78 const parsed = await apds.parseYaml(raw) 79 if (parsed && typeof parsed === 'object') { 80 name = typeof parsed.name === 'string' ? parsed.name.trim() : undefined 81 yamlBody = typeof parsed.body === 'string' ? parsed.body.trim() : undefined 82 } 83 } catch { 84 if (yamlBlock) { 85 try { 86 const parsed = await apds.parseYaml(yamlBlock) 87 if (parsed && typeof parsed === 'object') { 88 name = typeof parsed.name === 'string' ? parsed.name.trim() : undefined 89 yamlBody = typeof parsed.body === 'string' ? parsed.body.trim() : undefined 90 } 91 } catch { 92 // Fall back to raw body if YAML parsing fails. 93 } 94 } 95 } 96 97 const body = bodyText.trim() || (yamlBody || '').trim() 98 99 return { 100 name: name || undefined, 101 body: body || undefined, 102 } 103} 104 105function formatPushTitle(name, author) { 106 const authorLabel = name || (author ? author.substring(0, 10) : 'Someone') 107 return authorLabel 108} 109 110function formatPushBody(body) { 111 if (body && body.trim()) return body.trim() 112 return 'Tap to view the latest update' 113} 114 115function parseOpenedTimestamp(opened) { 116 if (!opened || typeof opened !== 'string' || opened.length < 13) return 0 117 const ts = Number.parseInt(opened.substring(0, 13), 10) 118 return Number.isFinite(ts) ? ts : 0 119} 120 121function summarizeText(text, maxLen = 140) { 122 if (!text || typeof text !== 'string') return '' 123 const single = text.replace(/\s+/g, ' ').trim() 124 if (single.length <= maxLen) return single 125 return single.substring(0, maxLen) + '...' 126} 127 128async function extractFeedRows(messages, limit = 40) { 129 if (!Array.isArray(messages) || !messages.length) return [] 130 const contentByHash = new Map() 131 const rowsByHash = new Map() 132 const replyCountByParent = new Map() 133 134 for (const msg of messages) { 135 if (typeof msg !== 'string' || !msg.length) continue 136 const opened = await apds.open(msg) 137 if (opened) { 138 const hash = await apds.hash(msg) 139 if (!hash) continue 140 const ts = parseOpenedTimestamp(opened) 141 const contentHash = opened.substring(13) 142 rowsByHash.set(hash, { 143 hash, 144 ts, 145 opened, 146 author: msg.substring(0, 44), 147 contentHash, 148 }) 149 continue 150 } 151 const contentHash = await apds.hash(msg) 152 if (!contentHash) continue 153 try { 154 const yaml = await apds.parseYaml(msg) 155 if (!yaml || typeof yaml !== 'object') continue 156 const replyParent = typeof yaml.replyHash === 'string' && yaml.replyHash.length === 44 157 ? yaml.replyHash 158 : (typeof yaml.reply === 'string' && yaml.reply.length === 44 ? yaml.reply : '') 159 if (replyParent) { 160 replyCountByParent.set(replyParent, (replyCountByParent.get(replyParent) || 0) + 1) 161 } 162 contentByHash.set(contentHash, { 163 name: typeof yaml.name === 'string' ? yaml.name.trim() : '', 164 preview: summarizeText( 165 (typeof yaml.body === 'string' && yaml.body) || 166 (typeof yaml.bio === 'string' && yaml.bio) || 167 '' 168 ), 169 replyParent, 170 }) 171 } catch { 172 // Ignore invalid YAML content blobs. 173 } 174 } 175 176 const rows = Array.from(rowsByHash.values()).map((row) => { 177 const content = contentByHash.get(row.contentHash) || {} 178 return { 179 hash: row.hash, 180 ts: row.ts, 181 opened: row.opened, 182 author: row.author, 183 contentHash: row.contentHash, 184 name: content.name || '', 185 preview: content.preview || '', 186 replyCount: replyCountByParent.get(row.hash) || 0, 187 } 188 }) 189 190 rows.sort((a, b) => b.ts - a.ts) 191 return rows.slice(0, Math.max(1, Math.min(200, limit))) 192} 193 194async function fetchPollRows(upstreamBase, since, limit) { 195 const upstream = new URL('/gossip/poll', upstreamBase) 196 upstream.searchParams.set('since', String(since)) 197 const res = await fetch(upstream.toString(), { cache: 'no-store' }) 198 if (!res.ok) { 199 throw new Error('upstream poll unavailable') 200 } 201 const data = await res.json() 202 const messages = Array.isArray(data?.messages) ? data.messages : [] 203 const rows = await extractFeedRows(messages, limit) 204 const nextSince = Number.isFinite(data?.nextSince) ? data.nextSince : since 205 return { rows, nextSince } 206} 207 208async function toPushPayload(latest, pushIconUrl) { 209 const record = latest && typeof latest === 'object' ? latest : null 210 const hash = record && typeof record.hash === 'string' ? record.hash : '' 211 const explicitUrl = record && typeof record.url === 'string' ? record.url : '' 212 const targetUrl = explicitUrl || (hash ? `https://wiredove.net/#${hash}` : 'https://wiredove.net/') 213 const rawText = record && typeof record.text === 'string' ? record.text : '' 214 const parsed = rawText ? await parsePostText(rawText) : {} 215 const bodyText = parsed.body || '' 216 if (!bodyText.trim()) return null 217 const title = formatPushTitle(parsed.name, record?.author) 218 const body = formatPushBody(bodyText) 219 return JSON.stringify({ 220 title, 221 body, 222 url: targetUrl, 223 hash, 224 icon: pushIconUrl, 225 latest, 226 }) 227} 228 229export async function createNotificationsService(options = {}) { 230 const settings = { 231 dataDir: DEFAULTS.dataDir, 232 subsFile: DEFAULTS.subsFile, 233 stateFile: DEFAULTS.stateFile, 234 configFile: Deno.env.get('VAPID_CONFIG_PATH') ?? DEFAULTS.configFile, 235 vapidSubject: Deno.env.get('VAPID_SUBJECT') ?? DEFAULTS.vapidSubject, 236 pushIconUrl: Deno.env.get('PUSH_ICON_URL') ?? DEFAULTS.pushIconUrl, 237 feedRowsUpstream: Deno.env.get('FEED_ROWS_UPSTREAM') ?? DEFAULTS.feedRowsUpstream, 238 ...options, 239 } 240 241 await Deno.mkdir(settings.dataDir, { recursive: true }) 242 243 const config = await ensureVapidConfig(settings.configFile, settings.vapidSubject) 244 webpush.setVapidDetails( 245 config.vapidSubject, 246 config.vapidPublicKey, 247 config.vapidPrivateKey, 248 ) 249 250 async function loadSubscriptions() { 251 return await readJsonFile(settings.subsFile, []) 252 } 253 254 async function saveSubscriptions(subs) { 255 await writeJsonFile(settings.subsFile, subs) 256 } 257 258 async function loadState() { 259 return await readJsonFile(settings.stateFile, {}) 260 } 261 262 async function saveState(state) { 263 await writeJsonFile(settings.stateFile, state) 264 } 265 266 async function sendPayloadToSubscriptions(payload) { 267 const subs = await loadSubscriptions() 268 if (subs.length === 0) { 269 return { sent: false, reason: 'no subscriptions' } 270 } 271 272 const now = new Date().toISOString() 273 const nextSubs = [] 274 275 for (const sub of subs) { 276 try { 277 await webpush.sendNotification( 278 { 279 endpoint: sub.endpoint, 280 keys: sub.keys, 281 }, 282 payload, 283 ) 284 nextSubs.push({ ...sub, lastNotifiedAt: now }) 285 } catch (err) { 286 const status = err && typeof err === 'object' ? err.statusCode : undefined 287 if (status === 404 || status === 410) { 288 console.warn(`Removing expired subscription: ${sub.id}`) 289 continue 290 } 291 console.error(`Push failed for ${sub.id}`, err) 292 nextSubs.push(sub) 293 } 294 } 295 296 await saveSubscriptions(nextSubs) 297 return { sent: true } 298 } 299 300 async function handleRequest(req) { 301 const url = new URL(req.url) 302 303 if (req.method === 'GET' && url.pathname === '/vapid-public-key') { 304 return Response.json({ key: config.vapidPublicKey }) 305 } 306 307 if (req.method === 'POST' && url.pathname === '/subscribe') { 308 const body = await req.json().catch(() => null) 309 if (!body || typeof body !== 'object') { 310 return Response.json({ error: 'invalid subscription' }, { status: 400 }) 311 } 312 313 const sub = body 314 if (!sub.endpoint || !sub.keys?.p256dh || !sub.keys?.auth) { 315 return Response.json({ error: 'missing fields' }, { status: 400 }) 316 } 317 318 const subs = await loadSubscriptions() 319 const id = subscriptionId(sub.endpoint) 320 const existing = subs.find((item) => item.id === id) 321 if (!existing) { 322 subs.push({ 323 id, 324 endpoint: sub.endpoint, 325 keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth }, 326 createdAt: new Date().toISOString(), 327 }) 328 await saveSubscriptions(subs) 329 } 330 331 return new Response('ok', { status: 200 }) 332 } 333 334 if (req.method === 'POST' && url.pathname === '/unsubscribe') { 335 const body = await req.json().catch(() => null) 336 const endpoint = body?.endpoint 337 if (!endpoint) { 338 return Response.json({ error: 'missing endpoint' }, { status: 400 }) 339 } 340 341 const subs = await loadSubscriptions() 342 const id = subscriptionId(endpoint) 343 const nextSubs = subs.filter((item) => item.id !== id) 344 if (nextSubs.length !== subs.length) await saveSubscriptions(nextSubs) 345 346 return new Response('ok', { status: 200 }) 347 } 348 349 if (req.method === 'POST' && url.pathname === '/push-now') { 350 const body = await req.json().catch(() => null) 351 if (!body || typeof body !== 'object') { 352 return Response.json({ error: 'invalid payload' }, { status: 400 }) 353 } 354 const record = { 355 hash: typeof body.hash === 'string' ? body.hash : undefined, 356 author: typeof body.author === 'string' ? body.author : undefined, 357 text: typeof body.text === 'string' ? body.text : undefined, 358 url: typeof body.url === 'string' ? body.url : undefined, 359 } 360 361 const payload = await toPushPayload(record, settings.pushIconUrl) 362 if (!payload) { 363 return Response.json({ sent: false, reason: 'no content' }) 364 } 365 366 if (record.hash) { 367 await saveState({ lastSeenId: record.hash }) 368 } 369 370 const sendResult = await sendPayloadToSubscriptions(payload) 371 return Response.json({ 372 sent: sendResult.sent, 373 reason: sendResult.reason, 374 }) 375 } 376 377 if (req.method === 'GET' && url.pathname === '/feed-rows/home') { 378 const sinceRaw = url.searchParams.get('since') || '0' 379 const limitRaw = url.searchParams.get('limit') || '40' 380 const since = Number.parseInt(sinceRaw, 10) 381 const limit = Number.parseInt(limitRaw, 10) 382 const safeSince = Number.isFinite(since) && since > 0 ? since : 0 383 const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 40 384 try { 385 const { rows, nextSince } = await fetchPollRows(settings.feedRowsUpstream, safeSince, safeLimit) 386 return Response.json({ rows, nextSince }) 387 } catch (err) { 388 console.error('feed rows fetch failed', err) 389 return Response.json({ error: 'feed-rows-failed' }, { status: 500 }) 390 } 391 } 392 393 if (req.method === 'GET' && url.pathname.startsWith('/feed-rows/author/')) { 394 const pubkey = decodeURIComponent(url.pathname.substring('/feed-rows/author/'.length)) 395 const sinceRaw = url.searchParams.get('since') || '0' 396 const limitRaw = url.searchParams.get('limit') || '40' 397 const since = Number.parseInt(sinceRaw, 10) 398 const limit = Number.parseInt(limitRaw, 10) 399 const safeSince = Number.isFinite(since) && since > 0 ? since : 0 400 const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 40 401 try { 402 const { rows, nextSince } = await fetchPollRows(settings.feedRowsUpstream, safeSince, safeLimit * 4) 403 const filtered = rows.filter((row) => row.author === pubkey).slice(0, safeLimit) 404 return Response.json({ rows: filtered, nextSince }) 405 } catch (err) { 406 console.error('author feed rows fetch failed', err) 407 return Response.json({ error: 'feed-rows-author-failed' }, { status: 500 }) 408 } 409 } 410 411 if (req.method === 'GET' && url.pathname.startsWith('/feed-rows/alias/')) { 412 const alias = decodeURIComponent(url.pathname.substring('/feed-rows/alias/'.length)) 413 const sinceRaw = url.searchParams.get('since') || '0' 414 const limitRaw = url.searchParams.get('limit') || '40' 415 const since = Number.parseInt(sinceRaw, 10) 416 const limit = Number.parseInt(limitRaw, 10) 417 const safeSince = Number.isFinite(since) && since > 0 ? since : 0 418 const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 40 419 try { 420 const aliasUrl = new URL('/' + alias, settings.feedRowsUpstream) 421 const aliasRes = await fetch(aliasUrl.toString(), { cache: 'no-store' }) 422 if (!aliasRes.ok) { 423 return Response.json({ rows: [], nextSince: safeSince }) 424 } 425 const aliasData = await aliasRes.json().catch(() => []) 426 const authors = new Set(Array.isArray(aliasData) ? aliasData.filter((item) => typeof item === 'string') : []) 427 if (!authors.size) { 428 return Response.json({ rows: [], nextSince: safeSince }) 429 } 430 const { rows, nextSince } = await fetchPollRows(settings.feedRowsUpstream, safeSince, safeLimit * 4) 431 const filtered = rows.filter((row) => authors.has(row.author)).slice(0, safeLimit) 432 return Response.json({ rows: filtered, nextSince }) 433 } catch (err) { 434 console.error('alias feed rows fetch failed', err) 435 return Response.json({ error: 'feed-rows-alias-failed' }, { status: 500 }) 436 } 437 } 438 439 return null 440 } 441 442 return { 443 config, 444 handleRequest, 445 } 446}