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