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