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