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 express, { Request, Response } from 'express'
5import { getIronSession } from 'iron-session'
6import assert from 'node:assert'
7import type {
8 IncomingMessage,
9 RequestListener,
10 ServerResponse,
11} from 'node:http'
12import path from 'node:path'
13
14import type { AppContext } from '#/context'
15import * as Profile from '#/lexicon/types/app/bsky/actor/profile'
16import * as Status from '#/lexicon/types/xyz/statusphere/status'
17import { env } from '#/env'
18import { handler } from '#/lib/http'
19import { page } from '#/lib/view'
20import { home, STATUS_OPTIONS } from '#/pages/home'
21import { login } from '#/pages/login'
22import { ifString } from './lib/util'
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 // force rotating the credentials if the request has a no-cache header
39 const refresh = req.headers['cache-control']?.includes('no-cache') || 'auto'
40
41 const oauthSession = await ctx.oauthClient.restore(session.did, refresh)
42 return oauthSession ? new Agent(oauthSession) : null
43 } catch (err) {
44 ctx.logger.warn({ err }, 'oauth restore failed')
45 await session.destroy()
46 return null
47 }
48}
49
50export function createRouter(ctx: AppContext): RequestListener {
51 const app = express()
52
53 // Static assets
54 app.use('/public', express.static(path.join(__dirname, 'pages', 'public')))
55
56 // OAuth metadata
57 app.get(
58 '/oauth-client-metadata.json',
59 handler((req: Request, res: Response) => {
60 res.json(ctx.oauthClient.clientMetadata)
61 }),
62 )
63
64 // Public keys
65 app.get(
66 '/.well-known/jwks.json',
67 handler((req: Request, res: Response) => {
68 res.json(ctx.oauthClient.jwks)
69 }),
70 )
71
72 // OAuth callback to complete session creation
73 app.get(
74 '/oauth/callback',
75 handler(async (req: Request, res: Response) => {
76 const params = new URLSearchParams(req.originalUrl.split('?')[1])
77 try {
78 // Load the session cookie
79 const session = await getIronSession<Session>(req, res, {
80 cookieName: 'sid',
81 password: env.COOKIE_SECRET,
82 })
83
84 // If the user is already signed in, destroy the old credentials
85 if (session.did) {
86 try {
87 const oauthSession = await ctx.oauthClient.restore(session.did)
88 if (oauthSession) oauthSession.signOut()
89 } catch (err) {
90 ctx.logger.warn({ err }, 'oauth restore failed')
91 }
92 }
93
94 // Complete the OAuth flow
95 const oauth = await ctx.oauthClient.callback(params)
96
97 // Update the session cookie
98 session.did = oauth.session.did
99 await session.save()
100
101 if (oauth.state?.startsWith('status:')) {
102 const status = oauth.state.slice(7)
103 const agent = new Agent(oauth.session)
104 try {
105 await updateStatus(agent, status)
106 } catch (err) {
107 const message = err instanceof Error ? err.message : 'Unknown error'
108 return res.redirect(`/?error=${encodeURIComponent(message)}`)
109 }
110 }
111
112 // Redirect to the homepage
113 return res.redirect('/')
114 } catch (err) {
115 ctx.logger.error({ err }, 'oauth callback failed')
116 return res.redirect('/?error')
117 }
118 }),
119 )
120
121 // Login page
122 app.get(
123 '/login',
124 handler((req: Request, res: Response) => {
125 const state = ifString(req.query.state)
126 res.type('html').send(page(login({ state })))
127 }),
128 )
129
130 // Login handler
131 app.post(
132 '/login',
133 express.urlencoded({ extended: true }),
134 handler(async (req: Request, res: Response) => {
135 const input = ifString(req.body.input)
136 const state = ifString(req.body.state)
137
138 // Validate
139 if (!input) {
140 return void res
141 .type('html')
142 .send(page(login({ error: 'invalid input' })))
143 }
144
145 // Initiate the OAuth flow
146 try {
147 const url = await ctx.oauthClient.authorize(input, {
148 scope: 'atproto transition:generic',
149 state,
150 })
151 res.redirect(url.toString())
152 } catch (err) {
153 ctx.logger.error({ err }, 'oauth authorize failed')
154
155 const error =
156 err instanceof OAuthResolverError
157 ? err.message
158 : "couldn't initiate login"
159
160 res.type('html').send(page(login({ state, error })))
161 }
162 }),
163 )
164
165 // Signup
166 app.get(
167 '/signup',
168 handler(async (req: Request, res: Response) => {
169 try {
170 const service = env.PDS_URL ?? 'https://bsky.social'
171 const url = await ctx.oauthClient.authorize(service, {
172 scope: 'atproto transition:generic',
173 state: ifString(req.query.state),
174 })
175 res.redirect(url.toString())
176 } catch (err) {
177 ctx.logger.error({ err }, 'oauth authorize failed')
178 res.type('html').send(
179 page(
180 login({
181 error:
182 err instanceof OAuthResolverError
183 ? err.message
184 : "couldn't initiate login",
185 }),
186 ),
187 )
188 }
189 }),
190 )
191
192 // Logout handler
193 app.post(
194 '/logout',
195 handler(async (req: Request, res: Response) => {
196 const session = await getIronSession<Session>(req, res, {
197 cookieName: 'sid',
198 password: env.COOKIE_SECRET,
199 })
200
201 // Revoke credentials on the server
202 if (session.did) {
203 try {
204 const oauthSession = await ctx.oauthClient.restore(session.did)
205 if (oauthSession) await oauthSession.signOut()
206 } catch (err) {
207 ctx.logger.warn({ err }, 'Failed to revoke credentials')
208 }
209 }
210
211 session.destroy()
212
213 return res.redirect('/')
214 }),
215 )
216
217 // Homepage
218 app.get(
219 '/',
220 handler(async (req: Request, res: Response) => {
221 const error = ifString(req.query.error)
222
223 // If the user is signed in, get an agent which communicates with their server
224 const agent = await getSessionAgent(req, res, ctx)
225
226 // Fetch data stored in our SQLite
227 const statuses = await ctx.db
228 .selectFrom('status')
229 .selectAll()
230 .orderBy('indexedAt', 'desc')
231 .limit(10)
232 .execute()
233 const myStatus = agent
234 ? await ctx.db
235 .selectFrom('status')
236 .selectAll()
237 .where('authorDid', '=', agent.assertDid)
238 .orderBy('indexedAt', 'desc')
239 .executeTakeFirst()
240 : undefined
241
242 // Map (unique) user DIDs to their domain-name handles
243 const uniqueDids = [...new Set(statuses.map((s) => s.authorDid))]
244
245 const didHandleMap: Record<string, string | undefined> =
246 Object.fromEntries(
247 await Promise.all(
248 uniqueDids.map((did) =>
249 ctx.identityResolver.resolve(did).then(
250 (r) => [did, r.handle],
251 () => [did, undefined],
252 ),
253 ),
254 ),
255 )
256
257 if (!agent) {
258 // Serve the logged-out view
259 return void res
260 .type('html')
261 .send(page(home({ error, statuses, didHandleMap })))
262 }
263
264 // Fetch additional information about the logged-in user
265 const profileResponse = await agent.com.atproto.repo
266 .getRecord({
267 repo: agent.assertDid,
268 collection: 'app.bsky.actor.profile',
269 rkey: 'self',
270 })
271 .catch(() => undefined)
272
273 const profileRecord = profileResponse?.data
274
275 const profile =
276 profileRecord &&
277 Profile.isRecord(profileRecord.value) &&
278 Profile.validateRecord(profileRecord.value).success
279 ? profileRecord.value
280 : {}
281
282 // Serve the logged-in view
283 res
284 .type('html')
285 .send(page(home({ error, statuses, didHandleMap, profile, myStatus })))
286 }),
287 )
288
289 // "Set status" handler
290 app.post(
291 '/status',
292 express.urlencoded({ extended: true }),
293 handler(async (req: Request, res: Response) => {
294 const status = req.body?.status
295
296 // If the user is signed in, get an agent which communicates with their server
297 const agent = await getSessionAgent(req, res, ctx)
298 if (!agent) {
299 return void res.redirect(
300 `/login?state=status:${encodeURIComponent(status)}`,
301 )
302 }
303
304 try {
305 await updateStatus(agent, status)
306 return res.redirect('/')
307 } catch (err) {
308 const message = err instanceof Error ? err.message : 'Unknown error'
309 return res.redirect(`/?error=${encodeURIComponent(message)}`)
310 }
311 }),
312 )
313
314 return app
315
316 async function updateStatus(agent: Agent, status: unknown) {
317 if (typeof status !== 'string' || !STATUS_OPTIONS.includes(status)) {
318 throw new Error('Invalid status')
319 }
320
321 // Construct & validate their status record
322 const rkey = TID.nextStr()
323 const record = {
324 $type: 'xyz.statusphere.status',
325 status,
326 createdAt: new Date().toISOString(),
327 }
328
329 if (!Status.validateRecord(record).success) {
330 throw new Error('Invalid status record')
331 }
332
333 // Write the status record to the user's repository
334 const res = await agent.com.atproto.repo
335 .putRecord({
336 repo: agent.assertDid,
337 collection: 'xyz.statusphere.status',
338 rkey,
339 record,
340 validate: false,
341 })
342 .catch((err) => {
343 ctx.logger.error({ err }, 'failed to write record')
344 throw new Error('Failed to write record', { cause: err })
345 })
346
347 try {
348 // Optimistically update our SQLite
349 // This isn't strictly necessary because the write event will be
350 // handled in #/firehose/ingestor.ts, but it ensures that future reads
351 // will be up-to-date after this method finishes.
352 await ctx.db
353 .insertInto('status')
354 .values({
355 uri: res.data.uri,
356 authorDid: agent.assertDid,
357 status: record.status,
358 createdAt: record.createdAt,
359 indexedAt: new Date().toISOString(),
360 })
361 .execute()
362 } catch (err) {
363 ctx.logger.warn(
364 { err },
365 'failed to update computed view; ignoring as it should be caught by the firehose',
366 )
367 }
368 }
369}