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