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 { formHandler, 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(async (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(async (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(async (req: Request, res: Response) => { 95 res.type('html').send(page(login({}))) 96 }), 97 ) 98 99 // Login handler 100 app.post( 101 '/login', 102 formHandler(async (req: Request, res: Response) => { 103 // Validate 104 const handle = req.body?.handle 105 if (typeof handle !== 'string' || !isValidHandle(handle)) { 106 return void res 107 .type('html') 108 .send(page(login({ error: 'invalid handle' }))) 109 } 110 111 // Initiate the OAuth flow 112 try { 113 const url = await ctx.oauthClient.authorize(handle, { 114 scope: 'atproto transition:generic', 115 }) 116 res.redirect(url.toString()) 117 } catch (err) { 118 ctx.logger.error({ err }, 'oauth authorize failed') 119 res.type('html').send( 120 page( 121 login({ 122 error: 123 err instanceof OAuthResolverError 124 ? err.message 125 : "couldn't initiate login", 126 }), 127 ), 128 ) 129 } 130 }), 131 ) 132 133 // Logout handler 134 app.post( 135 '/logout', 136 formHandler(async (req: Request, res: Response) => { 137 const session = await getIronSession<Session>(req, res, { 138 cookieName: 'sid', 139 password: env.COOKIE_SECRET, 140 }) 141 await session.destroy() 142 return res.redirect('/') 143 }), 144 ) 145 146 // Homepage 147 app.get( 148 '/', 149 handler(async (req: Request, res: Response) => { 150 // If the user is signed in, get an agent which communicates with their server 151 const agent = await getSessionAgent(req, res, ctx) 152 153 // Fetch data stored in our SQLite 154 const statuses = await ctx.db 155 .selectFrom('status') 156 .selectAll() 157 .orderBy('indexedAt', 'desc') 158 .limit(10) 159 .execute() 160 const myStatus = agent 161 ? await ctx.db 162 .selectFrom('status') 163 .selectAll() 164 .where('authorDid', '=', agent.assertDid) 165 .orderBy('indexedAt', 'desc') 166 .executeTakeFirst() 167 : undefined 168 169 // Map (unique) user DIDs to their domain-name handles 170 const uniqueDids = [...new Set(statuses.map((s) => s.authorDid))] 171 172 const didHandleMap: Record<string, string | undefined> = 173 Object.fromEntries( 174 await Promise.all( 175 uniqueDids.map((did) => 176 ctx.identityResolver.resolve(did).then( 177 (r) => [did, r.handle], 178 () => [did, undefined], 179 ), 180 ), 181 ), 182 ) 183 184 if (!agent) { 185 // Serve the logged-out view 186 return void res 187 .type('html') 188 .send(page(home({ statuses, didHandleMap }))) 189 } 190 191 // Fetch additional information about the logged-in user 192 const profileResponse = await agent.com.atproto.repo 193 .getRecord({ 194 repo: agent.assertDid, 195 collection: 'app.bsky.actor.profile', 196 rkey: 'self', 197 }) 198 .catch(() => undefined) 199 200 const profileRecord = profileResponse?.data 201 202 const profile = 203 profileRecord && 204 Profile.isRecord(profileRecord.value) && 205 Profile.validateRecord(profileRecord.value).success 206 ? profileRecord.value 207 : {} 208 209 // Serve the logged-in view 210 res.type('html').send( 211 page( 212 home({ 213 statuses, 214 didHandleMap, 215 profile, 216 myStatus, 217 }), 218 ), 219 ) 220 }), 221 ) 222 223 // "Set status" handler 224 app.post( 225 '/status', 226 formHandler(async (req: Request, res: Response) => { 227 // If the user is signed in, get an agent which communicates with their server 228 const agent = await getSessionAgent(req, res, ctx) 229 if (!agent) { 230 return void res 231 .status(401) 232 .type('html') 233 .send('<h1>Error: Session required</h1>') 234 } 235 236 // Construct & validate their status record 237 const rkey = TID.nextStr() 238 const record = { 239 $type: 'xyz.statusphere.status', 240 status: req.body?.status, 241 createdAt: new Date().toISOString(), 242 } 243 if (!Status.validateRecord(record).success) { 244 return void res 245 .status(400) 246 .type('html') 247 .send('<h1>Error: Invalid status</h1>') 248 } 249 250 let uri 251 try { 252 // Write the status record to the user's repository 253 const res = await agent.com.atproto.repo.putRecord({ 254 repo: agent.assertDid, 255 collection: 'xyz.statusphere.status', 256 rkey, 257 record, 258 validate: false, 259 }) 260 uri = res.data.uri 261 } catch (err) { 262 ctx.logger.warn({ err }, 'failed to write record') 263 return void res 264 .status(500) 265 .type('html') 266 .send('<h1>Error: Failed to write record</h1>') 267 } 268 269 try { 270 // Optimistically update our SQLite 271 // This isn't strictly necessary because the write event will be 272 // handled in #/firehose/ingestor.ts, but it ensures that future reads 273 // will be up-to-date after this method finishes. 274 await ctx.db 275 .insertInto('status') 276 .values({ 277 uri, 278 authorDid: agent.assertDid, 279 status: record.status, 280 createdAt: record.createdAt, 281 indexedAt: new Date().toISOString(), 282 }) 283 .execute() 284 } catch (err) { 285 ctx.logger.warn( 286 { err }, 287 'failed to update computed view; ignoring as it should be caught by the firehose', 288 ) 289 } 290 291 return res.redirect('/') 292 }), 293 ) 294 295 return app 296}