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}