admin-only access

Changed files
+42 -13
atproto-notifications
src
server
+20 -7
atproto-notifications/src/App.tsx
··· 14 </div> 15 ); 16 17 - function requestPermission(host, setAsking) { 18 return async () => { 19 setAsking(true); 20 let err; ··· 27 body: JSON.stringify({ sub }), 28 credentials: 'include', 29 }); 30 - if (!res.ok) throw res; 31 } catch (e) { 32 err = e; 33 } 34 setAsking(false); ··· 68 const [user, setUser] = useLocalStorage('spacedust-notif-user', null); 69 const [verif, setVerif] = useState(null); 70 const [asking, setAsking] = useState(false); 71 72 const onIdentify = useCallback(async details => { 73 setVerif('verifying'); ··· 102 content = <><p>Sorry, failed to verify that identity. please let us know!</p>{content}</>; 103 } 104 } 105 - } else if (notifPerm !== 'granted') { 106 content = ( 107 <> 108 <h3>Step 2: Allow notifications</h3> 109 <p>To show notifications we need permission:</p> 110 <p> 111 <button 112 - onClick={requestPermission(host, setAsking)} 113 disabled={asking} 114 > 115 {asking ? <>Requesting&hellip;</> : <>Request permission</>} ··· 117 </p> 118 {notifPerm === 'denied' ? ( 119 <p className="detail">Notification permission was denied. You may need to clear the browser setting to try again.</p> 120 - ) : ( 121 - <p className="detail">You can revoke this any time</p> 122 - )} 123 </> 124 ); 125 } else {
··· 14 </div> 15 ); 16 17 + function requestPermission(host, setAsking, setPermissionError) { 18 return async () => { 19 setAsking(true); 20 let err; ··· 27 body: JSON.stringify({ sub }), 28 credentials: 'include', 29 }); 30 + if (!res.ok) { 31 + let content; 32 + try { 33 + content = (await res.json()).reason; 34 + } catch (_) { 35 + content = await res.text(); 36 + } 37 + throw content; 38 + } 39 } catch (e) { 40 + setPermissionError(e); 41 err = e; 42 } 43 setAsking(false); ··· 77 const [user, setUser] = useLocalStorage('spacedust-notif-user', null); 78 const [verif, setVerif] = useState(null); 79 const [asking, setAsking] = useState(false); 80 + const [permissionError, setPermissionError] = useState(null); 81 82 const onIdentify = useCallback(async details => { 83 setVerif('verifying'); ··· 112 content = <><p>Sorry, failed to verify that identity. please let us know!</p>{content}</>; 113 } 114 } 115 + } else if (permissionError !== null || notifPerm !== 'granted') { 116 content = ( 117 <> 118 <h3>Step 2: Allow notifications</h3> 119 <p>To show notifications we need permission:</p> 120 <p> 121 <button 122 + onClick={requestPermission(host, setAsking, setPermissionError)} 123 disabled={asking} 124 > 125 {asking ? <>Requesting&hellip;</> : <>Request permission</>} ··· 127 </p> 128 {notifPerm === 'denied' ? ( 129 <p className="detail">Notification permission was denied. You may need to clear the browser setting to try again.</p> 130 + ) : permissionError ? ( 131 + <p className="detail">Sorry, something went wrong: {permissionError}</p> 132 + ) : ( 133 + <p className="detail">You can revoke this any time</p> 134 + ) 135 + } 136 </> 137 ); 138 } else {
+5 -2
server/db.js
··· 37 38 this.#stmt_insert_account = db.prepare( 39 `insert into accounts (did) 40 - values (?)`); 41 42 this.#stmt_get_account = db.prepare( 43 `select a.first_seen, ··· 53 54 this.#stmt_insert_push_sub = db.prepare( 55 `insert into push_subs (account_did, session, subscription) 56 - values (?, ?, ?)`); 57 58 this.#stmt_get_all_sub_dids = db.prepare( 59 `select distinct account_did
··· 37 38 this.#stmt_insert_account = db.prepare( 39 `insert into accounts (did) 40 + values (?) 41 + on conflict do nothing`); 42 43 this.#stmt_get_account = db.prepare( 44 `select a.first_seen, ··· 54 55 this.#stmt_insert_push_sub = db.prepare( 56 `insert into push_subs (account_did, session, subscription) 57 + values (?, ?, ?) 58 + on conflict do update 59 + set subscription = excluded.subscription`); 60 61 this.#stmt_get_all_sub_dids = db.prepare( 62 `select distinct account_did
+17 -4
server/index.js
··· 225 return res.writeHead(200).end('okayyyy'); 226 }; 227 228 - const handleSubscribe = async (db, req, res, appSecret) => { 229 let info = getAccountCookie(req, res, appSecret); 230 if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 231 const [did, session] = info; 232 233 const body = await getRequesBody(req); 234 const { sub } = JSON.parse(body); 235 // addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG) ··· 240 res.end('{"oh": "hi"}'); 241 }; 242 243 - const requestListener = (secrets, jwks, db) => (req, res) => { 244 if (req.method === 'GET' && req.url === '/') { 245 return handleIndex(req, res, { PUBKEY: secrets.pushKeys.publicKey }); 246 } ··· 263 } 264 if (req.method === 'POST' && req.url === '/subscribe') { 265 res.setHeaders(new Headers(CORS_PERMISSIVE(req))); 266 - return handleSubscribe(db, req, res, secrets.appSecret); 267 } 268 269 res.writeHead(200); ··· 271 } 272 273 const main = env => { 274 if (!env.SECRETS_FILE) throw new Error('SECRETS_FILE is required to run'); 275 const secrets = getOrCreateSecrets(env.SECRETS_FILE); 276 webpush.setVapidDetails( ··· 294 const port = parseInt(env.PORT ?? 8000, 10); 295 296 http 297 - .createServer(requestListener(secrets, jwks, db)) 298 .listen(port, host, () => console.log(`listening at http://${host}:${port}`)); 299 }; 300
··· 225 return res.writeHead(200).end('okayyyy'); 226 }; 227 228 + const handleSubscribe = async (db, req, res, appSecret, adminDid) => { 229 let info = getAccountCookie(req, res, appSecret); 230 if (!info) return res.writeHead(400).end(JSON.stringify({ reason: 'failed to verify cookie signature' })); 231 const [did, session] = info; 232 233 + // not yet public!! 234 + if (did !== adminDid) { 235 + res.setHeader('Content-Type', 'application/json'); 236 + res.writeHead(403); 237 + 238 + return clearAccountCookie(res).end(JSON.stringify({ 239 + reason: 'the spacedust notifications demo isn\'t public yet!', 240 + })); 241 + } 242 + 243 const body = await getRequesBody(req); 244 const { sub } = JSON.parse(body); 245 // addSub('did:plc:z72i7hdynmk6r22z27h6tvur', sub); // DELETEME @bsky.app (DEBUG) ··· 250 res.end('{"oh": "hi"}'); 251 }; 252 253 + const requestListener = (secrets, jwks, db, adminDid) => (req, res) => { 254 if (req.method === 'GET' && req.url === '/') { 255 return handleIndex(req, res, { PUBKEY: secrets.pushKeys.publicKey }); 256 } ··· 273 } 274 if (req.method === 'POST' && req.url === '/subscribe') { 275 res.setHeaders(new Headers(CORS_PERMISSIVE(req))); 276 + return handleSubscribe(db, req, res, secrets.appSecret, adminDid); 277 } 278 279 res.writeHead(200); ··· 281 } 282 283 const main = env => { 284 + if (!env.ADMIN_DID) throw new Error('ADMIN_DID is required to run'); 285 + const adminDid = env.ADMIN_DID; 286 + 287 if (!env.SECRETS_FILE) throw new Error('SECRETS_FILE is required to run'); 288 const secrets = getOrCreateSecrets(env.SECRETS_FILE); 289 webpush.setVapidDetails( ··· 307 const port = parseInt(env.PORT ?? 8000, 10); 308 309 http 310 + .createServer(requestListener(secrets, jwks, db, adminDid)) 311 .listen(port, host, () => console.log(`listening at http://${host}:${port}`)); 312 }; 313