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 '#/view' 10import * as Status from '#/lexicon/types/com/example/status' 11 12const handler = 13 (fn: express.Handler) => 14 async ( 15 req: express.Request, 16 res: express.Response, 17 next: express.NextFunction 18 ) => { 19 try { 20 await fn(req, res, next) 21 } catch (err) { 22 next(err) 23 } 24 } 25 26export const createRouter = (ctx: AppContext) => { 27 const router = express.Router() 28 29 router.use('/public', express.static(path.join(__dirname, 'pages', 'public'))) 30 31 router.get( 32 '/client-metadata.json', 33 handler((_req, res) => { 34 return res.json(ctx.oauthClient.clientMetadata) 35 }) 36 ) 37 38 router.get( 39 '/oauth/callback', 40 handler(async (req, res) => { 41 const params = new URLSearchParams(req.originalUrl.split('?')[1]) 42 try { 43 const { agent } = await ctx.oauthClient.callback(params) 44 await createSession(req, res, agent.accountDid) 45 } catch (err) { 46 ctx.logger.error({ err }, 'oauth callback failed') 47 return res.redirect('/?error') 48 } 49 return res.redirect('/') 50 }) 51 ) 52 53 router.get( 54 '/login', 55 handler(async (_req, res) => { 56 return res.type('html').send(page(login({}))) 57 }) 58 ) 59 60 router.post( 61 '/login', 62 handler(async (req, res) => { 63 const handle = req.body?.handle 64 if (typeof handle !== 'string' || !isValidHandle(handle)) { 65 return res.type('html').send(page(login({ error: 'invalid handle' }))) 66 } 67 try { 68 const url = await ctx.oauthClient.authorize(handle) 69 return res.redirect(url.toString()) 70 } catch (err) { 71 ctx.logger.error({ err }, 'oauth authorize failed') 72 return res.type('html').send( 73 page( 74 login({ 75 error: 76 err instanceof OAuthResolverError 77 ? err.message 78 : "couldn't initiate login", 79 }) 80 ) 81 ) 82 } 83 }) 84 ) 85 86 router.post( 87 '/logout', 88 handler(async (req, res) => { 89 await destroySession(req, res) 90 return res.redirect('/') 91 }) 92 ) 93 94 router.get( 95 '/', 96 handler(async (req, res) => { 97 const agent = await getSessionAgent(req, res, ctx) 98 const statuses = await ctx.db 99 .selectFrom('status') 100 .selectAll() 101 .orderBy('indexedAt', 'desc') 102 .limit(10) 103 .execute() 104 const myStatus = agent 105 ? await ctx.db 106 .selectFrom('status') 107 .selectAll() 108 .where('authorDid', '=', agent.accountDid) 109 .executeTakeFirst() 110 : undefined 111 const didHandleMap = await ctx.resolver.resolveDidsToHandles( 112 statuses.map((s) => s.authorDid) 113 ) 114 if (!agent) { 115 return res.type('html').send(page(home({ statuses, didHandleMap }))) 116 } 117 const { data: profile } = await agent.getProfile({ 118 actor: agent.accountDid, 119 }) 120 return res 121 .type('html') 122 .send(page(home({ statuses, didHandleMap, profile, myStatus }))) 123 }) 124 ) 125 126 router.post( 127 '/status', 128 handler(async (req, res) => { 129 const agent = await getSessionAgent(req, res, ctx) 130 if (!agent) { 131 return res.status(401).json({ error: 'Session required' }) 132 } 133 134 const record = { 135 $type: 'com.example.status', 136 status: req.body?.status, 137 updatedAt: new Date().toISOString(), 138 } 139 if (!Status.validateRecord(record).success) { 140 return res.status(400).json({ error: 'Invalid status' }) 141 } 142 143 try { 144 await agent.com.atproto.repo.putRecord({ 145 repo: agent.accountDid, 146 collection: 'com.example.status', 147 rkey: 'self', 148 record, 149 validate: false, 150 }) 151 } catch (err) { 152 ctx.logger.warn({ err }, 'failed to write record') 153 return res.status(500).json({ error: 'Failed to write record' }) 154 } 155 156 try { 157 await ctx.db 158 .insertInto('status') 159 .values({ 160 authorDid: agent.accountDid, 161 status: record.status, 162 updatedAt: record.updatedAt, 163 indexedAt: new Date().toISOString(), 164 }) 165 .onConflict((oc) => 166 oc.column('authorDid').doUpdateSet({ 167 status: record.status, 168 updatedAt: record.updatedAt, 169 indexedAt: new Date().toISOString(), 170 }) 171 ) 172 .execute() 173 } catch (err) { 174 ctx.logger.warn( 175 { err }, 176 'failed to update computed view; ignoring as it should be caught by the firehose' 177 ) 178 } 179 180 res.status(200).json({}) 181 }) 182 ) 183 184 return router 185}