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