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