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