Scratch space for learning atproto app development
1import assert from 'node:assert' 2import path from 'node:path' 3import type { IncomingMessage, ServerResponse } from 'node:http' 4import { OAuthResolverError } from '@atproto/oauth-client-node' 5import { isValidHandle } from '@atproto/syntax' 6import { TID } from '@atproto/common' 7import express from 'express' 8import { getIronSession } from 'iron-session' 9import type { AppContext } from '#/index' 10import { home } from '#/pages/home' 11import { login } from '#/pages/login' 12import { env } from '#/lib/env' 13import { page } from '#/lib/view' 14import * as Status from '#/lexicon/types/com/example/status' 15import * as Profile from '#/lexicon/types/app/bsky/actor/profile' 16 17type Session = { did: string } 18 19// Helper function for defining routes 20const handler = 21 (fn: express.Handler) => 22 async ( 23 req: express.Request, 24 res: express.Response, 25 next: express.NextFunction 26 ) => { 27 try { 28 await fn(req, res, next) 29 } catch (err) { 30 next(err) 31 } 32 } 33 34// Helper function to get the Atproto Agent for the active session 35async function getSessionAgent( 36 req: IncomingMessage, 37 res: ServerResponse<IncomingMessage>, 38 ctx: AppContext 39) { 40 const session = await getIronSession<Session>(req, res, { 41 cookieName: 'sid', 42 password: env.COOKIE_SECRET, 43 }) 44 if (!session.did) return null 45 try { 46 return await ctx.oauthClient.restore(session.did) 47 } catch (err) { 48 ctx.logger.warn({ err }, 'oauth restore failed') 49 await session.destroy() 50 return null 51 } 52} 53 54export const createRouter = (ctx: AppContext) => { 55 const router = express.Router() 56 57 // Static assets 58 router.use('/public', express.static(path.join(__dirname, 'pages', 'public'))) 59 60 // OAuth metadata 61 router.get( 62 '/client-metadata.json', 63 handler((_req, res) => { 64 return res.json(ctx.oauthClient.clientMetadata) 65 }) 66 ) 67 68 // OAuth callback to complete session creation 69 router.get( 70 '/oauth/callback', 71 handler(async (req, res) => { 72 const params = new URLSearchParams(req.originalUrl.split('?')[1]) 73 try { 74 const { session } = await ctx.oauthClient.callback(params) 75 const clientSession = await getIronSession<Session>(req, res, { 76 cookieName: 'sid', 77 password: env.COOKIE_SECRET, 78 }) 79 assert(!clientSession.did, 'session already exists') 80 clientSession.did = session.did 81 await clientSession.save() 82 } catch (err) { 83 ctx.logger.error({ err }, 'oauth callback failed') 84 return res.redirect('/?error') 85 } 86 return res.redirect('/') 87 }) 88 ) 89 90 // Login page 91 router.get( 92 '/login', 93 handler(async (_req, res) => { 94 return res.type('html').send(page(login({}))) 95 }) 96 ) 97 98 // Login handler 99 router.post( 100 '/login', 101 handler(async (req, res) => { 102 // Validate 103 const handle = req.body?.handle 104 if (typeof handle !== 'string' || !isValidHandle(handle)) { 105 return res.type('html').send(page(login({ error: 'invalid handle' }))) 106 } 107 108 // Initiate the OAuth flow 109 try { 110 const url = await ctx.oauthClient.authorize(handle, { 111 scope: 'atproto transition:generic', 112 }) 113 return res.redirect(url.toString()) 114 } catch (err) { 115 ctx.logger.error({ err }, 'oauth authorize failed') 116 return res.type('html').send( 117 page( 118 login({ 119 error: 120 err instanceof OAuthResolverError 121 ? err.message 122 : "couldn't initiate login", 123 }) 124 ) 125 ) 126 } 127 }) 128 ) 129 130 // Logout handler 131 router.post( 132 '/logout', 133 handler(async (req, res) => { 134 const session = await getIronSession<Session>(req, res, { 135 cookieName: 'sid', 136 password: env.COOKIE_SECRET, 137 }) 138 await session.destroy() 139 return res.redirect('/') 140 }) 141 ) 142 143 // Homepage 144 router.get( 145 '/', 146 handler(async (req, res) => { 147 // If the user is signed in, get an agent which communicates with their server 148 const agent = await getSessionAgent(req, res, ctx) 149 150 // Fetch data stored in our SQLite 151 const statuses = await ctx.db 152 .selectFrom('status') 153 .selectAll() 154 .orderBy('indexedAt', 'desc') 155 .limit(10) 156 .execute() 157 const myStatus = agent 158 ? await ctx.db 159 .selectFrom('status') 160 .selectAll() 161 .where('authorDid', '=', agent.assertDid) 162 .orderBy('indexedAt', 'desc') 163 .executeTakeFirst() 164 : undefined 165 166 // Map user DIDs to their domain-name handles 167 const didHandleMap = await ctx.resolver.resolveDidsToHandles( 168 statuses.map((s) => s.authorDid) 169 ) 170 171 if (!agent) { 172 // Serve the logged-out view 173 return res.type('html').send(page(home({ statuses, didHandleMap }))) 174 } 175 176 // Fetch additional information about the logged-in user 177 const { data: profileRecord } = await agent.com.atproto.repo.getRecord({ 178 repo: agent.assertDid, 179 collection: 'app.bsky.actor.profile', 180 rkey: 'self', 181 }) 182 const profile = 183 Profile.isRecord(profileRecord.value) && 184 Profile.validateRecord(profileRecord.value).success 185 ? profileRecord.value 186 : {} 187 188 // Serve the logged-in view 189 return res.type('html').send( 190 page( 191 home({ 192 statuses, 193 didHandleMap, 194 profile, 195 myStatus, 196 }) 197 ) 198 ) 199 }) 200 ) 201 202 // "Set status" handler 203 router.post( 204 '/status', 205 handler(async (req, res) => { 206 // If the user is signed in, get an agent which communicates with their server 207 const agent = await getSessionAgent(req, res, ctx) 208 if (!agent) { 209 return res 210 .status(401) 211 .type('html') 212 .send('<h1>Error: Session required</h1>') 213 } 214 215 // Construct & validate their status record 216 const rkey = TID.nextStr() 217 const record = { 218 $type: 'com.example.status', 219 status: req.body?.status, 220 createdAt: new Date().toISOString(), 221 } 222 if (!Status.validateRecord(record).success) { 223 return res 224 .status(400) 225 .type('html') 226 .send('<h1>Error: Invalid status</h1>') 227 } 228 229 let uri 230 try { 231 // Write the status record to the user's repository 232 const res = await agent.com.atproto.repo.putRecord({ 233 repo: agent.assertDid, 234 collection: 'com.example.status', 235 rkey, 236 record, 237 validate: false, 238 }) 239 uri = res.data.uri 240 } catch (err) { 241 ctx.logger.warn({ err }, 'failed to write record') 242 return res 243 .status(500) 244 .type('html') 245 .send('<h1>Error: Failed to write record</h1>') 246 } 247 248 try { 249 // Optimistically update our SQLite 250 // This isn't strictly necessary because the write event will be 251 // handled in #/firehose/ingestor.ts, but it ensures that future reads 252 // will be up-to-date after this method finishes. 253 await ctx.db 254 .insertInto('status') 255 .values({ 256 uri, 257 authorDid: agent.assertDid, 258 status: record.status, 259 createdAt: record.createdAt, 260 indexedAt: new Date().toISOString(), 261 }) 262 .execute() 263 } catch (err) { 264 ctx.logger.warn( 265 { err }, 266 'failed to update computed view; ignoring as it should be caught by the firehose' 267 ) 268 } 269 270 return res.redirect('/') 271 }) 272 ) 273 274 return router 275}