Something went wrong. Try again.
Something went wrong. Try again.
11 kB · 351 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352import fs from 'node:fs';import http from 'http';import { jwtVerify } from 'jose';import cookie from 'cookie';import cookieSig from 'cookie-signature';import { v4 as uuidv4 } from 'uuid';
const replyJson = (res, code) => res.setHeader('Content-Type', 'application/json').writeHead(code);const errJson = (code, reason) => res => replyJson(res, code).end(JSON.stringify({ reason }));
const ok = (res, data) => replyJson(res, 200).end(JSON.stringify(data));const gotIt = res => res.writeHead(201).end();const okBye = res => res.writeHead(204).end();const notModified = res => res.writeHead(304).end();const badRequest = (res, reason) => errJson(400, reason)(res);const forbidden = errJson(401, 'forbidden');const unauthorized = errJson(403, 'unauthorized');const notFound = errJson(404, 'not found');const conflict = errJson(409, 'conflict');const serverError = errJson(500, 'internal server error');
const getRequesBody = async req => new Promise((resolve, reject) => { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => resolve(body)); req.on('error', err => reject(err));});
const COOKIE_BASE = { httpOnly: true, secure: true, partitioned: true, sameSite: 'None' };const setAccountCookie = (res, did, session, appSecret) => res.setHeader('Set-Cookie', cookie.serialize( 'verified-account', cookieSig.sign(JSON.stringify([did, session]), appSecret), { ...COOKIE_BASE, maxAge: 90 * 86_400 },));const clearAccountCookie = res => res.setHeader('Set-Cookie', cookie.serialize( 'verified-account', '', { ...COOKIE_BASE, expires: new Date(0) },));
const getUser = (req, res, db, appSecret, adminDid) => { const cookies = cookie.parse(req.headers.cookie ?? ''); const untrusted = cookies['verified-account'] ?? ''; const json = cookieSig.unsign(untrusted, appSecret); if (!json) { clearAccountCookie(res); return null; } let did, session; try { [did, session] = JSON.parse(json); } catch (e) { console.warn('validated account cookie but failed to parse json', e); clearAccountCookie(res); return null; } let role; if (did === adminDid) { role = 'admin'; } else { const account = db.getAccount(did); if (!account) { console.warn('valid account cookie but could not find in db'); clearAccountCookie(res); return null; } role = account.role ?? 'public'; } return { did, session, role };};
/////// handlers
// never EVER allow user-controllable input into fname (or just fix the path joining)const handleFile = (fname, ftype) => async (req, res, replace = {}) => { let content try { content = await fs.promises.readFile(`./web-content/${fname}`); // DANGERDANGER content = content.toString(); } catch (err) { console.error(err); return serverError(res); } res.setHeader('Content-Type', ftype); res.writeHead(200); for (let k in replace) { content = content.replace(k, JSON.stringify(replace[k])); } res.end(content);}const handleIndex = handleFile('index.html', 'text/html');
const handleVerify = async (db, req, res, secrets, jwks, adminDid) => { const body = await getRequesBody(req); const { token } = JSON.parse(body); let did; try { const verified = await jwtVerify(token, jwks); did = verified.payload.sub; } catch (e) { console.warn('jwks verification failed', e); return badRequest(res, 'token verification failed'); } const isAdmin = did && did === adminDid; db.addAccount(did); const session = uuidv4(); setAccountCookie(res, did, session, secrets.appSecret); return ok(res, { webPushPublicKey: secrets.pushKeys.publicKey, role: isAdmin ? 'admin' : 'public', did, });};
const handleHello = async (user, req, res, webPushPublicKey, whoamiHost) => ok(res, { whoamiHost, webPushPublicKey, role: user?.role ?? 'anonymous', did: user?.did, });
const handleSubscribe = async (db, user, req, res, updateSubs) => { const body = await getRequesBody(req); const { sub } = JSON.parse(body); try { db.addPushSub(user.did, user.session, JSON.stringify(sub)); } catch (e) { console.warn('failed to add sub', e); return serverError(res); } updateSubs(db); return gotIt(res);};
const handlePushTest = async (db, user, res, push) => { const subscription = db.getSubBySession(user.session); const payload = JSON.stringify({ subject: user.did, source: 'blue.microcosm.test.notification:hello', source_record: `at://${user.did}/blue.microcosm.test.notification/test`, timestamp: +new Date(), }); await push(db, subscription, payload); return okBye(res);};
const handleLogout = async (db, user, req, res, appSecret, updateSubs) => { try { db.deleteSub(user.session); } catch (e) { console.warn('failed to remove sub', e); return serverError(res); } updateSubs(db); clearAccountCookie(res); return okBye(res);};
const handleTopSecret = async (db, user, req, res) => { // TODO: succeed early if they're already in? const body = await getRequesBody(req); const { secret_password } = JSON.parse(body); const { did } = user; const role = 'early'; const updated = db.setRole({ did, role, secret_password }); if (updated) { return okBye(res); } else { return forbidden(res); }};
const handleGetGlobalNotifySettings = async (db, user, res) => { const settings = db.getNotifyAccountGlobals(user.did); return ok(res, settings);};
const handleSetGlobalNotifySettings = async (db, user, req, res) => { const body = await getRequesBody(req); const { notify_enabled, notify_self } = JSON.parse(body); db.setNotifyAccountGlobals(user.did, { notify_enabled, notify_self }); return gotIt(res);};
const handleGetNotificationFilter = async (db, user, searchParams, res) => { const selector = searchParams.get('selector'); if (!selector) return badRequest(res, '"selector" required in search query');
const selection = searchParams.get('selection'); if (!selection) return badRequest(res, '"selection" required in search query');
const { did } = user;
const notify = db.getNotificationFilter(did, selector, selection) ?? null; return ok(res, { notify });};
const handleSetNotificationFilter = async (db, user, req, res) => { const body = await getRequesBody(req); const { selector, selection, notify } = JSON.parse(body); const { did } = user; db.setNotificationFilter(did, selector, selection, notify); return ok(res, { notify });};
/// admin stuff
const handleListSecrets = async (db, res) => { const secrets = db.getSecrets(); return ok(res, secrets);};
const handleAddSecret = async (db, req, res) => { const body = await getRequesBody(req); const { secret_password } = JSON.parse(body); try { db.addTopSecret(secret_password); } catch (e) { if (['SQLITE_CONSTRAINT_PRIMARYKEY', 'SQLITE_CONSTRAINT_CHECK'].includes(e.code)) { return conflict(res); } throw e; } return gotIt(res);};
const handleExpireSecret = async (db, req, res) => { const body = await getRequesBody(req); const { secret_password } = JSON.parse(body); if (db.expireTopSecret(secret_password)) { return gotIt(res); } else { return notModified(res); }};
const handleTopSecretAccounts = async (db, req, res, searchParams) => { const secret = searchParams.get('secret_password'); const accounts = secret ? db.getSecretAccounts(secret) : db.getNonSecretAccounts(); return ok(res, accounts);};
/////// end handlers
const attempt = listener => async (req, res) => { console.log(`-> ${req.method} ${req.url}`); try { await listener(req, res); console.log(` <-${req.method} ${req.url} (${res.statusCode})`); } catch (e) { console.error('listener errored:', e); return serverError(res); }};
const withCors = (allowedOrigin, listener) => { const corsHeaders = new Headers({ 'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Methods': 'OPTIONS, GET, POST', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Credentials': 'true', }); return (req, res) => { res.setHeaders(corsHeaders); if (req.method === 'OPTIONS') { return okBye(res); } return listener(req, res); }}
export const server = (secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, push, adminDid) => { const handler = (req, res) => { // don't love this but whatever const { pathname, searchParams } = new URL(`http://localhost${req.url}`); const { method } = req;
// public (we're doing fall-through auth, what could go wrong) if (method === 'GET' && pathname === '/') { return handleIndex(req, res, {}); } if (method === 'POST' && pathname === '/verify') { return handleVerify(db, req, res, secrets, jwks, adminDid); }
// semi-public const user = getUser(req, res, db, secrets.appSecret, adminDid); if (method === 'GET' && pathname === '/hello') { return handleHello(user, req, res, secrets.pushKeys.publicKey, whoamiHost); }
// login required if (method === 'POST' && pathname === '/logout') { if (!user) return unauthorized(res); return handleLogout(db, user, req, res, secrets.appSecret, updateSubs); } if (method === 'POST' && pathname === '/super-top-secret-access') { if (!user) return unauthorized(res); return handleTopSecret(db, user, req, res); } if (method === 'GET' && pathname === '/global-notify') { if (!user) return unauthorized(res); return handleGetGlobalNotifySettings(db, user, res); } if (method === 'POST' && pathname === '/global-notify') { if (!user) return unauthorized(res); return handleSetGlobalNotifySettings(db, user, req, res); } if (method === 'GET' && pathname === '/notification-filter') { if (!user) return unauthorized(res); return handleGetNotificationFilter(db, user, searchParams, res); } if (method === 'POST' && pathname === '/notification-filter') { if (!user) return unauthorized(res); return handleSetNotificationFilter(db, user, req, res); }
// non-public access required if (method === 'POST' && pathname === '/subscribe') { if (!user || user.role === 'public') return forbidden(res); return handleSubscribe(db, user, req, res, updateSubs); } if (method === 'POST' && pathname === '/push-test') { if (!user || user.role === 'public') return forbidden(res); return handlePushTest(db, user, res, push); }
// admin required (just 404 for non-admin) if (user?.role === 'admin') { if (method === 'GET' && pathname === '/top-secrets') { return handleListSecrets(db, res); } if (method === 'POST' && pathname === '/top-secret') { return handleAddSecret(db, req, res); } if (method === 'POST' && pathname === '/expire-top-secret') { return handleExpireSecret(db, req, res); } if (method === 'GET' && pathname === '/top-secret-accounts') { return handleTopSecretAccounts(db, req, res, searchParams); } }
// sigh return notFound(res); };
return http.createServer(attempt(withCors(allowedOrigin, handler)));}