split up the server a lil

Changed files
+389 -385
server
+232
server/api.js
···
··· 1 + import fs from 'node:fs'; 2 + import http from 'http'; 3 + import { jwtVerify } from 'jose'; 4 + import cookie from 'cookie'; 5 + import cookieSig from 'cookie-signature'; 6 + import { v4 as uuidv4 } from 'uuid'; 7 + 8 + const getRequesBody = async req => new Promise((resolve, reject) => { 9 + let body = ''; 10 + req.on('data', chunk => body += chunk); 11 + req.on('end', () => resolve(body)); 12 + req.on('error', err => reject(err)); 13 + }); 14 + 15 + const COOKIE_BASE = { httpOnly: true, secure: true, partitioned: true, sameSite: 'None' }; 16 + const setAccountCookie = (res, did, session, appSecret) => res.setHeader('Set-Cookie', cookie.serialize( 17 + 'verified-account', 18 + cookieSig.sign(JSON.stringify([did, session]), appSecret), 19 + { ...COOKIE_BASE, maxAge: 90 * 86_400 }, 20 + )); 21 + const clearAccountCookie = res => res.setHeader('Set-Cookie', cookie.serialize( 22 + 'verified-account', 23 + '', 24 + { ...COOKIE_BASE, expires: new Date(0) }, 25 + )); 26 + 27 + const getAccountCookie = (req, res, appSecret, adminDid, noDidCheck = false) => { 28 + const cookies = cookie.parse(req.headers.cookie ?? ''); 29 + const untrusted = cookies['verified-account'] ?? ''; 30 + const json = cookieSig.unsign(untrusted, appSecret); 31 + if (!json) { 32 + clearAccountCookie(res); 33 + return null; 34 + } 35 + let did, session; 36 + try { 37 + [did, session] = JSON.parse(json); 38 + } catch (e) { 39 + console.warn('validated account cookie but failed to parse json', e); 40 + clearAccountCookie(res); 41 + return null; 42 + } 43 + 44 + // not yet public!! 45 + if (!did || (did !== adminDid && !noDidCheck)) { 46 + console.log('no, clearing you', did, did === adminDid, noDidCheck); 47 + clearAccountCookie(res) 48 + .setHeader('Content-Type', 'application/json') 49 + .writeHead(403) 50 + .end(JSON.stringify({ 51 + reason: 'the spacedust notifications demo isn\'t public yet!', 52 + })); 53 + throw new Error('unauthorized'); 54 + } 55 + 56 + return [did, session, did && (did === adminDid)]; 57 + }; 58 + 59 + // never EVER allow user-controllable input into fname (or just fix the path joining) 60 + const handleFile = (fname, ftype) => async (req, res, replace = {}) => { 61 + let content 62 + try { 63 + content = await fs.promises.readFile(`./web-content/${fname}`); // DANGERDANGER 64 + content = content.toString(); 65 + } catch (err) { 66 + console.error(err); 67 + res.writeHead(500); 68 + res.end('Internal server error'); 69 + return; 70 + } 71 + res.setHeader('Content-Type', ftype); 72 + res.writeHead(200); 73 + for (let k in replace) { 74 + content = content.replace(k, JSON.stringify(replace[k])); 75 + } 76 + res.end(content); 77 + } 78 + const handleIndex = handleFile('index.html', 'text/html'); 79 + const handleServiceWorker = handleFile('service-worker.js', 'application/javascript'); 80 + 81 + const handleHello = async (db, req, res, secrets, whoamiHost, adminDid) => { 82 + const resBase = { webPushPublicKey: secrets.pushKeys.publicKey, whoamiHost }; 83 + res.setHeader('Content-Type', 'application/json'); 84 + let info = getAccountCookie(req, res, secrets.appSecret, adminDid, true); 85 + if (info) { 86 + const [did, _session, isAdmin] = info; 87 + let role = db.getAccount(did)?.role; 88 + role = isAdmin ? 'admin' : (role ?? 'public'); 89 + res 90 + .setHeader('Content-Type', 'application/json') 91 + .writeHead(200) 92 + .end(JSON.stringify({ ...resBase, role, did })); 93 + } else { 94 + res 95 + .setHeader('Content-Type', 'application/json') 96 + .writeHead(200) 97 + .end(JSON.stringify({ ...resBase, role: 'anonymous' })); 98 + } 99 + }; 100 + 101 + const handleVerify = async (db, req, res, secrets, whoamiHost, adminDid, jwks) => { 102 + const body = await getRequesBody(req); 103 + const { token } = JSON.parse(body); 104 + let did; 105 + try { 106 + const verified = await jwtVerify(token, jwks); 107 + did = verified.payload.sub; 108 + } catch (e) { 109 + console.warn('jwks verification failed', e); 110 + return clearAccountCookie(res).writeHead(400).end(JSON.stringify({ reason: 'verification failed' })); 111 + } 112 + const isAdmin = did && did === adminDid; 113 + db.addAccount(did); 114 + const session = uuidv4(); 115 + setAccountCookie(res, did, session, secrets.appSecret); 116 + return res 117 + .setHeader('Content-Type', 'application/json') 118 + .writeHead(200) 119 + .end(JSON.stringify({ 120 + did, 121 + role: isAdmin ? 'admin' : 'public', 122 + webPushPublicKey: secrets.pushKeys.publicKey, 123 + })); 124 + }; 125 + 126 + const handleSubscribe = async (db, req, res, appSecret, updateSubs, adminDid) => { 127 + let info = getAccountCookie(req, res, appSecret, adminDid, true); 128 + if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 129 + const [did, session, _isAdmin] = info; 130 + const body = await getRequesBody(req); 131 + const { sub } = JSON.parse(body); 132 + // addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG) 133 + try { 134 + db.addPushSub(did, session, JSON.stringify(sub)); 135 + } catch (e) { 136 + console.warn('failed to add sub', e); 137 + return res 138 + .setHeader('Content-Type', 'application/json') 139 + .writeHead(500) 140 + .end(JSON.stringify({ reason: 'failed to register subscription' })); 141 + } 142 + updateSubs(db); 143 + res.setHeader('Content-Type', 'application/json'); 144 + res.writeHead(201); 145 + res.end(JSON.stringify({ sup: 'hi' })); 146 + }; 147 + 148 + const handleLogout = async (db, req, res, appSecret, updateSubs) => { 149 + let info = getAccountCookie(req, res, appSecret, null, true); 150 + if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 151 + const [_did, session, _isAdmin] = info; 152 + try { 153 + db.deleteSub(session); 154 + } catch (e) { 155 + console.warn('failed to remove sub', e); 156 + return res 157 + .setHeader('Content-Type', 'application/json') 158 + .writeHead(500) 159 + .end(JSON.stringify({ reason: 'failed to register subscription' })); 160 + } 161 + updateSubs(db); 162 + clearAccountCookie(res); 163 + res.setHeader('Content-Type', 'application/json'); 164 + res.writeHead(201); 165 + res.end(JSON.stringify({ sup: 'bye' })); 166 + } 167 + 168 + const handleTopSecret = async (db, req, res, appSecret) => { 169 + let info = getAccountCookie(req, res, appSecret, null, true); 170 + if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 171 + const [did, _session, _isAdmin] = info; 172 + const body = await getRequesBody(req); 173 + const { secret_password } = JSON.parse(body); 174 + console.log({ secret_password }); 175 + const role = 'early'; 176 + db.setRole(did, role, secret_password); 177 + res.setHeader('Content-Type', 'application/json') 178 + .writeHead(200) 179 + .end('"heyyy"'); 180 + } 181 + 182 + const attempt = listener => async (req, res) => { 183 + console.log(`-> ${req.method} ${req.url}`); 184 + try { 185 + return await listener(req, res); 186 + } catch (e) { 187 + console.error('listener errored:', e); 188 + } 189 + }; 190 + 191 + const withCors = (allowedOrigin, listener) => { 192 + const corsHeaders = new Headers({ 193 + 'Access-Control-Allow-Origin': allowedOrigin, 194 + 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', 195 + 'Access-Control-Allow-Headers': 'Content-Type', 196 + 'Access-Control-Allow-Credentials': 'true', 197 + }); 198 + return (req, res) => { 199 + res.setHeaders(corsHeaders); 200 + if (req.method === 'OPTIONS') { 201 + return res.writeHead(204).end(); 202 + } 203 + return listener(req, res); 204 + } 205 + } 206 + 207 + export const server = (secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, adminDid) => { 208 + const handler = (req, res) => { 209 + if (req.method === 'GET' && req.url === '/') { 210 + return handleIndex(req, res, { PUBKEY: secrets.pushKeys.publicKey }); 211 + } 212 + if (req.method === 'GET' && req.url === '/hello') { 213 + return handleHello(db, req, res, secrets, whoamiHost, adminDid); 214 + } 215 + if (req.method === 'POST' && req.url === '/verify') { 216 + return handleVerify(db, req, res, secrets, whoamiHost, adminDid, jwks); 217 + } 218 + if (req.method === 'POST' && req.url === '/subscribe') { 219 + return handleSubscribe(db, req, res, secrets.appSecret, updateSubs, adminDid); 220 + } 221 + if (req.method === 'POST' && req.url === '/logout') { 222 + return handleLogout(db, req, res, secrets.appSecret, updateSubs); 223 + } 224 + if (req.method === 'POST' && req.url === '/super-top-secret-access') { 225 + return handleTopSecret(db, req, res, secrets.appSecret); 226 + } 227 + 228 + res.writeHead(404).end('not found (sorry)'); 229 + }; 230 + 231 + return http.createServer(attempt(withCors(allowedOrigin, handler))); 232 + }
+10 -385
server/index.js
··· 1 #!/usr/bin/env node 2 - "use strict"; 3 4 import fs from 'node:fs'; 5 import { randomBytes } from 'node:crypto'; 6 - import http from 'http'; 7 - import * as jose from 'jose'; 8 - import cookie from 'cookie'; 9 - import cookieSig from 'cookie-signature'; 10 - import lexicons from 'lexicons'; 11 - import psl from 'psl'; 12 import webpush from 'web-push'; 13 - import WebSocket from 'ws'; 14 - import { v4 as uuidv4 } from 'uuid'; 15 import { DB } from './db.js'; 16 - 17 - // kind of silly but right now there's no way to tell spacedust that we want an alive connection 18 - // but don't want the notification firehose (everything filtered out) 19 - // so... the final filter is an absolute on this fake did, effectively filtering all notifs. 20 - // (this is only used when there are no subscribers registered) 21 - const DUMMY_DID = 'did:plc:zzzzzzzzzzzzzzzzzzzzzzzz'; 22 - 23 - let spacedust; 24 - let spacedustEverStarted = false; 25 - 26 - 27 - const updateSubs = db => { 28 - if (!spacedust) { 29 - console.warn('not updating subscription, no spacedust (reconnecting?)'); 30 - return; 31 - } 32 - const wantedSubjectDids = db.getSubscribedDids(); 33 - if (wantedSubjectDids.length === 0) { 34 - wantedSubjectDids.push(DUMMY_DID); 35 - } 36 - console.log('updating for wantedSubjectDids', wantedSubjectDids); 37 - spacedust.send(JSON.stringify({ 38 - type: 'options_update', 39 - payload: { 40 - wantedSubjectDids, 41 - }, 42 - })); 43 - }; 44 - 45 - async function push(db, pushSubscription, payload) { 46 - const { session, subscription, since_last_push } = pushSubscription; 47 - if (since_last_push !== null && since_last_push < 1.618) { 48 - console.warn(`rate limiter: dropping too-soon push (${since_last_push})`); 49 - return; 50 - } 51 - 52 - let sub; 53 - try { 54 - sub = JSON.parse(subscription); 55 - } catch (e) { 56 - console.error('failed to parse subscription json, dropping session', e); 57 - db.deleteSub(session); 58 - return; 59 - } 60 - 61 - try { 62 - await webpush.sendNotification(sub, payload); 63 - } catch (err) { 64 - if (400 <= err.statusCode && err.statusCode < 500) { 65 - console.info(`removing sub for ${err.statusCode}`); 66 - db.deleteSub(session); 67 - return; 68 - } else { 69 - console.warn('something went wrong for another reason', err); 70 - } 71 - } 72 - 73 - db.updateLastPush(session); 74 - } 75 - 76 - const isTorment = source => { 77 - try { 78 - const [nsid, ...rp] = source.split(':'); 79 - 80 - let parts = nsid.split('.'); 81 - parts.reverse(); 82 - parts = parts.join('.'); 83 - 84 - // const unreversed = parts.toReversed().join('.'); 85 - 86 - const app = psl.parse(parts)?.domain ?? 'unknown'; 87 - 88 - let appPrefix = app.split('.'); 89 - appPrefix.reverse(); 90 - appPrefix = appPrefix.join('.') 91 - 92 - return source.slice(app.length + 1) in lexicons[appPrefix]?.torment_sources; 93 - } catch (e) { 94 - console.error('checking tormentedness failed, allowing through', e); 95 - return false; 96 - } 97 - } 98 - 99 - const handleDust = db => async event => { 100 - console.log('got', event.data); 101 - let data; 102 - try { 103 - data = JSON.parse(event.data); 104 - } catch (err) { 105 - console.error(err); 106 - return; 107 - } 108 - const { link: { subject, source, source_record } } = data; 109 - if (isTorment(source)) { 110 - console.log('nope! not today,', source); 111 - return; 112 - } 113 - const timestamp = +new Date(); 114 - 115 - let did; 116 - if (subject.startsWith('did:')) did = subject; 117 - else if (subject.startsWith('at://')) { 118 - const [id, ..._] = subject.slice('at://'.length).split('/'); 119 - if (id.startsWith('did:')) did = id; 120 - } 121 - if (!did) { 122 - console.warn(`ignoring link with non-DID subject: ${subject}`) 123 - return; 124 - } 125 - 126 - const subs = db.getSubsByDid(did); 127 - const payload = JSON.stringify({ subject, source, source_record, timestamp }); 128 - let res = await Promise.all(subs.map(pushSubscription => push(db, pushSubscription, payload))); 129 - console.log('send results', res); 130 - }; 131 - 132 - const connectSpacedust = (db, host) => { 133 - spacedust = new WebSocket(`${host}/subscribe?instant=true&wantedSubjectDids=${DUMMY_DID}`); 134 - let restarting = false; 135 - 136 - const restart = () => { 137 - if (restarting) return; 138 - restarting = true; 139 - let wait = Math.round(500 + (Math.random() * 1000)); 140 - console.info(`restarting spacedust connection in ${wait}ms...`); 141 - setTimeout(() => connectSpacedust(db, host), wait); 142 - spacedust = null; 143 - } 144 - 145 - spacedust.onopen = () => updateSubs(db); 146 - spacedust.onmessage = handleDust(db); 147 - 148 - spacedust.onerror = e => { 149 - console.error('spacedust errored:', e); 150 - restart(); 151 - }; 152 - 153 - spacedust.onclose = () => { 154 - console.log('spacedust closed'); 155 - restart(); 156 - }; 157 - } 158 159 const getOrCreateSecrets = filename => { 160 let secrets; ··· 174 return secrets; 175 } 176 177 - const getRequesBody = async req => new Promise((resolve, reject) => { 178 - let body = ''; 179 - req.on('data', chunk => body += chunk); 180 - req.on('end', () => resolve(body)); 181 - req.on('error', err => reject(err)); 182 - }); 183 - 184 - const COOKIE_BASE = { httpOnly: true, secure: true, partitioned: true, sameSite: 'None' }; 185 - const setAccountCookie = (res, did, session, appSecret) => res.setHeader('Set-Cookie', cookie.serialize( 186 - 'verified-account', 187 - cookieSig.sign(JSON.stringify([did, session]), appSecret), 188 - { ...COOKIE_BASE, maxAge: 90 * 86_400 }, 189 - )); 190 - const clearAccountCookie = res => res.setHeader('Set-Cookie', cookie.serialize( 191 - 'verified-account', 192 - '', 193 - { ...COOKIE_BASE, expires: new Date(0) }, 194 - )); 195 - 196 - const getAccountCookie = (req, res, appSecret, adminDid, noDidCheck = false) => { 197 - const cookies = cookie.parse(req.headers.cookie ?? ''); 198 - const untrusted = cookies['verified-account'] ?? ''; 199 - const json = cookieSig.unsign(untrusted, appSecret); 200 - if (!json) { 201 - clearAccountCookie(res); 202 - return null; 203 - } 204 - let did, session; 205 - try { 206 - [did, session] = JSON.parse(json); 207 - } catch (e) { 208 - console.warn('validated account cookie but failed to parse json', e); 209 - clearAccountCookie(res); 210 - return null; 211 - } 212 - 213 - // not yet public!! 214 - if (!did || (did !== adminDid && !noDidCheck)) { 215 - console.log('no, clearing you', did, did === adminDid, noDidCheck); 216 - clearAccountCookie(res) 217 - .setHeader('Content-Type', 'application/json') 218 - .writeHead(403) 219 - .end(JSON.stringify({ 220 - reason: 'the spacedust notifications demo isn\'t public yet!', 221 - })); 222 - throw new Error('unauthorized'); 223 - } 224 - 225 - return [did, session, did && (did === adminDid)]; 226 - }; 227 - 228 - // never EVER allow user-controllable input into fname (or just fix the path joining) 229 - const handleFile = (fname, ftype) => async (req, res, replace = {}) => { 230 - let content 231 - try { 232 - content = await fs.promises.readFile(`./web-content/${fname}`); // DANGERDANGER 233 - content = content.toString(); 234 - } catch (err) { 235 - console.error(err); 236 - res.writeHead(500); 237 - res.end('Internal server error'); 238 - return; 239 - } 240 - res.setHeader('Content-Type', ftype); 241 - res.writeHead(200); 242 - for (let k in replace) { 243 - content = content.replace(k, JSON.stringify(replace[k])); 244 - } 245 - res.end(content); 246 - } 247 - const handleIndex = handleFile('index.html', 'text/html'); 248 - const handleServiceWorker = handleFile('service-worker.js', 'application/javascript'); 249 - 250 - const handleHello = async (db, req, res, secrets, whoamiHost, adminDid) => { 251 - const resBase = { webPushPublicKey: secrets.pushKeys.publicKey, whoamiHost }; 252 - res.setHeader('Content-Type', 'application/json'); 253 - let info = getAccountCookie(req, res, secrets.appSecret, adminDid, true); 254 - if (info) { 255 - const [did, _session, isAdmin] = info; 256 - let role = db.getAccount(did)?.role; 257 - role = isAdmin ? 'admin' : (role ?? 'public'); 258 - res 259 - .setHeader('Content-Type', 'application/json') 260 - .writeHead(200) 261 - .end(JSON.stringify({ ...resBase, role, did })); 262 - } else { 263 - res 264 - .setHeader('Content-Type', 'application/json') 265 - .writeHead(200) 266 - .end(JSON.stringify({ ...resBase, role: 'anonymous' })); 267 - } 268 - }; 269 - 270 - const handleVerify = async (db, req, res, secrets, whoamiHost, adminDid, jwks) => { 271 - const body = await getRequesBody(req); 272 - const { token } = JSON.parse(body); 273 - let did; 274 - try { 275 - const verified = await jose.jwtVerify(token, jwks); 276 - did = verified.payload.sub; 277 - } catch (e) { 278 - console.warn('jwks verification failed', e); 279 - return clearAccountCookie(res).writeHead(400).end(JSON.stringify({ reason: 'verification failed' })); 280 - } 281 - const isAdmin = did && did === adminDid; 282 - db.addAccount(did); 283 - const session = uuidv4(); 284 - setAccountCookie(res, did, session, secrets.appSecret); 285 - return res 286 - .setHeader('Content-Type', 'application/json') 287 - .writeHead(200) 288 - .end(JSON.stringify({ 289 - did, 290 - role: isAdmin ? 'admin' : 'public', 291 - webPushPublicKey: secrets.pushKeys.publicKey, 292 - })); 293 - }; 294 - 295 - const handleSubscribe = async (db, req, res, appSecret, adminDid) => { 296 - let info = getAccountCookie(req, res, appSecret, adminDid, true); 297 - if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 298 - const [did, session, _isAdmin] = info; 299 - const body = await getRequesBody(req); 300 - const { sub } = JSON.parse(body); 301 - // addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG) 302 - try { 303 - db.addPushSub(did, session, JSON.stringify(sub)); 304 - } catch (e) { 305 - console.warn('failed to add sub', e); 306 - return res 307 - .setHeader('Content-Type', 'application/json') 308 - .writeHead(500) 309 - .end(JSON.stringify({ reason: 'failed to register subscription' })); 310 - } 311 - updateSubs(db); 312 - res.setHeader('Content-Type', 'application/json'); 313 - res.writeHead(201); 314 - res.end(JSON.stringify({ sup: 'hi' })); 315 - }; 316 - 317 - const handleLogout = async (db, req, res, appSecret) => { 318 - let info = getAccountCookie(req, res, appSecret, null, true); 319 - if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 320 - const [_did, session, _isAdmin] = info; 321 - try { 322 - db.deleteSub(session); 323 - } catch (e) { 324 - console.warn('failed to remove sub', e); 325 - return res 326 - .setHeader('Content-Type', 'application/json') 327 - .writeHead(500) 328 - .end(JSON.stringify({ reason: 'failed to register subscription' })); 329 - } 330 - updateSubs(db); 331 - clearAccountCookie(res); 332 - res.setHeader('Content-Type', 'application/json'); 333 - res.writeHead(201); 334 - res.end(JSON.stringify({ sup: 'bye' })); 335 - } 336 - 337 - const handleTopSecret = async (db, req, res, appSecret) => { 338 - let info = getAccountCookie(req, res, appSecret, null, true); 339 - if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 340 - const [did, _session, _isAdmin] = info; 341 - const body = await getRequesBody(req); 342 - const { secret_password } = JSON.parse(body); 343 - console.log({ secret_password }); 344 - const role = 'early'; 345 - db.setRole(did, role, secret_password); 346 - res.setHeader('Content-Type', 'application/json') 347 - .writeHead(200) 348 - .end('"heyyy"'); 349 - } 350 - 351 - const attempt = listener => async (req, res) => { 352 - console.log(`-> ${req.method} ${req.url}`); 353 - try { 354 - return await listener(req, res); 355 - } catch (e) { 356 - console.error('listener errored:', e); 357 - } 358 - }; 359 - 360 - const withCors = (allowedOrigin, listener) => { 361 - const corsHeaders = new Headers({ 362 - 'Access-Control-Allow-Origin': allowedOrigin, 363 - 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', 364 - 'Access-Control-Allow-Headers': 'Content-Type', 365 - 'Access-Control-Allow-Credentials': 'true', 366 - }); 367 - return (req, res) => { 368 - res.setHeaders(corsHeaders); 369 - if (req.method === 'OPTIONS') { 370 - return res.writeHead(204).end(); 371 - } 372 - return listener(req, res); 373 - } 374 - } 375 - 376 - const requestListener = 377 - (secrets, jwks, allowedOrigin, whoamiHost, db, adminDid) => 378 - attempt(withCors(allowedOrigin, (req, res) => { 379 - 380 - if (req.method === 'GET' && req.url === '/') { 381 - return handleIndex(req, res, { PUBKEY: secrets.pushKeys.publicKey }); 382 - } 383 - if (req.method === 'GET' && req.url === '/hello') { 384 - return handleHello(db, req, res, secrets, whoamiHost, adminDid); 385 - } 386 - if (req.method === 'POST' && req.url === '/verify') { 387 - return handleVerify(db, req, res, secrets, whoamiHost, adminDid, jwks); 388 - } 389 - if (req.method === 'POST' && req.url === '/subscribe') { 390 - return handleSubscribe(db, req, res, secrets.appSecret, adminDid); 391 - } 392 - if (req.method === 'POST' && req.url === '/logout') { 393 - return handleLogout(db, req, res, secrets.appSecret); 394 - } 395 - if (req.method === 'POST' && req.url === '/super-top-secret-access') { 396 - return handleTopSecret(db, req, res, secrets.appSecret); 397 - } 398 - 399 - res.writeHead(404).end('not found (sorry)'); 400 - })); 401 - 402 const main = env => { 403 if (!env.ADMIN_DID) throw new Error('ADMIN_DID is required to run'); 404 const adminDid = env.ADMIN_DID; ··· 412 ); 413 414 const whoamiHost = env.WHOAMI_HOST ?? 'https://who-am-i.microcosm.blue'; 415 - const jwks = jose.createRemoteJWKSet(new URL(`${whoamiHost}/.well-known/jwks.json`)); 416 417 const dbFilename = env.DB_FILE ?? './db.sqlite3'; 418 const initDb = process.argv.includes('--init-db'); ··· 420 const db = new DB(dbFilename, initDb); 421 422 const spacedustHost = env.SPACEDUST_HOST ?? 'wss://spacedust.microcosm.blue'; 423 - connectSpacedust(db, spacedustHost); 424 425 const host = env.HOST ?? 'localhost'; 426 const port = parseInt(env.PORT ?? 8000, 10); 427 428 const allowedOrigin = env.ALLOWED_ORIGIN ?? 'http://127.0.0.1:5173'; 429 430 - http 431 - .createServer(requestListener(secrets, jwks, allowedOrigin, whoamiHost, db, adminDid)) 432 - .listen( 433 - port, 434 - host, 435 - () => console.log(`listening at http://${host}:${port} with allowed origin: ${allowedOrigin}`), 436 - ); 437 }; 438 439 main(process.env);
··· 1 #!/usr/bin/env node 2 3 + import { createRemoteJWKSet } from 'jose'; 4 import fs from 'node:fs'; 5 import { randomBytes } from 'node:crypto'; 6 import webpush from 'web-push'; 7 import { DB } from './db.js'; 8 + import { connectSpacedust } from './notifications.js'; 9 + import { server } from './api.js'; 10 11 const getOrCreateSecrets = filename => { 12 let secrets; ··· 26 return secrets; 27 } 28 29 const main = env => { 30 if (!env.ADMIN_DID) throw new Error('ADMIN_DID is required to run'); 31 const adminDid = env.ADMIN_DID; ··· 39 ); 40 41 const whoamiHost = env.WHOAMI_HOST ?? 'https://who-am-i.microcosm.blue'; 42 + const jwks = createRemoteJWKSet(new URL(`${whoamiHost}/.well-known/jwks.json`)); 43 44 const dbFilename = env.DB_FILE ?? './db.sqlite3'; 45 const initDb = process.argv.includes('--init-db'); ··· 47 const db = new DB(dbFilename, initDb); 48 49 const spacedustHost = env.SPACEDUST_HOST ?? 'wss://spacedust.microcosm.blue'; 50 + const updateSubs = connectSpacedust(db, spacedustHost); 51 52 const host = env.HOST ?? 'localhost'; 53 const port = parseInt(env.PORT ?? 8000, 10); 54 55 const allowedOrigin = env.ALLOWED_ORIGIN ?? 'http://127.0.0.1:5173'; 56 57 + server(secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, adminDid).listen( 58 + port, 59 + host, 60 + () => console.log(`listening at http://${host}:${port} with allowed origin: ${allowedOrigin}`), 61 + ); 62 }; 63 64 main(process.env);
+147
server/notifications.js
···
··· 1 + import lexicons from 'lexicons'; 2 + import psl from 'psl'; 3 + import webpush from 'web-push'; 4 + import WebSocket from 'ws'; 5 + 6 + // kind of silly but right now there's no way to tell spacedust that we want an alive connection 7 + // but don't want the notification firehose (everything filtered out) 8 + // so... the final filter is an absolute on this fake did, effectively filtering all notifs. 9 + // (this is only used when there are no subscribers registered) 10 + const DUMMY_DID = 'did:plc:zzzzzzzzzzzzzzzzzzzzzzzz'; 11 + 12 + let spacedust; 13 + let spacedustEverStarted = false; 14 + 15 + const updateSubs = db => { 16 + if (!spacedust) { 17 + console.warn('not updating subscription, no spacedust (reconnecting?)'); 18 + return; 19 + } 20 + const wantedSubjectDids = db.getSubscribedDids(); 21 + if (wantedSubjectDids.length === 0) { 22 + wantedSubjectDids.push(DUMMY_DID); 23 + } 24 + console.log('updating for wantedSubjectDids', wantedSubjectDids); 25 + spacedust.send(JSON.stringify({ 26 + type: 'options_update', 27 + payload: { 28 + wantedSubjectDids, 29 + }, 30 + })); 31 + }; 32 + 33 + const push = async (db, pushSubscription, payload) => { 34 + const { session, subscription, since_last_push } = pushSubscription; 35 + if (since_last_push !== null && since_last_push < 1.618) { 36 + console.warn(`rate limiter: dropping too-soon push (${since_last_push})`); 37 + return; 38 + } 39 + 40 + let sub; 41 + try { 42 + sub = JSON.parse(subscription); 43 + } catch (e) { 44 + console.error('failed to parse subscription json, dropping session', e); 45 + db.deleteSub(session); 46 + return; 47 + } 48 + 49 + try { 50 + await webpush.sendNotification(sub, payload); 51 + } catch (err) { 52 + if (400 <= err.statusCode && err.statusCode < 500) { 53 + console.info(`removing sub for ${err.statusCode}`); 54 + db.deleteSub(session); 55 + return; 56 + } else { 57 + console.warn('something went wrong for another reason', err); 58 + } 59 + } 60 + 61 + db.updateLastPush(session); 62 + }; 63 + 64 + const isTorment = source => { 65 + try { 66 + const [nsid, ...rp] = source.split(':'); 67 + 68 + let parts = nsid.split('.'); 69 + parts.reverse(); 70 + parts = parts.join('.'); 71 + 72 + // const unreversed = parts.toReversed().join('.'); 73 + 74 + const app = psl.parse(parts)?.domain ?? 'unknown'; 75 + 76 + let appPrefix = app.split('.'); 77 + appPrefix.reverse(); 78 + appPrefix = appPrefix.join('.') 79 + 80 + return source.slice(app.length + 1) in lexicons[appPrefix]?.torment_sources; 81 + } catch (e) { 82 + console.error('checking tormentedness failed, allowing through', e); 83 + return false; 84 + } 85 + }; 86 + 87 + const handleDust = db => async event => { 88 + console.log('got', event.data); 89 + let data; 90 + try { 91 + data = JSON.parse(event.data); 92 + } catch (err) { 93 + console.error(err); 94 + return; 95 + } 96 + const { link: { subject, source, source_record } } = data; 97 + if (isTorment(source)) { 98 + console.log('nope! not today,', source); 99 + return; 100 + } 101 + const timestamp = +new Date(); 102 + 103 + let did; 104 + if (subject.startsWith('did:')) did = subject; 105 + else if (subject.startsWith('at://')) { 106 + const [id, ..._] = subject.slice('at://'.length).split('/'); 107 + if (id.startsWith('did:')) did = id; 108 + } 109 + if (!did) { 110 + console.warn(`ignoring link with non-DID subject: ${subject}`) 111 + return; 112 + } 113 + 114 + const subs = db.getSubsByDid(did); 115 + const payload = JSON.stringify({ subject, source, source_record, timestamp }); 116 + let res = await Promise.all(subs.map(pushSubscription => push(db, pushSubscription, payload))); 117 + console.log('send results', res); 118 + }; 119 + 120 + export const connectSpacedust = (db, host) => { 121 + spacedust = new WebSocket(`${host}/subscribe?instant=true&wantedSubjectDids=${DUMMY_DID}`); 122 + let restarting = false; 123 + 124 + const restart = () => { 125 + if (restarting) return; 126 + restarting = true; 127 + let wait = Math.round(500 + (Math.random() * 1000)); 128 + console.info(`restarting spacedust connection in ${wait}ms...`); 129 + setTimeout(() => connectSpacedust(db, host), wait); 130 + spacedust = null; 131 + } 132 + 133 + spacedust.onopen = () => updateSubs(db); 134 + spacedust.onmessage = handleDust(db); 135 + 136 + spacedust.onerror = e => { 137 + console.error('spacedust errored:', e); 138 + restart(); 139 + }; 140 + 141 + spacedust.onclose = () => { 142 + console.log('spacedust closed'); 143 + restart(); 144 + }; 145 + 146 + return updateSubs; 147 + };