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