tangled
alpha
login
or
join now
bad-example.com
/
spacedust-utils
6
fork
atom
demos for spacedust
6
fork
atom
overview
issues
pulls
pipelines
dev/prod shenanigans
bad-example.com
8 months ago
9372cbd1
1f81b3e1
+85
-50
7 changed files
expand all
collapse all
unified
split
atproto-notifications
package.json
src
App.tsx
components
Fetch.tsx
WhoAmI.tsx
context.ts
gh-pages.sh
server
index.js
+2
-2
atproto-notifications/package.json
···
4
4
"version": "0.0.0",
5
5
"type": "module",
6
6
"scripts": {
7
7
-
"dev": "vite --host 127.0.0.1",
7
7
+
"dev": "VITE_NOTIFICATIONS_HOST=http://127.0.0.1:8000 vite --host 127.0.0.1",
8
8
"build": "tsc -b && vite build",
9
9
-
"just-build": "VITE_PUSH_PUBKEY=BDZZicf6KaHoH6EI9OETLO3G9M4e6mKQDRJDcj_XUUxvdsnz08ne-URkk_ToqpwWgrRqXIBd0LJ_w8bP-R5xMKA vite build",
9
9
+
"just-build": "vite build",
10
10
"lint": "eslint .",
11
11
"preview": "vite preview"
12
12
},
+30
-18
atproto-notifications/src/App.tsx
···
1
1
-
import { useCallback, useState } from 'react';
1
1
+
import { useCallback, useState, useEffect } from 'react';
2
2
import { useLocalStorage } from "@uidotdev/usehooks";
3
3
-
import { HostContext } from './context'
3
3
+
import { GetJson } from './components/fetch';
4
4
import { WhoAmI } from './components/WhoAmI';
5
5
import { Feed } from './components/Feed';
6
6
import { urlBase64ToUint8Array } from './utils';
···
14
14
</div>
15
15
);
16
16
17
17
-
function requestPermission(host, setAsking, setPermissionError) {
17
17
+
function requestPermission(pushServerHost, pushServerPubkey, setAsking, setPermissionError) {
18
18
return async () => {
19
19
setAsking(true);
20
20
let err;
21
21
try {
22
22
await Notification.requestPermission();
23
23
-
const sub = await subscribeToPush();
24
24
-
const res = await fetch(`${host}/subscribe`, {
23
23
+
const sub = await subscribeToPush(pushServerPubkey);
24
24
+
const res = await fetch(`${pushServerHost}/subscribe`, {
25
25
method: 'POST',
26
26
headers: {'Content-Type': 'application/json'},
27
27
body: JSON.stringify({ sub }),
···
46
46
}
47
47
48
48
let autoreg;
49
49
-
async function subscribeToPush() {
49
49
+
async function subscribeToPush(pushServerPubkey) {
50
50
const registration = await navigator.serviceWorker.register('/service-worker.js');
51
51
52
52
-
// auto-update in case they keep it open in a tab for a long time
52
52
+
// auto-update the service worker in case they keep it open in a tab for a long time
53
53
clearInterval(autoreg);
54
54
autoreg = setInterval(() => registration.update(), 4 * 60 * 60 * 1000); // every 4h
55
55
56
56
-
const subscribeOptions = {
56
56
+
return await registration.pushManager.subscribe({
57
57
+
pushServerPubkey: urlBase64ToUint8Array(pushServerPubkey),
57
58
userVisibleOnly: true,
58
58
-
applicationServerKey: urlBase64ToUint8Array(import.meta.env.VITE_PUSH_PUBKEY),
59
59
-
};
60
60
-
const pushSubscription = await registration.pushManager.subscribe(subscribeOptions);
61
61
-
console.log({ pushSubscription });
62
62
-
return pushSubscription;
59
59
+
});
63
60
}
64
61
65
62
async function verifyUser(host, token) {
···
73
70
}
74
71
75
72
function App() {
76
76
-
const [host, setHost] = useLocalStorage('spacedust-notif-host', 'http://localhost:8000');
73
73
+
const host = import.meta.env.VITE_NOTIFICATIONS_HOST;
74
74
+
const [pushPubkey, setPushPubkey] = useState(null);
75
75
+
const [whoamiHost, setWhoamiHost] = useState(null);
76
76
+
77
77
+
const [role, setRole] = useLocalStorage('spacedust-notif-role', 'anonymous');
77
78
const [user, setUser] = useLocalStorage('spacedust-notif-user', null);
78
79
const [verif, setVerif] = useState(null);
79
80
const [asking, setAsking] = useState(false);
···
99
100
let hasPush = 'PushManager' in window;
100
101
let notifPerm = Notification?.permission ?? 'default';
101
102
103
103
+
function Blah({ info }) {
104
104
+
useEffect(() => {
105
105
+
setPushPubkey(info.webPushPublicKey);
106
106
+
setWhoamiHost(info.whoamiHost);
107
107
+
setRole(info.role);
108
108
+
});
109
109
+
return <>Got server hello, updating app state…</>;
110
110
+
}
111
111
+
102
112
let content;
103
113
if (!hasSW) {
104
114
content = <Problem>your browser does not support the background task needd to deliver notifications</Problem>;
105
115
} else if (!hasPush) {
106
116
content = <Problem>your browser does not support registering push notifications.</Problem>
117
117
+
} else if (!whoamiHost) {
118
118
+
content = <GetJson endpoint='/hello' ok={info => <Blah info={info} />} />
107
119
} else if (!user) {
108
120
if (verif === 'verifying') content = <p><em>verifying…</em></p>;
109
121
else {
110
110
-
content = <WhoAmI onIdentify={onIdentify} />;
122
122
+
content = <WhoAmI onIdentify={onIdentify} origin={whoamiHost} />;
111
123
if (verif === 'failed') {
112
124
content = <><p>Sorry, failed to verify that identity. please let us know!</p>{content}</>;
113
125
}
···
119
131
<p>To show notifications we need permission:</p>
120
132
<p>
121
133
<button
122
122
-
onClick={requestPermission(host, setAsking, setPermissionError)}
134
134
+
onClick={requestPermission(host, pushPubkey, setAsking, setPermissionError)}
123
135
disabled={asking}
124
136
>
125
137
{asking ? <>Requesting…</> : <>Request permission</>}
···
140
152
}
141
153
142
154
return (
143
143
-
<HostContext.Provider value={host}>
155
155
+
<>
144
156
<header id="app-header">
145
157
<h1>spacedust notifications <span className="demo">demo!</span></h1>
146
158
{user && (
···
200
212
</label>
201
213
</p>
202
214
</div>
203
203
-
</HostContext.Provider>
215
215
+
</>
204
216
)
205
217
}
206
218
+1
-2
atproto-notifications/src/components/Fetch.tsx
···
1
1
import { useContext, useEffect, useState } from 'react';
2
2
-
import { HostContext } from '../context'
3
2
4
3
const loadingDefault = () => (
5
4
<em>Loading…</em>
···
52
51
}
53
52
54
53
export function GetJson({ endpoint, params, ...forFetch }) {
55
55
-
const host = useContext(HostContext);
54
54
+
const host = import.meta.env.VITE_NOTIFICATIONS_HOST;
56
55
const url = new URL(endpoint, host);
57
56
for (let [key, val] of Object.entries(params ?? {})) {
58
57
url.searchParams.append(key, val);
+1
-1
atproto-notifications/src/components/WhoAmI.tsx
···
1
1
import { useRef, useEffect } from 'react';
2
2
3
3
-
export function WhoAmI({ onIdentify, origin = 'http://127.0.0.1:9997' }) {
3
3
+
export function WhoAmI({ onIdentify, origin }) {
4
4
const frameRef = useRef(null);
5
5
6
6
useEffect(() => {
-5
atproto-notifications/src/context.ts
···
1
1
-
import { createContext } from 'react';
2
2
-
3
3
-
const HostContext = createContext(null);
4
4
-
5
5
-
export { HostContext };
+1
gh-pages.sh
···
6
6
git merge --no-ff main -m 'merge main'
7
7
8
8
cd atproto-notifications
9
9
+
export VITE_NOTIFICATIONS_HOST=https://notifications-demo-api.microcosm.blue
9
10
npm run just-build
10
11
cd ..
11
12
+50
-22
server/index.js
···
169
169
'',
170
170
{ ...COOKIE_BASE, expires: new Date(0) },
171
171
));
172
172
-
const getAccountCookie = (req, res, appSecret) => {
172
172
+
const getAccountCookie = (req, res, appSecret, adminDid) => {
173
173
const cookies = cookie.parse(req.headers.cookie ?? '');
174
174
const untrusted = cookies['verified-account'] ?? '';
175
175
const json = cookieSig.unsign(untrusted, appSecret);
···
177
177
clearAccountCookie(res);
178
178
return null;
179
179
}
180
180
+
let did, session;
180
181
try {
181
181
-
const [did, session] = JSON.parse(json);
182
182
-
return [did, session];
182
182
+
[did, session] = JSON.parse(json);
183
183
} catch (e) {
184
184
console.warn('validated account cookie but failed to parse json', e);
185
185
clearAccountCookie(res);
186
186
return null;
187
187
}
188
188
+
189
189
+
// not yet public!!
190
190
+
if (!did || did !== adminDid) {
191
191
+
res.setHeader('Content-Type', 'application/json');
192
192
+
res.writeHead(403);
193
193
+
clearAccountCookie(res).end(JSON.stringify({
194
194
+
reason: 'the spacedust notifications demo isn\'t public yet!',
195
195
+
}));
196
196
+
throw new Error('unauthorized');
197
197
+
}
198
198
+
199
199
+
return [did, session, did && (did === adminDid)];
188
200
};
189
201
190
202
// never EVER allow user-controllable input into fname (or just fix the path joining)
···
209
221
const handleIndex = handleFile('index.html', 'text/html');
210
222
const handleServiceWorker = handleFile('service-worker.js', 'application/javascript');
211
223
212
212
-
const handleVerify = async (db, req, res, jwks, appSecret) => {
224
224
+
const handleHello = async (db, req, res, secrets, whoamiHost, adminDid) => {
225
225
+
const resBase = { webPushPublicKey: secrets.pushKeys.publicKey, whoamiHost };
226
226
+
res.setHeader('Content-Type', 'application/json');
227
227
+
let info = getAccountCookie(req, res, secrets.appSecret, adminDid);
228
228
+
if (info) {
229
229
+
const [did, _session, isAdmin] = info;
230
230
+
const role = isAdmin ? 'admin' : 'public';
231
231
+
res
232
232
+
.setHeader('Content-Type', 'application/json')
233
233
+
.writeHead(200)
234
234
+
.end(JSON.stringify({ ...resBase, role, did }));
235
235
+
} else {
236
236
+
res
237
237
+
.setHeader('Content-Type', 'application/json')
238
238
+
.writeHead(200)
239
239
+
.end(JSON.stringify({ ...resBase, role: 'anonymous' }));
240
240
+
}
241
241
+
};
242
242
+
243
243
+
const handleVerify = async (db, req, res, whoamiHost, appSecret) => {
244
244
+
const jwks = jose.createRemoteJWKSet(new URL(`${whoamiHost}/.well-known/jwks.json`));
213
245
const body = await getRequesBody(req);
214
246
const { token } = JSON.parse(body);
215
247
let did;
···
226
258
};
227
259
228
260
const handleSubscribe = async (db, req, res, appSecret, adminDid) => {
229
229
-
let info = getAccountCookie(req, res, appSecret);
261
261
+
let info = getAccountCookie(req, res, appSecret, adminDid);
230
262
if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' }));
231
231
-
const [did, session] = info;
232
232
-
233
233
-
// not yet public!!
234
234
-
if (did !== adminDid) {
235
235
-
res.setHeader('Content-Type', 'application/json');
236
236
-
res.writeHead(403);
237
237
-
238
238
-
return clearAccountCookie(res).end(JSON.stringify({
239
239
-
reason: 'the spacedust notifications demo isn\'t public yet!',
240
240
-
}));
241
241
-
}
242
242
-
263
263
+
const [did, session, _isAdmin] = info;
243
264
const body = await getRequesBody(req);
244
265
const { sub } = JSON.parse(body);
245
266
// addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG)
···
247
268
updateSubs(db);
248
269
res.setHeader('Content-Type', 'application/json');
249
270
res.writeHead(201);
250
250
-
res.end('{"oh": "hi"}');
271
271
+
res.end(JSON.stringify({ sup: 'hi' }));
251
272
};
252
273
253
253
-
const requestListener = (secrets, jwks, db, adminDid) => (req, res) => {
274
274
+
const requestListener = (secrets, whoamiHost, db, adminDid) => (req, res) => {
254
275
if (req.method === 'GET' && req.url === '/') {
255
276
return handleIndex(req, res, { PUBKEY: secrets.pushKeys.publicKey });
256
277
}
···
258
279
return handleServiceWorker(req, res, { PUBKEY: secrets.pushKeys.publicKey });
259
280
}
260
281
282
282
+
if (req.method === 'OPTIONS' && req.url === '/hello') {
283
283
+
return res.writeHead(204, CORS_PERMISSIVE(req)).end();
284
284
+
}
285
285
+
if (req.method === 'GET' && req.url === '/hello') {
286
286
+
res.setHeaders(new Headers(CORS_PERMISSIVE(req)));
287
287
+
return handleHello(db, req, res, secrets, whoamiHost, adminDid);
288
288
+
}
289
289
+
261
290
if (req.method === 'OPTIONS' && req.url === '/verify') {
262
291
// TODO: probably restrict the origin
263
292
return res.writeHead(204, CORS_PERMISSIVE(req)).end();
264
293
}
265
294
if (req.method === 'POST' && req.url === '/verify') {
266
295
res.setHeaders(new Headers(CORS_PERMISSIVE(req)));
267
267
-
return handleVerify(db, req, res, jwks, secrets.appSecret);
296
296
+
return handleVerify(db, req, res, whoamiHost, secrets.appSecret);
268
297
}
269
298
270
299
if (req.method === 'OPTIONS' && req.url === '/subscribe') {
···
293
322
);
294
323
295
324
const whoamiHost = env.WHOAMI_HOST ?? 'https://who-am-i.microcosm.blue';
296
296
-
const jwks = jose.createRemoteJWKSet(new URL(`${whoamiHost}/.well-known/jwks.json`));
297
325
298
326
const dbFilename = env.DB_FILE ?? './db.sqlite3';
299
327
const initDb = process.argv.includes('--init-db');
···
307
335
const port = parseInt(env.PORT ?? 8000, 10);
308
336
309
337
http
310
310
-
.createServer(requestListener(secrets, jwks, db, adminDid))
338
338
+
.createServer(requestListener(secrets, whoamiHost, db, adminDid))
311
339
.listen(port, host, () => console.log(`listening at http://${host}:${port}`));
312
340
};
313
341