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