at main 11 kB view raw
1import fs from 'node:fs'; 2import http from 'http'; 3import { jwtVerify } from 'jose'; 4import cookie from 'cookie'; 5import cookieSig from 'cookie-signature'; 6import { v4 as uuidv4 } from 'uuid'; 7 8const replyJson = (res, code) => res.setHeader('Content-Type', 'application/json').writeHead(code); 9const errJson = (code, reason) => res => replyJson(res, code).end(JSON.stringify({ reason })); 10 11const ok = (res, data) => replyJson(res, 200).end(JSON.stringify(data)); 12const gotIt = res => res.writeHead(201).end(); 13const okBye = res => res.writeHead(204).end(); 14const notModified = res => res.writeHead(304).end(); 15const badRequest = (res, reason) => errJson(400, reason)(res); 16const forbidden = errJson(401, 'forbidden'); 17const unauthorized = errJson(403, 'unauthorized'); 18const notFound = errJson(404, 'not found'); 19const conflict = errJson(409, 'conflict'); 20const serverError = errJson(500, 'internal server error'); 21 22const getRequesBody = async req => new Promise((resolve, reject) => { 23 let body = ''; 24 req.on('data', chunk => body += chunk); 25 req.on('end', () => resolve(body)); 26 req.on('error', err => reject(err)); 27}); 28 29const COOKIE_BASE = { httpOnly: true, secure: true, partitioned: true, sameSite: 'None' }; 30const setAccountCookie = (res, did, session, appSecret) => res.setHeader('Set-Cookie', cookie.serialize( 31 'verified-account', 32 cookieSig.sign(JSON.stringify([did, session]), appSecret), 33 { ...COOKIE_BASE, maxAge: 90 * 86_400 }, 34)); 35const clearAccountCookie = res => res.setHeader('Set-Cookie', cookie.serialize( 36 'verified-account', 37 '', 38 { ...COOKIE_BASE, expires: new Date(0) }, 39)); 40 41const getUser = (req, res, db, appSecret, adminDid) => { 42 const cookies = cookie.parse(req.headers.cookie ?? ''); 43 const untrusted = cookies['verified-account'] ?? ''; 44 const json = cookieSig.unsign(untrusted, appSecret); 45 if (!json) { 46 clearAccountCookie(res); 47 return null; 48 } 49 let did, session; 50 try { 51 [did, session] = JSON.parse(json); 52 } catch (e) { 53 console.warn('validated account cookie but failed to parse json', e); 54 clearAccountCookie(res); 55 return null; 56 } 57 let role; 58 if (did === adminDid) { 59 role = 'admin'; 60 } else { 61 const account = db.getAccount(did); 62 if (!account) { 63 console.warn('valid account cookie but could not find in db'); 64 clearAccountCookie(res); 65 return null; 66 } 67 role = account.role ?? 'public'; 68 } 69 return { did, session, role }; 70}; 71 72/////// handlers 73 74// never EVER allow user-controllable input into fname (or just fix the path joining) 75const handleFile = (fname, ftype) => async (req, res, replace = {}) => { 76 let content 77 try { 78 content = await fs.promises.readFile(`./web-content/${fname}`); // DANGERDANGER 79 content = content.toString(); 80 } catch (err) { 81 console.error(err); 82 return serverError(res); 83 } 84 res.setHeader('Content-Type', ftype); 85 res.writeHead(200); 86 for (let k in replace) { 87 content = content.replace(k, JSON.stringify(replace[k])); 88 } 89 res.end(content); 90} 91const handleIndex = handleFile('index.html', 'text/html'); 92 93const handleVerify = async (db, req, res, secrets, jwks, adminDid) => { 94 const body = await getRequesBody(req); 95 const { token } = JSON.parse(body); 96 let did; 97 try { 98 const verified = await jwtVerify(token, jwks); 99 did = verified.payload.sub; 100 } catch (e) { 101 console.warn('jwks verification failed', e); 102 return badRequest(res, 'token verification failed'); 103 } 104 const isAdmin = did && did === adminDid; 105 db.addAccount(did); 106 const session = uuidv4(); 107 setAccountCookie(res, did, session, secrets.appSecret); 108 return ok(res, { 109 webPushPublicKey: secrets.pushKeys.publicKey, 110 role: isAdmin ? 'admin' : 'public', 111 did, 112 }); 113}; 114 115const handleHello = async (user, req, res, webPushPublicKey, whoamiHost) => 116 ok(res, { 117 whoamiHost, 118 webPushPublicKey, 119 role: user?.role ?? 'anonymous', 120 did: user?.did, 121 }); 122 123const handleSubscribe = async (db, user, req, res, updateSubs) => { 124 const body = await getRequesBody(req); 125 const { sub } = JSON.parse(body); 126 try { 127 db.addPushSub(user.did, user.session, JSON.stringify(sub)); 128 } catch (e) { 129 console.warn('failed to add sub', e); 130 return serverError(res); 131 } 132 updateSubs(db); 133 return gotIt(res); 134}; 135 136const handlePushTest = async (db, user, res, push) => { 137 const subscription = db.getSubBySession(user.session); 138 const payload = JSON.stringify({ 139 subject: user.did, 140 source: 'blue.microcosm.test.notification:hello', 141 source_record: `at://${user.did}/blue.microcosm.test.notification/test`, 142 timestamp: +new Date(), 143 }); 144 await push(db, subscription, payload); 145 return okBye(res); 146}; 147 148const handleLogout = async (db, user, req, res, appSecret, updateSubs) => { 149 try { 150 db.deleteSub(user.session); 151 } catch (e) { 152 console.warn('failed to remove sub', e); 153 return serverError(res); 154 } 155 updateSubs(db); 156 clearAccountCookie(res); 157 return okBye(res); 158}; 159 160const handleTopSecret = async (db, user, req, res) => { 161 // TODO: succeed early if they're already in? 162 const body = await getRequesBody(req); 163 const { secret_password } = JSON.parse(body); 164 const { did } = user; 165 const role = 'early'; 166 const updated = db.setRole({ did, role, secret_password }); 167 if (updated) { 168 return okBye(res); 169 } else { 170 return forbidden(res); 171 } 172}; 173 174const handleGetGlobalNotifySettings = async (db, user, res) => { 175 const settings = db.getNotifyAccountGlobals(user.did); 176 return ok(res, settings); 177}; 178 179const handleSetGlobalNotifySettings = async (db, user, req, res) => { 180 const body = await getRequesBody(req); 181 const { notify_enabled, notify_self } = JSON.parse(body); 182 db.setNotifyAccountGlobals(user.did, { notify_enabled, notify_self }); 183 return gotIt(res); 184}; 185 186const handleGetNotificationFilter = async (db, user, searchParams, res) => { 187 const selector = searchParams.get('selector'); 188 if (!selector) return badRequest(res, '"selector" required in search query'); 189 190 const selection = searchParams.get('selection'); 191 if (!selection) return badRequest(res, '"selection" required in search query'); 192 193 const { did } = user; 194 195 const notify = db.getNotificationFilter(did, selector, selection) ?? null; 196 return ok(res, { notify }); 197}; 198 199const handleSetNotificationFilter = async (db, user, req, res) => { 200 const body = await getRequesBody(req); 201 const { selector, selection, notify } = JSON.parse(body); 202 const { did } = user; 203 db.setNotificationFilter(did, selector, selection, notify); 204 return ok(res, { notify }); 205}; 206 207/// admin stuff 208 209const handleListSecrets = async (db, res) => { 210 const secrets = db.getSecrets(); 211 return ok(res, secrets); 212}; 213 214const handleAddSecret = async (db, req, res) => { 215 const body = await getRequesBody(req); 216 const { secret_password } = JSON.parse(body); 217 try { 218 db.addTopSecret(secret_password); 219 } catch (e) { 220 if (['SQLITE_CONSTRAINT_PRIMARYKEY', 'SQLITE_CONSTRAINT_CHECK'].includes(e.code)) { 221 return conflict(res); 222 } 223 throw e; 224 } 225 return gotIt(res); 226}; 227 228const handleExpireSecret = async (db, req, res) => { 229 const body = await getRequesBody(req); 230 const { secret_password } = JSON.parse(body); 231 if (db.expireTopSecret(secret_password)) { 232 return gotIt(res); 233 } else { 234 return notModified(res); 235 } 236}; 237 238const handleTopSecretAccounts = async (db, req, res, searchParams) => { 239 const secret = searchParams.get('secret_password'); 240 const accounts = secret ? db.getSecretAccounts(secret) : db.getNonSecretAccounts(); 241 return ok(res, accounts); 242}; 243 244 245/////// end handlers 246 247const attempt = listener => async (req, res) => { 248 console.log(`-> ${req.method} ${req.url}`); 249 try { 250 await listener(req, res); 251 console.log(` <-${req.method} ${req.url} (${res.statusCode})`); 252 } catch (e) { 253 console.error('listener errored:', e); 254 return serverError(res); 255 } 256}; 257 258const withCors = (allowedOrigin, listener) => { 259 const corsHeaders = new Headers({ 260 'Access-Control-Allow-Origin': allowedOrigin, 261 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', 262 'Access-Control-Allow-Headers': 'Content-Type', 263 'Access-Control-Allow-Credentials': 'true', 264 }); 265 return (req, res) => { 266 res.setHeaders(corsHeaders); 267 if (req.method === 'OPTIONS') { 268 return okBye(res); 269 } 270 return listener(req, res); 271 } 272} 273 274export const server = (secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, push, adminDid) => { 275 const handler = (req, res) => { 276 // don't love this but whatever 277 const { pathname, searchParams } = new URL(`http://localhost${req.url}`); 278 const { method } = req; 279 280 // public (we're doing fall-through auth, what could go wrong) 281 if (method === 'GET' && pathname === '/') { 282 return handleIndex(req, res, {}); 283 } 284 if (method === 'POST' && pathname === '/verify') { 285 return handleVerify(db, req, res, secrets, jwks, adminDid); 286 } 287 288 // semi-public 289 const user = getUser(req, res, db, secrets.appSecret, adminDid); 290 if (method === 'GET' && pathname === '/hello') { 291 return handleHello(user, req, res, secrets.pushKeys.publicKey, whoamiHost); 292 } 293 294 // login required 295 if (method === 'POST' && pathname === '/logout') { 296 if (!user) return unauthorized(res); 297 return handleLogout(db, user, req, res, secrets.appSecret, updateSubs); 298 } 299 if (method === 'POST' && pathname === '/super-top-secret-access') { 300 if (!user) return unauthorized(res); 301 return handleTopSecret(db, user, req, res); 302 } 303 if (method === 'GET' && pathname === '/global-notify') { 304 if (!user) return unauthorized(res); 305 return handleGetGlobalNotifySettings(db, user, res); 306 } 307 if (method === 'POST' && pathname === '/global-notify') { 308 if (!user) return unauthorized(res); 309 return handleSetGlobalNotifySettings(db, user, req, res); 310 } 311 if (method === 'GET' && pathname === '/notification-filter') { 312 if (!user) return unauthorized(res); 313 return handleGetNotificationFilter(db, user, searchParams, res); 314 } 315 if (method === 'POST' && pathname === '/notification-filter') { 316 if (!user) return unauthorized(res); 317 return handleSetNotificationFilter(db, user, req, res); 318 } 319 320 // non-public access required 321 if (method === 'POST' && pathname === '/subscribe') { 322 if (!user || user.role === 'public') return forbidden(res); 323 return handleSubscribe(db, user, req, res, updateSubs); 324 } 325 if (method === 'POST' && pathname === '/push-test') { 326 if (!user || user.role === 'public') return forbidden(res); 327 return handlePushTest(db, user, res, push); 328 } 329 330 // admin required (just 404 for non-admin) 331 if (user?.role === 'admin') { 332 if (method === 'GET' && pathname === '/top-secrets') { 333 return handleListSecrets(db, res); 334 } 335 if (method === 'POST' && pathname === '/top-secret') { 336 return handleAddSecret(db, req, res); 337 } 338 if (method === 'POST' && pathname === '/expire-top-secret') { 339 return handleExpireSecret(db, req, res); 340 } 341 if (method === 'GET' && pathname === '/top-secret-accounts') { 342 return handleTopSecretAccounts(db, req, res, searchParams); 343 } 344 } 345 346 // sigh 347 return notFound(res); 348 }; 349 350 return http.createServer(attempt(withCors(allowedOrigin, handler))); 351}