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