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 { agent } = await ctx.oauthClient.callback(params) 75 const session = await getIronSession<Session>(req, res, { 76 cookieName: 'sid', 77 password: env.COOKIE_SECRET, 78 }) 79 assert(!session.did, 'session already exists') 80 session.did = agent.accountDid 81 await session.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 return res.redirect(url.toString()) 112 } catch (err) { 113 ctx.logger.error({ err }, 'oauth authorize failed') 114 return res.type('html').send( 115 page( 116 login({ 117 error: 118 err instanceof OAuthResolverError 119 ? err.message 120 : "couldn't initiate login", 121 }) 122 ) 123 ) 124 } 125 }) 126 ) 127 128 // Logout handler 129 router.post( 130 '/logout', 131 handler(async (req, res) => { 132 const session = await getIronSession<Session>(req, res, { 133 cookieName: 'sid', 134 password: env.COOKIE_SECRET, 135 }) 136 await session.destroy() 137 return res.redirect('/') 138 }) 139 ) 140 141 // Homepage 142 router.get( 143 '/', 144 handler(async (req, res) => { 145 // If the user is signed in, get an agent which communicates with their server 146 const agent = await getSessionAgent(req, res, ctx) 147 148 // Fetch data stored in our SQLite 149 const statuses = await ctx.db 150 .selectFrom('status') 151 .selectAll() 152 .orderBy('indexedAt', 'desc') 153 .limit(10) 154 .execute() 155 const myStatus = agent 156 ? await ctx.db 157 .selectFrom('status') 158 .selectAll() 159 .where('authorDid', '=', agent.accountDid) 160 .orderBy('indexedAt', 'desc') 161 .executeTakeFirst() 162 : undefined 163 164 // Map user DIDs to their domain-name handles 165 const didHandleMap = await ctx.resolver.resolveDidsToHandles( 166 statuses.map((s) => s.authorDid) 167 ) 168 169 if (!agent) { 170 // Serve the logged-out view 171 return res.type('html').send(page(home({ statuses, didHandleMap }))) 172 } 173 174 // Fetch additional information about the logged-in user 175 const { data: profileRecord } = await agent.com.atproto.repo.getRecord({ 176 repo: agent.accountDid, 177 collection: 'app.bsky.actor.profile', 178 rkey: 'self', 179 }) 180 const profile = 181 Profile.isRecord(profileRecord.value) && 182 Profile.validateRecord(profileRecord.value).success 183 ? profileRecord.value 184 : {} 185 186 // Serve the logged-in view 187 return res.type('html').send( 188 page( 189 home({ 190 statuses, 191 didHandleMap, 192 profile, 193 myStatus, 194 }) 195 ) 196 ) 197 }) 198 ) 199 200 // "Set status" handler 201 router.post( 202 '/status', 203 handler(async (req, res) => { 204 // If the user is signed in, get an agent which communicates with their server 205 const agent = await getSessionAgent(req, res, ctx) 206 if (!agent) { 207 return res.status(401).json({ error: 'Session required' }) 208 } 209 210 // Construct & validate their status record 211 const rkey = TID.nextStr() 212 const record = { 213 $type: 'com.example.status', 214 status: req.body?.status, 215 createdAt: new Date().toISOString(), 216 } 217 if (!Status.validateRecord(record).success) { 218 return res.status(400).json({ error: 'Invalid status' }) 219 } 220 221 let uri 222 try { 223 // Write the status record to the user's repository 224 const res = await agent.com.atproto.repo.putRecord({ 225 repo: agent.accountDid, 226 collection: 'com.example.status', 227 rkey, 228 record, 229 validate: false, 230 }) 231 uri = res.data.uri 232 } catch (err) { 233 ctx.logger.warn({ err }, 'failed to write record') 234 return res.status(500).json({ error: 'Failed to write record' }) 235 } 236 237 try { 238 // Optimistically update our SQLite 239 // This isn't strictly necessary because the write event will be 240 // handled in #/firehose/ingestor.ts, but it ensures that future reads 241 // will be up-to-date after this method finishes. 242 await ctx.db 243 .insertInto('status') 244 .values({ 245 uri, 246 authorDid: agent.accountDid, 247 status: record.status, 248 createdAt: record.createdAt, 249 indexedAt: new Date().toISOString(), 250 }) 251 .execute() 252 } catch (err) { 253 ctx.logger.warn( 254 { err }, 255 'failed to update computed view; ignoring as it should be caught by the firehose' 256 ) 257 } 258 259 res.status(200).json({}) 260 }) 261 ) 262 263 return router 264}