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