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