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