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