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