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 router = express()
52
53 // Static assets
54 router.use('/public', express.static(path.join(__dirname, 'pages', 'public')))
55
56 // OAuth metadata
57 router.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 router.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 router.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 // Redirect to the homepage
102 return res.redirect('/')
103 } catch (err) {
104 ctx.logger.error({ err }, 'oauth callback failed')
105 return res.redirect('/?error')
106 }
107 }),
108 )
109
110 // Login page
111 router.get(
112 '/login',
113 handler((req: Request, res: Response) => {
114 res.type('html').send(page(login({})))
115 }),
116 )
117
118 // Login handler
119 router.post(
120 '/login',
121 express.urlencoded({ extended: true }),
122 handler(async (req: Request, res: Response) => {
123 const input = ifString(req.body.input)
124
125 // Validate
126 if (!input) {
127 res.type('html').send(page(login({ error: 'invalid input' })))
128 return
129 }
130
131 // Initiate the OAuth flow
132 try {
133 const url = await ctx.oauthClient.authorize(input, {
134 scope: 'atproto transition:generic',
135 })
136 res.redirect(url.toString())
137 } catch (err) {
138 ctx.logger.error({ err }, 'oauth authorize failed')
139
140 const error =
141 err instanceof OAuthResolverError
142 ? err.message
143 : "couldn't initiate login"
144
145 res.type('html').send(page(login({ error })))
146 }
147 }),
148 )
149
150 // Signup
151 router.get(
152 '/signup',
153 handler(async (req: Request, res: Response) => {
154 try {
155 const service = env.PDS_URL ?? 'https://bsky.social'
156 const url = await ctx.oauthClient.authorize(service, {
157 scope: 'atproto transition:generic',
158 })
159 res.redirect(url.toString())
160 } catch (err) {
161 ctx.logger.error({ err }, 'oauth authorize failed')
162 res.type('html').send(
163 page(
164 login({
165 error:
166 err instanceof OAuthResolverError
167 ? err.message
168 : "couldn't initiate login",
169 }),
170 ),
171 )
172 }
173 }),
174 )
175
176 // Logout handler
177 router.post(
178 '/logout',
179 handler(async (req: Request, res: Response) => {
180 const session = await getIronSession<Session>(req, res, {
181 cookieName: 'sid',
182 password: env.COOKIE_SECRET,
183 })
184
185 // Revoke credentials on the server
186 if (session.did) {
187 try {
188 const oauthSession = await ctx.oauthClient.restore(session.did)
189 if (oauthSession) await oauthSession.signOut()
190 } catch (err) {
191 ctx.logger.warn({ err }, 'Failed to revoke credentials')
192 }
193 }
194
195 session.destroy()
196
197 return res.redirect('/')
198 }),
199 )
200
201 // Homepage
202 router.get(
203 '/',
204 handler(async (req: Request, res: Response) => {
205 const error = ifString(req.query.error)
206
207 // If the user is signed in, get an agent which communicates with their server
208 const agent = await getSessionAgent(req, res, ctx)
209
210 // Fetch data stored in our SQLite
211 const statuses = await ctx.db
212 .selectFrom('status')
213 .selectAll()
214 .orderBy('indexedAt', 'desc')
215 .limit(10)
216 .execute()
217 const myStatus = agent
218 ? await ctx.db
219 .selectFrom('status')
220 .selectAll()
221 .where('authorDid', '=', agent.assertDid)
222 .orderBy('indexedAt', 'desc')
223 .executeTakeFirst()
224 : undefined
225
226 // Map (unique) user DIDs to their domain-name handles
227 const uniqueDids = [...new Set(statuses.map((s) => s.authorDid))]
228
229 const didHandleMap: Record<string, string | undefined> =
230 Object.fromEntries(
231 await Promise.all(
232 uniqueDids.map((did) =>
233 ctx.identityResolver.resolve(did).then(
234 (r) => [did, r.handle],
235 () => [did, undefined],
236 ),
237 ),
238 ),
239 )
240
241 if (!agent) {
242 // Serve the logged-out view
243 res.type('html').send(page(home({ error, statuses, didHandleMap })))
244 return
245 }
246
247 // Fetch additional information about the logged-in user
248 const profileResponse = await agent.com.atproto.repo
249 .getRecord({
250 repo: agent.assertDid,
251 collection: 'app.bsky.actor.profile',
252 rkey: 'self',
253 })
254 .catch(() => undefined)
255
256 const profileRecord = profileResponse?.data
257
258 const profile =
259 profileRecord &&
260 Profile.isRecord(profileRecord.value) &&
261 Profile.validateRecord(profileRecord.value).success
262 ? profileRecord.value
263 : {}
264
265 // Serve the logged-in view
266 res
267 .type('html')
268 .send(page(home({ error, statuses, didHandleMap, profile, myStatus })))
269 }),
270 )
271
272 // "Set status" handler
273 router.post(
274 '/status',
275 express.urlencoded({ extended: true }),
276 handler(async (req: Request, res: Response) => {
277 // If the user is signed in, get an agent which communicates with their server
278 const agent = await getSessionAgent(req, res, ctx)
279 if (!agent) {
280 res.redirect(`/login}`)
281 return
282 }
283
284 try {
285 const status = req.body?.status
286 if (typeof status !== 'string' || !STATUS_OPTIONS.includes(status)) {
287 throw new Error('Invalid status')
288 }
289
290 // Construct & validate their status record
291 const rkey = TID.nextStr()
292 const record = {
293 $type: 'xyz.statusphere.status',
294 status,
295 createdAt: new Date().toISOString(),
296 }
297
298 if (!Status.validateRecord(record).success) {
299 res.status(400).type('html').send('<h1>Error: Invalid status</h1>')
300 return
301 }
302
303 // Write the status record to the user's repository
304 let uri
305 try {
306 const res = await agent.com.atproto.repo.putRecord({
307 repo: agent.assertDid,
308 collection: 'xyz.statusphere.status',
309 rkey,
310 record,
311 validate: false,
312 })
313 uri = res.data.uri
314 } catch (err) {
315 ctx.logger.error({ err }, 'failed to write record')
316 res
317 .status(500)
318 .type('html')
319 .send('<h1>Error: Failed to write record</h1>')
320 return
321 }
322
323 try {
324 // Optimistically update our SQLite
325 // This isn't strictly necessary because the write event will be
326 // handled in #/firehose/ingestor.ts, but it ensures that future reads
327 // will be up-to-date after this method finishes.
328 await ctx.db
329 .insertInto('status')
330 .values({
331 uri,
332 authorDid: agent.assertDid,
333 status: record.status,
334 createdAt: record.createdAt,
335 indexedAt: new Date().toISOString(),
336 })
337 .execute()
338 } catch (err) {
339 ctx.logger.warn(
340 { err },
341 'failed to update computed view; ignoring as it should be caught by the firehose',
342 )
343 }
344
345 res.redirect('/')
346 } catch (err) {
347 const message = err instanceof Error ? err.message : 'Unknown error'
348 res.redirect(`/?error=${encodeURIComponent(message)}`)
349 }
350 }),
351 )
352
353 return router
354}