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