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 208 .status(401) 209 .type('html') 210 .send('<h1>Error: Session required</h1>') 211 } 212 213 // Construct & validate their status record 214 const rkey = TID.nextStr() 215 const record = { 216 $type: 'com.example.status', 217 status: req.body?.status, 218 createdAt: new Date().toISOString(), 219 } 220 if (!Status.validateRecord(record).success) { 221 return res 222 .status(400) 223 .type('html') 224 .send('<h1>Error: Invalid status</h1>') 225 } 226 227 let uri 228 try { 229 // Write the status record to the user's repository 230 const res = await agent.com.atproto.repo.putRecord({ 231 repo: agent.accountDid, 232 collection: 'com.example.status', 233 rkey, 234 record, 235 validate: false, 236 }) 237 uri = res.data.uri 238 } catch (err) { 239 ctx.logger.warn({ err }, 'failed to write record') 240 return res 241 .status(500) 242 .type('html') 243 .send('<h1>Error: Failed to write record</h1>') 244 } 245 246 try { 247 // Optimistically update our SQLite 248 // This isn't strictly necessary because the write event will be 249 // handled in #/firehose/ingestor.ts, but it ensures that future reads 250 // will be up-to-date after this method finishes. 251 await ctx.db 252 .insertInto('status') 253 .values({ 254 uri, 255 authorDid: agent.accountDid, 256 status: record.status, 257 createdAt: record.createdAt, 258 indexedAt: new Date().toISOString(), 259 }) 260 .execute() 261 } catch (err) { 262 ctx.logger.warn( 263 { err }, 264 'failed to update computed view; ignoring as it should be caught by the firehose' 265 ) 266 } 267 268 return res.redirect('/') 269 }) 270 ) 271 272 return router 273}