+232
server/api.js
+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
+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
+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
+
};