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