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