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