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