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