Scratch space for learning atproto app development
1import { Agent } from '@atproto/api' 2import { TID } from '@atproto/common' 3import { OAuthResolverError } from '@atproto/oauth-client-node' 4import express, { Request, Response } from 'express' 5import { getIronSession } from 'iron-session' 6import assert from 'node:assert' 7import type { 8 IncomingMessage, 9 RequestListener, 10 ServerResponse, 11} from 'node:http' 12import path from 'node:path' 13 14import type { AppContext } from '#/context' 15import * as Profile from '#/lexicon/types/app/bsky/actor/profile' 16import * as Status from '#/lexicon/types/xyz/statusphere/status' 17import { env } from '#/env' 18import { handler } from '#/lib/http' 19import { page } from '#/lib/view' 20import { home, STATUS_OPTIONS } from '#/pages/home' 21import { login } from '#/pages/login' 22import { ifString } from './lib/util' 23 24type Session = { did?: string } 25 26// Helper function to get the Atproto Agent for the active session 27async function getSessionAgent( 28 req: IncomingMessage, 29 res: ServerResponse, 30 ctx: AppContext, 31) { 32 const session = await getIronSession<Session>(req, res, { 33 cookieName: 'sid', 34 password: env.COOKIE_SECRET, 35 }) 36 if (!session.did) return null 37 try { 38 // force rotating the credentials if the request has a no-cache header 39 const refresh = req.headers['cache-control']?.includes('no-cache') || 'auto' 40 41 const oauthSession = await ctx.oauthClient.restore(session.did, refresh) 42 return oauthSession ? new Agent(oauthSession) : null 43 } catch (err) { 44 ctx.logger.warn({ err }, 'oauth restore failed') 45 await session.destroy() 46 return null 47 } 48} 49 50export function createRouter(ctx: AppContext): RequestListener { 51 const app = express() 52 53 // Static assets 54 app.use('/public', express.static(path.join(__dirname, 'pages', 'public'))) 55 56 // OAuth metadata 57 app.get( 58 '/oauth-client-metadata.json', 59 handler((req: Request, res: Response) => { 60 res.json(ctx.oauthClient.clientMetadata) 61 }), 62 ) 63 64 // Public keys 65 app.get( 66 '/.well-known/jwks.json', 67 handler((req: Request, res: Response) => { 68 res.json(ctx.oauthClient.jwks) 69 }), 70 ) 71 72 // OAuth callback to complete session creation 73 app.get( 74 '/oauth/callback', 75 handler(async (req: Request, res: Response) => { 76 const params = new URLSearchParams(req.originalUrl.split('?')[1]) 77 try { 78 // Load the session cookie 79 const session = await getIronSession<Session>(req, res, { 80 cookieName: 'sid', 81 password: env.COOKIE_SECRET, 82 }) 83 84 // If the user is already signed in, destroy the old credentials 85 if (session.did) { 86 try { 87 const oauthSession = await ctx.oauthClient.restore(session.did) 88 if (oauthSession) oauthSession.signOut() 89 } catch (err) { 90 ctx.logger.warn({ err }, 'oauth restore failed') 91 } 92 } 93 94 // Complete the OAuth flow 95 const oauth = await ctx.oauthClient.callback(params) 96 97 // Update the session cookie 98 session.did = oauth.session.did 99 await session.save() 100 101 if (oauth.state?.startsWith('status:')) { 102 const status = oauth.state.slice(7) 103 const agent = new Agent(oauth.session) 104 try { 105 await updateStatus(agent, status) 106 } catch (err) { 107 const message = err instanceof Error ? err.message : 'Unknown error' 108 return res.redirect(`/?error=${encodeURIComponent(message)}`) 109 } 110 } 111 112 // Redirect to the homepage 113 return res.redirect('/') 114 } catch (err) { 115 ctx.logger.error({ err }, 'oauth callback failed') 116 return res.redirect('/?error') 117 } 118 }), 119 ) 120 121 // Login page 122 app.get( 123 '/login', 124 handler((req: Request, res: Response) => { 125 const state = ifString(req.query.state) 126 res.type('html').send(page(login({ state }))) 127 }), 128 ) 129 130 // Login handler 131 app.post( 132 '/login', 133 express.urlencoded({ extended: true }), 134 handler(async (req: Request, res: Response) => { 135 const input = ifString(req.body.input) 136 const state = ifString(req.body.state) 137 138 // Validate 139 if (!input) { 140 return void res 141 .type('html') 142 .send(page(login({ error: 'invalid input' }))) 143 } 144 145 // Initiate the OAuth flow 146 try { 147 const url = await ctx.oauthClient.authorize(input, { 148 scope: 'atproto transition:generic', 149 state, 150 }) 151 res.redirect(url.toString()) 152 } catch (err) { 153 ctx.logger.error({ err }, 'oauth authorize failed') 154 155 const error = 156 err instanceof OAuthResolverError 157 ? err.message 158 : "couldn't initiate login" 159 160 res.type('html').send(page(login({ state, error }))) 161 } 162 }), 163 ) 164 165 // Signup 166 app.get( 167 '/signup', 168 handler(async (req: Request, res: Response) => { 169 try { 170 const service = env.PDS_URL ?? 'https://bsky.social' 171 const url = await ctx.oauthClient.authorize(service, { 172 scope: 'atproto transition:generic', 173 state: ifString(req.query.state), 174 }) 175 res.redirect(url.toString()) 176 } catch (err) { 177 ctx.logger.error({ err }, 'oauth authorize failed') 178 res.type('html').send( 179 page( 180 login({ 181 error: 182 err instanceof OAuthResolverError 183 ? err.message 184 : "couldn't initiate login", 185 }), 186 ), 187 ) 188 } 189 }), 190 ) 191 192 // Logout handler 193 app.post( 194 '/logout', 195 handler(async (req: Request, res: Response) => { 196 const session = await getIronSession<Session>(req, res, { 197 cookieName: 'sid', 198 password: env.COOKIE_SECRET, 199 }) 200 201 // Revoke credentials on the server 202 if (session.did) { 203 try { 204 const oauthSession = await ctx.oauthClient.restore(session.did) 205 if (oauthSession) await oauthSession.signOut() 206 } catch (err) { 207 ctx.logger.warn({ err }, 'Failed to revoke credentials') 208 } 209 } 210 211 session.destroy() 212 213 return res.redirect('/') 214 }), 215 ) 216 217 // Homepage 218 app.get( 219 '/', 220 handler(async (req: Request, res: Response) => { 221 const error = ifString(req.query.error) 222 223 // If the user is signed in, get an agent which communicates with their server 224 const agent = await getSessionAgent(req, res, ctx) 225 226 // Fetch data stored in our SQLite 227 const statuses = await ctx.db 228 .selectFrom('status') 229 .selectAll() 230 .orderBy('indexedAt', 'desc') 231 .limit(10) 232 .execute() 233 const myStatus = agent 234 ? await ctx.db 235 .selectFrom('status') 236 .selectAll() 237 .where('authorDid', '=', agent.assertDid) 238 .orderBy('indexedAt', 'desc') 239 .executeTakeFirst() 240 : undefined 241 242 // Map (unique) user DIDs to their domain-name handles 243 const uniqueDids = [...new Set(statuses.map((s) => s.authorDid))] 244 245 const didHandleMap: Record<string, string | undefined> = 246 Object.fromEntries( 247 await Promise.all( 248 uniqueDids.map((did) => 249 ctx.identityResolver.resolve(did).then( 250 (r) => [did, r.handle], 251 () => [did, undefined], 252 ), 253 ), 254 ), 255 ) 256 257 if (!agent) { 258 // Serve the logged-out view 259 return void res 260 .type('html') 261 .send(page(home({ error, statuses, didHandleMap }))) 262 } 263 264 // Fetch additional information about the logged-in user 265 const profileResponse = await agent.com.atproto.repo 266 .getRecord({ 267 repo: agent.assertDid, 268 collection: 'app.bsky.actor.profile', 269 rkey: 'self', 270 }) 271 .catch(() => undefined) 272 273 const profileRecord = profileResponse?.data 274 275 const profile = 276 profileRecord && 277 Profile.isRecord(profileRecord.value) && 278 Profile.validateRecord(profileRecord.value).success 279 ? profileRecord.value 280 : {} 281 282 // Serve the logged-in view 283 res 284 .type('html') 285 .send(page(home({ error, statuses, didHandleMap, profile, myStatus }))) 286 }), 287 ) 288 289 // "Set status" handler 290 app.post( 291 '/status', 292 express.urlencoded({ extended: true }), 293 handler(async (req: Request, res: Response) => { 294 const status = req.body?.status 295 296 // If the user is signed in, get an agent which communicates with their server 297 const agent = await getSessionAgent(req, res, ctx) 298 if (!agent) { 299 return void res.redirect( 300 `/login?state=status:${encodeURIComponent(status)}`, 301 ) 302 } 303 304 try { 305 await updateStatus(agent, status) 306 return res.redirect('/') 307 } catch (err) { 308 const message = err instanceof Error ? err.message : 'Unknown error' 309 return res.redirect(`/?error=${encodeURIComponent(message)}`) 310 } 311 }), 312 ) 313 314 return app 315 316 async function updateStatus(agent: Agent, status: unknown) { 317 if (typeof status !== 'string' || !STATUS_OPTIONS.includes(status)) { 318 throw new Error('Invalid status') 319 } 320 321 // Construct & validate their status record 322 const rkey = TID.nextStr() 323 const record = { 324 $type: 'xyz.statusphere.status', 325 status, 326 createdAt: new Date().toISOString(), 327 } 328 329 if (!Status.validateRecord(record).success) { 330 throw new Error('Invalid status record') 331 } 332 333 // Write the status record to the user's repository 334 const res = await agent.com.atproto.repo 335 .putRecord({ 336 repo: agent.assertDid, 337 collection: 'xyz.statusphere.status', 338 rkey, 339 record, 340 validate: false, 341 }) 342 .catch((err) => { 343 ctx.logger.error({ err }, 'failed to write record') 344 throw new Error('Failed to write record', { cause: err }) 345 }) 346 347 try { 348 // Optimistically update our SQLite 349 // This isn't strictly necessary because the write event will be 350 // handled in #/firehose/ingestor.ts, but it ensures that future reads 351 // will be up-to-date after this method finishes. 352 await ctx.db 353 .insertInto('status') 354 .values({ 355 uri: res.data.uri, 356 authorDid: agent.assertDid, 357 status: record.status, 358 createdAt: record.createdAt, 359 indexedAt: new Date().toISOString(), 360 }) 361 .execute() 362 } catch (err) { 363 ctx.logger.warn( 364 { err }, 365 'failed to update computed view; ignoring as it should be caught by the firehose', 366 ) 367 } 368 } 369}