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 type { 7 IncomingMessage, 8 RequestListener, 9 ServerResponse, 10} from 'node:http' 11import path from 'node:path' 12 13import type { AppContext } from '#/context' 14import { env } from '#/env' 15import * as Profile from '#/lexicon/types/app/bsky/actor/profile' 16import * as Status from '#/lexicon/types/xyz/statusphere/status' 17import { handler } from '#/lib/http' 18import { ifString } from '#/lib/util' 19import { page } from '#/lib/view' 20import { home } from '#/pages/home' 21import { login } from '#/pages/login' 22 23// Max age, in seconds, for static routes and assets 24const MAX_AGE = env.NODE_ENV === 'production' ? 60 : 0 25 26type Session = { did?: string } 27 28// Helper function to get the Atproto Agent for the active session 29async function getSessionAgent( 30 req: IncomingMessage, 31 res: ServerResponse, 32 ctx: AppContext, 33) { 34 res.setHeader('Vary', 'Cookie') 35 36 const session = await getIronSession<Session>(req, res, { 37 cookieName: 'sid', 38 password: env.COOKIE_SECRET, 39 }) 40 if (!session.did) return null 41 42 // This page is dynamic and should not be cached publicly 43 res.setHeader('cache-control', `max-age=${MAX_AGE}, private`) 44 45 try { 46 const oauthSession = await ctx.oauthClient.restore(session.did) 47 return oauthSession ? new Agent(oauthSession) : null 48 } catch (err) { 49 ctx.logger.warn({ err }, 'oauth restore failed') 50 await session.destroy() 51 return null 52 } 53} 54 55export const createRouter = (ctx: AppContext): RequestListener => { 56 const router = express() 57 58 // Static assets 59 router.use( 60 '/public', 61 express.static(path.join(__dirname, 'pages', 'public'), { 62 maxAge: MAX_AGE * 1000, 63 }), 64 ) 65 66 // OAuth metadata 67 router.get( 68 '/oauth-client-metadata.json', 69 handler((req, res) => { 70 res.setHeader('cache-control', `max-age=${MAX_AGE}, public`) 71 res.json(ctx.oauthClient.clientMetadata) 72 }), 73 ) 74 75 // Public keys 76 router.get( 77 '/.well-known/jwks.json', 78 handler((req, res) => { 79 res.setHeader('cache-control', `max-age=${MAX_AGE}, public`) 80 res.json(ctx.oauthClient.jwks) 81 }), 82 ) 83 84 // OAuth callback to complete session creation 85 router.get( 86 '/oauth/callback', 87 handler(async (req, res) => { 88 res.setHeader('cache-control', 'no-store') 89 90 const params = new URLSearchParams(req.originalUrl.split('?')[1]) 91 try { 92 // Load the session cookie 93 const session = await getIronSession<Session>(req, res, { 94 cookieName: 'sid', 95 password: env.COOKIE_SECRET, 96 }) 97 98 // If the user is already signed in, destroy the old credentials 99 if (session.did) { 100 try { 101 const oauthSession = await ctx.oauthClient.restore(session.did) 102 if (oauthSession) oauthSession.signOut() 103 } catch (err) { 104 ctx.logger.warn({ err }, 'oauth restore failed') 105 } 106 } 107 108 // Complete the OAuth flow 109 const oauth = await ctx.oauthClient.callback(params) 110 111 // Update the session cookie 112 session.did = oauth.session.did 113 await session.save() 114 } catch (err) { 115 ctx.logger.error({ err }, 'oauth callback failed') 116 return res.redirect('/?error') 117 } 118 return res.redirect('/') 119 }), 120 ) 121 122 // Login page 123 router.get( 124 '/login', 125 handler(async (req, res) => { 126 res.setHeader('cache-control', `max-age=${MAX_AGE}, public`) 127 res.type('html').send(page(login({}))) 128 }), 129 ) 130 131 // Login handler 132 router.post( 133 '/login', 134 express.urlencoded(), 135 handler(async (req, res) => { 136 // Never store this route 137 res.setHeader('cache-control', 'no-store') 138 139 // Initiate the OAuth flow 140 try { 141 // Validate input: can be a handle, a DID or a service URL (PDS). 142 const input = ifString(req.body.input) 143 if (!input) { 144 throw new Error('Invalid input') 145 } 146 147 // Initiate the OAuth flow 148 const url = await ctx.oauthClient.authorize(input, { 149 scope: 'atproto transition:generic', 150 }) 151 152 res.redirect(url.toString()) 153 } catch (err) { 154 ctx.logger.error({ err }, 'oauth authorize failed') 155 156 const error = err instanceof Error ? err.message : 'unexpected error' 157 158 return res.type('html').send(page(login({ error }))) 159 } 160 }), 161 ) 162 163 // Signup 164 router.get( 165 '/signup', 166 handler(async (req, res) => { 167 res.setHeader('cache-control', `max-age=${MAX_AGE}, public`) 168 169 try { 170 const service = env.PDS_URL ?? 'https://bsky.social' 171 const url = await ctx.oauthClient.authorize(service, { 172 scope: 'atproto transition:generic', 173 }) 174 res.redirect(url.toString()) 175 } catch (err) { 176 ctx.logger.error({ err }, 'oauth authorize failed') 177 res.type('html').send( 178 page( 179 login({ 180 error: 181 err instanceof OAuthResolverError 182 ? err.message 183 : "couldn't initiate login", 184 }), 185 ), 186 ) 187 } 188 }), 189 ) 190 191 // Logout handler 192 router.post( 193 '/logout', 194 handler(async (req, res) => { 195 // Never store this route 196 res.setHeader('cache-control', 'no-store') 197 198 const session = await getIronSession<Session>(req, res, { 199 cookieName: 'sid', 200 password: env.COOKIE_SECRET, 201 }) 202 203 // Revoke credentials on the server 204 if (session.did) { 205 try { 206 const oauthSession = await ctx.oauthClient.restore(session.did) 207 if (oauthSession) await oauthSession.signOut() 208 } catch (err) { 209 ctx.logger.warn({ err }, 'Failed to revoke credentials') 210 } 211 } 212 213 session.destroy() 214 215 return res.redirect('/') 216 }), 217 ) 218 219 // Homepage 220 router.get( 221 '/', 222 handler(async (req, res) => { 223 // If the user is signed in, get an agent which communicates with their server 224 const agent = await getSessionAgent(req, res, ctx) 225 226 // Fetch data stored in our SQLite 227 const statuses = await ctx.db 228 .selectFrom('status') 229 .selectAll() 230 .orderBy('indexedAt', 'desc') 231 .limit(10) 232 .execute() 233 const myStatus = agent 234 ? await ctx.db 235 .selectFrom('status') 236 .selectAll() 237 .where('authorDid', '=', agent.assertDid) 238 .orderBy('indexedAt', 'desc') 239 .executeTakeFirst() 240 : undefined 241 242 // Map user DIDs to their domain-name handles 243 const didHandleMap = await ctx.resolver.resolveDidsToHandles( 244 statuses.map((s) => s.authorDid), 245 ) 246 247 if (!agent) { 248 // Serve the logged-out view 249 return res.type('html').send(page(home({ statuses, didHandleMap }))) 250 } 251 252 // Fetch additional information about the logged-in user 253 const profileResponse = await agent.com.atproto.repo 254 .getRecord({ 255 repo: agent.assertDid, 256 collection: 'app.bsky.actor.profile', 257 rkey: 'self', 258 }) 259 .catch(() => undefined) 260 261 const profileRecord = profileResponse?.data 262 263 const profile = 264 profileRecord && 265 Profile.isRecord(profileRecord.value) && 266 Profile.validateRecord(profileRecord.value).success 267 ? profileRecord.value 268 : {} 269 270 // Serve the logged-in view 271 res 272 .type('html') 273 .send(page(home({ statuses, didHandleMap, profile, myStatus }))) 274 }), 275 ) 276 277 // "Set status" handler 278 router.post( 279 '/status', 280 express.urlencoded(), 281 handler(async (req, res) => { 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 return res 286 .status(401) 287 .type('html') 288 .send('<h1>Error: Session required</h1>') 289 } 290 291 // Construct their status record 292 const record = { 293 $type: 'xyz.statusphere.status', 294 status: req.body?.status, 295 createdAt: new Date().toISOString(), 296 } 297 298 // Make sure the record generated from the input is valid 299 if (!Status.validateRecord(record).success) { 300 return res 301 .status(400) 302 .type('html') 303 .send('<h1>Error: Invalid status</h1>') 304 } 305 306 let uri 307 try { 308 // Write the status record to the user's repository 309 const res = await agent.com.atproto.repo.putRecord({ 310 repo: agent.assertDid, 311 collection: 'xyz.statusphere.status', 312 rkey: TID.nextStr(), 313 record, 314 validate: false, 315 }) 316 uri = res.data.uri 317 } catch (err) { 318 ctx.logger.warn({ err }, 'failed to write record') 319 return res 320 .status(500) 321 .type('html') 322 .send('<h1>Error: Failed to write record</h1>') 323 } 324 325 try { 326 // Optimistically update our SQLite 327 // This isn't strictly necessary because the write event will be 328 // handled in #/firehose/ingestor.ts, but it ensures that future reads 329 // will be up-to-date after this method finishes. 330 await ctx.db 331 .insertInto('status') 332 .values({ 333 uri, 334 authorDid: agent.assertDid, 335 status: record.status, 336 createdAt: record.createdAt, 337 indexedAt: new Date().toISOString(), 338 }) 339 .execute() 340 } catch (err) { 341 ctx.logger.warn( 342 { err }, 343 'failed to update computed view; ignoring as it should be caught by the firehose', 344 ) 345 } 346 347 return res.redirect('/') 348 }), 349 ) 350 351 return router 352}