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