Scratch space for learning atproto app development
1import { Agent } from '@atproto/api'
2import { TID } from '@atproto/common'
3import { OAuthResolverError } from '@atproto/oauth-client-node'
4import { isValidHandle } from '@atproto/syntax'
5import express, { Request, Response } from 'express'
6import { getIronSession } from 'iron-session'
7import assert from 'node:assert'
8import type {
9 IncomingMessage,
10 RequestListener,
11 ServerResponse,
12} from 'node:http'
13import path from 'node:path'
14
15import type { AppContext } from '#/context'
16import * as Profile from '#/lexicon/types/app/bsky/actor/profile'
17import * as Status from '#/lexicon/types/xyz/statusphere/status'
18import { env } from '#/env'
19import { handler } from '#/lib/http'
20import { page } from '#/lib/view'
21import { home } from '#/pages/home'
22import { login } from '#/pages/login'
23
24type Session = { did: string }
25
26// Helper function to get the Atproto Agent for the active session
27async function getSessionAgent(
28 req: IncomingMessage,
29 res: ServerResponse,
30 ctx: AppContext,
31) {
32 const session = await getIronSession<Session>(req, res, {
33 cookieName: 'sid',
34 password: env.COOKIE_SECRET,
35 })
36 if (!session.did) return null
37 try {
38 const oauthSession = await ctx.oauthClient.restore(session.did)
39 return oauthSession ? new Agent(oauthSession) : null
40 } catch (err) {
41 ctx.logger.warn({ err }, 'oauth restore failed')
42 await session.destroy()
43 return null
44 }
45}
46
47export function createRouter(ctx: AppContext): RequestListener {
48 const app = express()
49
50 // Static assets
51 app.use('/public', express.static(path.join(__dirname, 'pages', 'public')))
52
53 // OAuth metadata
54 app.get(
55 '/oauth-client-metadata.json',
56 handler((req: Request, res: Response) => {
57 res.json(ctx.oauthClient.clientMetadata)
58 }),
59 )
60
61 // Public keys
62 app.get(
63 '/.well-known/jwks.json',
64 handler((req: Request, res: Response) => {
65 res.json(ctx.oauthClient.jwks)
66 }),
67 )
68
69 // OAuth callback to complete session creation
70 app.get(
71 '/oauth/callback',
72 handler(async (req: Request, res: Response) => {
73 const params = new URLSearchParams(req.originalUrl.split('?')[1])
74 try {
75 const { session } = await ctx.oauthClient.callback(params)
76 const clientSession = await getIronSession<Session>(req, res, {
77 cookieName: 'sid',
78 password: env.COOKIE_SECRET,
79 })
80 assert(!clientSession.did, 'session already exists')
81 clientSession.did = session.did
82 await clientSession.save()
83 return res.redirect('/')
84 } catch (err) {
85 ctx.logger.error({ err }, 'oauth callback failed')
86 return res.redirect('/?error')
87 }
88 }),
89 )
90
91 // Login page
92 app.get(
93 '/login',
94 handler((req: Request, res: Response) => {
95 res.type('html').send(page(login({})))
96 }),
97 )
98
99 // Login handler
100 app.post(
101 '/login',
102 express.urlencoded({ extended: true }),
103 handler(async (req: Request, res: Response) => {
104 // Validate
105 const handle = req.body?.handle
106 if (typeof handle !== 'string' || !isValidHandle(handle)) {
107 return void res
108 .type('html')
109 .send(page(login({ error: 'invalid handle' })))
110 }
111
112 // Initiate the OAuth flow
113 try {
114 const url = await ctx.oauthClient.authorize(handle, {
115 scope: 'atproto transition:generic',
116 })
117 res.redirect(url.toString())
118 } catch (err) {
119 ctx.logger.error({ err }, 'oauth authorize failed')
120 res.type('html').send(
121 page(
122 login({
123 error:
124 err instanceof OAuthResolverError
125 ? err.message
126 : "couldn't initiate login",
127 }),
128 ),
129 )
130 }
131 }),
132 )
133
134 // Logout handler
135 app.post(
136 '/logout',
137 handler(async (req: Request, res: Response) => {
138 const session = await getIronSession<Session>(req, res, {
139 cookieName: 'sid',
140 password: env.COOKIE_SECRET,
141 })
142 await session.destroy()
143 return res.redirect('/')
144 }),
145 )
146
147 // Homepage
148 app.get(
149 '/',
150 handler(async (req: Request, res: Response) => {
151 // If the user is signed in, get an agent which communicates with their server
152 const agent = await getSessionAgent(req, res, ctx)
153
154 // Fetch data stored in our SQLite
155 const statuses = await ctx.db
156 .selectFrom('status')
157 .selectAll()
158 .orderBy('indexedAt', 'desc')
159 .limit(10)
160 .execute()
161 const myStatus = agent
162 ? await ctx.db
163 .selectFrom('status')
164 .selectAll()
165 .where('authorDid', '=', agent.assertDid)
166 .orderBy('indexedAt', 'desc')
167 .executeTakeFirst()
168 : undefined
169
170 // Map (unique) user DIDs to their domain-name handles
171 const uniqueDids = [...new Set(statuses.map((s) => s.authorDid))]
172
173 const didHandleMap: Record<string, string | undefined> =
174 Object.fromEntries(
175 await Promise.all(
176 uniqueDids.map((did) =>
177 ctx.identityResolver.resolve(did).then(
178 (r) => [did, r.handle],
179 () => [did, undefined],
180 ),
181 ),
182 ),
183 )
184
185 if (!agent) {
186 // Serve the logged-out view
187 return void res
188 .type('html')
189 .send(page(home({ statuses, didHandleMap })))
190 }
191
192 // Fetch additional information about the logged-in user
193 const profileResponse = await agent.com.atproto.repo
194 .getRecord({
195 repo: agent.assertDid,
196 collection: 'app.bsky.actor.profile',
197 rkey: 'self',
198 })
199 .catch(() => undefined)
200
201 const profileRecord = profileResponse?.data
202
203 const profile =
204 profileRecord &&
205 Profile.isRecord(profileRecord.value) &&
206 Profile.validateRecord(profileRecord.value).success
207 ? profileRecord.value
208 : {}
209
210 // Serve the logged-in view
211 res.type('html').send(
212 page(
213 home({
214 statuses,
215 didHandleMap,
216 profile,
217 myStatus,
218 }),
219 ),
220 )
221 }),
222 )
223
224 // "Set status" handler
225 app.post(
226 '/status',
227 express.urlencoded({ extended: true }),
228 handler(async (req: Request, res: Response) => {
229 // If the user is signed in, get an agent which communicates with their server
230 const agent = await getSessionAgent(req, res, ctx)
231 if (!agent) {
232 return void res
233 .status(401)
234 .type('html')
235 .send('<h1>Error: Session required</h1>')
236 }
237
238 // Construct & validate their status record
239 const rkey = TID.nextStr()
240 const record = {
241 $type: 'xyz.statusphere.status',
242 status: req.body?.status,
243 createdAt: new Date().toISOString(),
244 }
245 if (!Status.validateRecord(record).success) {
246 return void res
247 .status(400)
248 .type('html')
249 .send('<h1>Error: Invalid status</h1>')
250 }
251
252 let uri
253 try {
254 // Write the status record to the user's repository
255 const res = await agent.com.atproto.repo.putRecord({
256 repo: agent.assertDid,
257 collection: 'xyz.statusphere.status',
258 rkey,
259 record,
260 validate: false,
261 })
262 uri = res.data.uri
263 } catch (err) {
264 ctx.logger.warn({ err }, 'failed to write record')
265 return void res
266 .status(500)
267 .type('html')
268 .send('<h1>Error: Failed to write record</h1>')
269 }
270
271 try {
272 // Optimistically update our SQLite
273 // This isn't strictly necessary because the write event will be
274 // handled in #/firehose/ingestor.ts, but it ensures that future reads
275 // will be up-to-date after this method finishes.
276 await ctx.db
277 .insertInto('status')
278 .values({
279 uri,
280 authorDid: agent.assertDid,
281 status: record.status,
282 createdAt: record.createdAt,
283 indexedAt: new Date().toISOString(),
284 })
285 .execute()
286 } catch (err) {
287 ctx.logger.warn(
288 { err },
289 'failed to update computed view; ignoring as it should be caught by the firehose',
290 )
291 }
292
293 return res.redirect('/')
294 }),
295 )
296
297 return app
298}