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 114 await session.save() 115 } catch (err) { 116 ctx.logger.error({ err }, 'oauth callback failed') 117 } 118 119 return res.redirect('/') 120 }), 121 ) 122 123 // Login page 124 router.get( 125 '/login', 126 handler(async (req, res) => { 127 res.setHeader('cache-control', `max-age=${MAX_AGE}, public`) 128 res.type('html').send(page(login({}))) 129 }), 130 ) 131 132 // Login handler 133 router.post( 134 '/login', 135 express.urlencoded(), 136 handler(async (req, res) => { 137 // Never store this route 138 res.setHeader('cache-control', 'no-store') 139 140 // Initiate the OAuth flow 141 try { 142 // Validate input: can be a handle, a DID or a service URL (PDS). 143 const input = ifString(req.body.input) 144 if (!input) { 145 throw new Error('Invalid input') 146 } 147 148 // Initiate the OAuth flow 149 const url = await ctx.oauthClient.authorize(input, { 150 scope: 'atproto transition:generic', 151 }) 152 153 res.redirect(url.toString()) 154 } catch (err) { 155 ctx.logger.error({ err }, 'oauth authorize failed') 156 157 const error = err instanceof Error ? err.message : 'unexpected error' 158 159 return res.type('html').send(page(login({ error }))) 160 } 161 }), 162 ) 163 164 // Signup 165 router.get( 166 '/signup', 167 handler(async (req, res) => { 168 res.setHeader('cache-control', `max-age=${MAX_AGE}, public`) 169 170 try { 171 const service = env.PDS_URL ?? 'https://bsky.social' 172 const url = await ctx.oauthClient.authorize(service, { 173 scope: 'atproto transition:generic', 174 }) 175 res.redirect(url.toString()) 176 } catch (err) { 177 ctx.logger.error({ err }, 'oauth authorize failed') 178 res.type('html').send( 179 page( 180 login({ 181 error: 182 err instanceof OAuthResolverError 183 ? err.message 184 : "couldn't initiate login", 185 }), 186 ), 187 ) 188 } 189 }), 190 ) 191 192 // Logout handler 193 router.post( 194 '/logout', 195 handler(async (req, res) => { 196 // Never store this route 197 res.setHeader('cache-control', 'no-store') 198 199 const session = await getIronSession<Session>(req, res, { 200 cookieName: 'sid', 201 password: env.COOKIE_SECRET, 202 }) 203 204 // Revoke credentials on the server 205 if (session.did) { 206 try { 207 const oauthSession = await ctx.oauthClient.restore(session.did) 208 if (oauthSession) await oauthSession.signOut() 209 } catch (err) { 210 ctx.logger.warn({ err }, 'Failed to revoke credentials') 211 } 212 } 213 214 session.destroy() 215 216 return res.redirect('/') 217 }), 218 ) 219 220 // Homepage 221 router.get( 222 '/', 223 handler(async (req, res) => { 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 // Fetch additional information about the logged-in user 254 const profileResponse = await agent.com.atproto.repo 255 .getRecord({ 256 repo: agent.assertDid, 257 collection: 'app.bsky.actor.profile', 258 rkey: 'self', 259 }) 260 .catch(() => undefined) 261 262 const profileRecord = profileResponse?.data 263 264 const profile = 265 profileRecord && 266 Profile.isRecord(profileRecord.value) && 267 Profile.validateRecord(profileRecord.value).success 268 ? profileRecord.value 269 : {} 270 271 // Serve the logged-in view 272 res 273 .type('html') 274 .send(page(home({ statuses, didHandleMap, profile, myStatus }))) 275 }), 276 ) 277 278 // "Set status" handler 279 router.post( 280 '/status', 281 express.urlencoded(), 282 handler(async (req, res) => { 283 // If the user is signed in, get an agent which communicates with their server 284 const agent = await getSessionAgent(req, res, ctx) 285 if (!agent) { 286 return res 287 .status(401) 288 .type('html') 289 .send('<h1>Error: Session required</h1>') 290 } 291 292 // Construct their status record 293 const record = { 294 $type: 'xyz.statusphere.status', 295 status: req.body?.status, 296 createdAt: new Date().toISOString(), 297 } 298 299 // Make sure the record generated from the input is valid 300 if (!Status.validateRecord(record).success) { 301 return res 302 .status(400) 303 .type('html') 304 .send('<h1>Error: Invalid status</h1>') 305 } 306 307 let uri 308 try { 309 // Write the status record to the user's repository 310 const res = await agent.com.atproto.repo.putRecord({ 311 repo: agent.assertDid, 312 collection: 'xyz.statusphere.status', 313 rkey: TID.nextStr(), 314 record, 315 validate: false, 316 }) 317 uri = res.data.uri 318 } catch (err) { 319 ctx.logger.warn({ err }, 'failed to write record') 320 return res 321 .status(500) 322 .type('html') 323 .send('<h1>Error: Failed to write record</h1>') 324 } 325 326 try { 327 // Optimistically update our SQLite 328 // This isn't strictly necessary because the write event will be 329 // handled in #/firehose/ingestor.ts, but it ensures that future reads 330 // will be up-to-date after this method finishes. 331 await ctx.db 332 .insertInto('status') 333 .values({ 334 uri, 335 authorDid: agent.assertDid, 336 status: record.status, 337 createdAt: record.createdAt, 338 indexedAt: new Date().toISOString(), 339 }) 340 .execute() 341 } catch (err) { 342 ctx.logger.warn( 343 { err }, 344 'failed to update computed view; ignoring as it should be caught by the firehose', 345 ) 346 } 347 348 return res.redirect('/') 349 }), 350 ) 351 352 return router 353}