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 { agent } = await ctx.oauthClient.callback(params) 48 await createSession(req, res, agent.accountDid) 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 return res.redirect(url.toString()) 79 } catch (err) { 80 ctx.logger.error({ err }, 'oauth authorize failed') 81 return res.type('html').send( 82 page( 83 login({ 84 error: 85 err instanceof OAuthResolverError 86 ? err.message 87 : "couldn't initiate login", 88 }) 89 ) 90 ) 91 } 92 }) 93 ) 94 95 // Logout handler 96 router.post( 97 '/logout', 98 handler(async (req, res) => { 99 await destroySession(req, res) 100 return res.redirect('/') 101 }) 102 ) 103 104 // Homepage 105 router.get( 106 '/', 107 handler(async (req, res) => { 108 // If the user is signed in, get an agent which communicates with their server 109 const agent = await getSessionAgent(req, res, ctx) 110 111 // Fetch data stored in our SQLite 112 const statuses = await ctx.db 113 .selectFrom('status') 114 .selectAll() 115 .orderBy('indexedAt', 'desc') 116 .limit(10) 117 .execute() 118 const myStatus = agent 119 ? await ctx.db 120 .selectFrom('status') 121 .selectAll() 122 .where('authorDid', '=', agent.accountDid) 123 .executeTakeFirst() 124 : undefined 125 126 // Map user DIDs to their domain-name handles 127 const didHandleMap = await ctx.resolver.resolveDidsToHandles( 128 statuses.map((s) => s.authorDid) 129 ) 130 131 if (!agent) { 132 // Serve the logged-out view 133 return res.type('html').send(page(home({ statuses, didHandleMap }))) 134 } 135 136 // Fetch additional information about the logged-in user 137 const { data: profile } = await agent.getProfile({ 138 actor: agent.accountDid, 139 }) 140 didHandleMap[profile.handle] = agent.accountDid 141 142 // Serve the logged-in view 143 return res 144 .type('html') 145 .send(page(home({ statuses, didHandleMap, profile, myStatus }))) 146 }) 147 ) 148 149 // "Set status" handler 150 router.post( 151 '/status', 152 handler(async (req, res) => { 153 // If the user is signed in, get an agent which communicates with their server 154 const agent = await getSessionAgent(req, res, ctx) 155 if (!agent) { 156 return res.status(401).json({ error: 'Session required' }) 157 } 158 159 // Construct & validate their status record 160 const record = { 161 $type: 'com.example.status', 162 status: req.body?.status, 163 updatedAt: new Date().toISOString(), 164 } 165 if (!Status.validateRecord(record).success) { 166 return res.status(400).json({ error: 'Invalid status' }) 167 } 168 169 try { 170 // Write the status record to the user's repository 171 await agent.com.atproto.repo.putRecord({ 172 repo: agent.accountDid, 173 collection: 'com.example.status', 174 rkey: 'self', 175 record, 176 validate: false, 177 }) 178 } catch (err) { 179 ctx.logger.warn({ err }, 'failed to write record') 180 return res.status(500).json({ error: 'Failed to write record' }) 181 } 182 183 try { 184 // Optimistically update our SQLite 185 // This isn't strictly necessary because the write event will be 186 // handled in #/firehose/ingestor.ts, but it ensures that future reads 187 // will be up-to-date after this method finishes. 188 await ctx.db 189 .insertInto('status') 190 .values({ 191 authorDid: agent.accountDid, 192 status: record.status, 193 updatedAt: record.updatedAt, 194 indexedAt: new Date().toISOString(), 195 }) 196 .onConflict((oc) => 197 oc.column('authorDid').doUpdateSet({ 198 status: record.status, 199 updatedAt: record.updatedAt, 200 indexedAt: new Date().toISOString(), 201 }) 202 ) 203 .execute() 204 } catch (err) { 205 ctx.logger.warn( 206 { err }, 207 'failed to update computed view; ignoring as it should be caught by the firehose' 208 ) 209 } 210 211 res.status(200).json({}) 212 }) 213 ) 214 215 return router 216}