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 101 // Redirect to the homepage 102 return res.redirect('/') 103 } catch (err) { 104 ctx.logger.error({ err }, 'oauth callback failed') 105 return res.redirect('/?error') 106 } 107 }), 108 ) 109 110 // Login page 111 router.get( 112 '/login', 113 handler((req: Request, res: Response) => { 114 res.type('html').send(page(login({}))) 115 }), 116 ) 117 118 // Login handler 119 router.post( 120 '/login', 121 express.urlencoded({ extended: true }), 122 handler(async (req: Request, res: Response) => { 123 const input = ifString(req.body.input) 124 125 // Validate 126 if (!input) { 127 res.type('html').send(page(login({ error: 'invalid input' }))) 128 return 129 } 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 const error = ifString(req.query.error) 206 207 // If the user is signed in, get an agent which communicates with their server 208 const agent = await getSessionAgent(req, res, ctx) 209 210 // Fetch data stored in our SQLite 211 const statuses = await ctx.db 212 .selectFrom('status') 213 .selectAll() 214 .orderBy('indexedAt', 'desc') 215 .limit(10) 216 .execute() 217 const myStatus = agent 218 ? await ctx.db 219 .selectFrom('status') 220 .selectAll() 221 .where('authorDid', '=', agent.assertDid) 222 .orderBy('indexedAt', 'desc') 223 .executeTakeFirst() 224 : undefined 225 226 // Map (unique) user DIDs to their domain-name handles 227 const uniqueDids = [...new Set(statuses.map((s) => s.authorDid))] 228 229 const didHandleMap: Record<string, string | undefined> = 230 Object.fromEntries( 231 await Promise.all( 232 uniqueDids.map((did) => 233 ctx.identityResolver.resolve(did).then( 234 (r) => [did, r.handle], 235 () => [did, undefined], 236 ), 237 ), 238 ), 239 ) 240 241 if (!agent) { 242 // Serve the logged-out view 243 res.type('html').send(page(home({ error, statuses, didHandleMap }))) 244 return 245 } 246 247 // Fetch additional information about the logged-in user 248 const profileResponse = await agent.com.atproto.repo 249 .getRecord({ 250 repo: agent.assertDid, 251 collection: 'app.bsky.actor.profile', 252 rkey: 'self', 253 }) 254 .catch(() => undefined) 255 256 const profileRecord = profileResponse?.data 257 258 const profile = 259 profileRecord && 260 Profile.isRecord(profileRecord.value) && 261 Profile.validateRecord(profileRecord.value).success 262 ? profileRecord.value 263 : {} 264 265 // Serve the logged-in view 266 res 267 .type('html') 268 .send(page(home({ error, statuses, didHandleMap, profile, myStatus }))) 269 }), 270 ) 271 272 // "Set status" handler 273 router.post( 274 '/status', 275 express.urlencoded({ extended: true }), 276 handler(async (req: Request, res: Response) => { 277 // If the user is signed in, get an agent which communicates with their server 278 const agent = await getSessionAgent(req, res, ctx) 279 if (!agent) { 280 res.redirect(`/login}`) 281 return 282 } 283 284 try { 285 const status = req.body?.status 286 if (typeof status !== 'string' || !STATUS_OPTIONS.includes(status)) { 287 throw new Error('Invalid status') 288 } 289 290 // Construct & validate their status record 291 const rkey = TID.nextStr() 292 const record = { 293 $type: 'xyz.statusphere.status', 294 status, 295 createdAt: new Date().toISOString(), 296 } 297 298 if (!Status.validateRecord(record).success) { 299 res.status(400).type('html').send('<h1>Error: Invalid status</h1>') 300 return 301 } 302 303 // Write the status record to the user's repository 304 let uri 305 try { 306 const res = await agent.com.atproto.repo.putRecord({ 307 repo: agent.assertDid, 308 collection: 'xyz.statusphere.status', 309 rkey, 310 record, 311 validate: false, 312 }) 313 uri = res.data.uri 314 } catch (err) { 315 ctx.logger.error({ err }, 'failed to write record') 316 res 317 .status(500) 318 .type('html') 319 .send('<h1>Error: Failed to write record</h1>') 320 return 321 } 322 323 try { 324 // Optimistically update our SQLite 325 // This isn't strictly necessary because the write event will be 326 // handled in #/firehose/ingestor.ts, but it ensures that future reads 327 // will be up-to-date after this method finishes. 328 await ctx.db 329 .insertInto('status') 330 .values({ 331 uri, 332 authorDid: agent.assertDid, 333 status: record.status, 334 createdAt: record.createdAt, 335 indexedAt: new Date().toISOString(), 336 }) 337 .execute() 338 } catch (err) { 339 ctx.logger.warn( 340 { err }, 341 'failed to update computed view; ignoring as it should be caught by the firehose', 342 ) 343 } 344 345 res.redirect('/') 346 } catch (err) { 347 const message = err instanceof Error ? err.message : 'Unknown error' 348 res.redirect(`/?error=${encodeURIComponent(message)}`) 349 } 350 }), 351 ) 352 353 return router 354}