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