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