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 await session.save()
114 } catch (err) {
115 ctx.logger.error({ err }, 'oauth callback failed')
116 return res.redirect('/?error')
117 }
118 return res.redirect('/')
119 }),
120 )
121
122 // Login page
123 router.get(
124 '/login',
125 handler(async (req, res) => {
126 res.setHeader('cache-control', `max-age=${MAX_AGE}, public`)
127 res.type('html').send(page(login({})))
128 }),
129 )
130
131 // Login handler
132 router.post(
133 '/login',
134 express.urlencoded(),
135 handler(async (req, res) => {
136 // Never store this route
137 res.setHeader('cache-control', 'no-store')
138
139 // Initiate the OAuth flow
140 try {
141 // Validate input: can be a handle, a DID or a service URL (PDS).
142 const input = ifString(req.body.input)
143 if (!input) {
144 throw new Error('Invalid input')
145 }
146
147 // Initiate the OAuth flow
148 const url = await ctx.oauthClient.authorize(input, {
149 scope: 'atproto transition:generic',
150 })
151
152 res.redirect(url.toString())
153 } catch (err) {
154 ctx.logger.error({ err }, 'oauth authorize failed')
155
156 const error = err instanceof Error ? err.message : 'unexpected error'
157
158 return res.type('html').send(page(login({ error })))
159 }
160 }),
161 )
162
163 // Signup
164 router.get(
165 '/signup',
166 handler(async (req, res) => {
167 res.setHeader('cache-control', `max-age=${MAX_AGE}, public`)
168
169 try {
170 const service = env.PDS_URL ?? 'https://bsky.social'
171 const url = await ctx.oauthClient.authorize(service, {
172 scope: 'atproto transition:generic',
173 })
174 res.redirect(url.toString())
175 } catch (err) {
176 ctx.logger.error({ err }, 'oauth authorize failed')
177 res.type('html').send(
178 page(
179 login({
180 error:
181 err instanceof OAuthResolverError
182 ? err.message
183 : "couldn't initiate login",
184 }),
185 ),
186 )
187 }
188 }),
189 )
190
191 // Logout handler
192 router.post(
193 '/logout',
194 handler(async (req, res) => {
195 // Never store this route
196 res.setHeader('cache-control', 'no-store')
197
198 const session = await getIronSession<Session>(req, res, {
199 cookieName: 'sid',
200 password: env.COOKIE_SECRET,
201 })
202
203 // Revoke credentials on the server
204 if (session.did) {
205 try {
206 const oauthSession = await ctx.oauthClient.restore(session.did)
207 if (oauthSession) await oauthSession.signOut()
208 } catch (err) {
209 ctx.logger.warn({ err }, 'Failed to revoke credentials')
210 }
211 }
212
213 session.destroy()
214
215 return res.redirect('/')
216 }),
217 )
218
219 // Homepage
220 router.get(
221 '/',
222 handler(async (req, res) => {
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 // Fetch additional information about the logged-in user
253 const profileResponse = await agent.com.atproto.repo
254 .getRecord({
255 repo: agent.assertDid,
256 collection: 'app.bsky.actor.profile',
257 rkey: 'self',
258 })
259 .catch(() => undefined)
260
261 const profileRecord = profileResponse?.data
262
263 const profile =
264 profileRecord &&
265 Profile.isRecord(profileRecord.value) &&
266 Profile.validateRecord(profileRecord.value).success
267 ? profileRecord.value
268 : {}
269
270 // Serve the logged-in view
271 res
272 .type('html')
273 .send(page(home({ statuses, didHandleMap, profile, myStatus })))
274 }),
275 )
276
277 // "Set status" handler
278 router.post(
279 '/status',
280 express.urlencoded(),
281 handler(async (req, res) => {
282 // If the user is signed in, get an agent which communicates with their server
283 const agent = await getSessionAgent(req, res, ctx)
284 if (!agent) {
285 return res
286 .status(401)
287 .type('html')
288 .send('<h1>Error: Session required</h1>')
289 }
290
291 // Construct their status record
292 const record = {
293 $type: 'xyz.statusphere.status',
294 status: req.body?.status,
295 createdAt: new Date().toISOString(),
296 }
297
298 // Make sure the record generated from the input is valid
299 if (!Status.validateRecord(record).success) {
300 return res
301 .status(400)
302 .type('html')
303 .send('<h1>Error: Invalid status</h1>')
304 }
305
306 let uri
307 try {
308 // Write the status record to the user's repository
309 const res = await agent.com.atproto.repo.putRecord({
310 repo: agent.assertDid,
311 collection: 'xyz.statusphere.status',
312 rkey: TID.nextStr(),
313 record,
314 validate: false,
315 })
316 uri = res.data.uri
317 } catch (err) {
318 ctx.logger.warn({ err }, 'failed to write record')
319 return res
320 .status(500)
321 .type('html')
322 .send('<h1>Error: Failed to write record</h1>')
323 }
324
325 try {
326 // Optimistically update our SQLite
327 // This isn't strictly necessary because the write event will be
328 // handled in #/firehose/ingestor.ts, but it ensures that future reads
329 // will be up-to-date after this method finishes.
330 await ctx.db
331 .insertInto('status')
332 .values({
333 uri,
334 authorDid: agent.assertDid,
335 status: record.status,
336 createdAt: record.createdAt,
337 indexedAt: new Date().toISOString(),
338 })
339 .execute()
340 } catch (err) {
341 ctx.logger.warn(
342 { err },
343 'failed to update computed view; ignoring as it should be caught by the firehose',
344 )
345 }
346
347 return res.redirect('/')
348 }),
349 )
350
351 return router
352}