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