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 { agent } = await ctx.oauthClient.callback(params)
75 const session = await getIronSession<Session>(req, res, {
76 cookieName: 'sid',
77 password: env.COOKIE_SECRET,
78 })
79 assert(!session.did, 'session already exists')
80 session.did = agent.accountDid
81 await session.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 return res.redirect(url.toString())
112 } catch (err) {
113 ctx.logger.error({ err }, 'oauth authorize failed')
114 return res.type('html').send(
115 page(
116 login({
117 error:
118 err instanceof OAuthResolverError
119 ? err.message
120 : "couldn't initiate login",
121 })
122 )
123 )
124 }
125 })
126 )
127
128 // Logout handler
129 router.post(
130 '/logout',
131 handler(async (req, res) => {
132 const session = await getIronSession<Session>(req, res, {
133 cookieName: 'sid',
134 password: env.COOKIE_SECRET,
135 })
136 await session.destroy()
137 return res.redirect('/')
138 })
139 )
140
141 // Homepage
142 router.get(
143 '/',
144 handler(async (req, res) => {
145 // If the user is signed in, get an agent which communicates with their server
146 const agent = await getSessionAgent(req, res, ctx)
147
148 // Fetch data stored in our SQLite
149 const statuses = await ctx.db
150 .selectFrom('status')
151 .selectAll()
152 .orderBy('indexedAt', 'desc')
153 .limit(10)
154 .execute()
155 const myStatus = agent
156 ? await ctx.db
157 .selectFrom('status')
158 .selectAll()
159 .where('authorDid', '=', agent.accountDid)
160 .orderBy('indexedAt', 'desc')
161 .executeTakeFirst()
162 : undefined
163
164 // Map user DIDs to their domain-name handles
165 const didHandleMap = await ctx.resolver.resolveDidsToHandles(
166 statuses.map((s) => s.authorDid)
167 )
168
169 if (!agent) {
170 // Serve the logged-out view
171 return res.type('html').send(page(home({ statuses, didHandleMap })))
172 }
173
174 // Fetch additional information about the logged-in user
175 const { data: profileRecord } = await agent.com.atproto.repo.getRecord({
176 repo: agent.accountDid,
177 collection: 'app.bsky.actor.profile',
178 rkey: 'self',
179 })
180 const profile =
181 Profile.isRecord(profileRecord.value) &&
182 Profile.validateRecord(profileRecord.value).success
183 ? profileRecord.value
184 : {}
185
186 // Serve the logged-in view
187 return res.type('html').send(
188 page(
189 home({
190 statuses,
191 didHandleMap,
192 profile,
193 myStatus,
194 })
195 )
196 )
197 })
198 )
199
200 // "Set status" handler
201 router.post(
202 '/status',
203 handler(async (req, res) => {
204 // If the user is signed in, get an agent which communicates with their server
205 const agent = await getSessionAgent(req, res, ctx)
206 if (!agent) {
207 return res
208 .status(401)
209 .type('html')
210 .send('<h1>Error: Session required</h1>')
211 }
212
213 // Construct & validate their status record
214 const rkey = TID.nextStr()
215 const record = {
216 $type: 'com.example.status',
217 status: req.body?.status,
218 createdAt: new Date().toISOString(),
219 }
220 if (!Status.validateRecord(record).success) {
221 return res
222 .status(400)
223 .type('html')
224 .send('<h1>Error: Invalid status</h1>')
225 }
226
227 let uri
228 try {
229 // Write the status record to the user's repository
230 const res = await agent.com.atproto.repo.putRecord({
231 repo: agent.accountDid,
232 collection: 'com.example.status',
233 rkey,
234 record,
235 validate: false,
236 })
237 uri = res.data.uri
238 } catch (err) {
239 ctx.logger.warn({ err }, 'failed to write record')
240 return res
241 .status(500)
242 .type('html')
243 .send('<h1>Error: Failed to write record</h1>')
244 }
245
246 try {
247 // Optimistically update our SQLite
248 // This isn't strictly necessary because the write event will be
249 // handled in #/firehose/ingestor.ts, but it ensures that future reads
250 // will be up-to-date after this method finishes.
251 await ctx.db
252 .insertInto('status')
253 .values({
254 uri,
255 authorDid: agent.accountDid,
256 status: record.status,
257 createdAt: record.createdAt,
258 indexedAt: new Date().toISOString(),
259 })
260 .execute()
261 } catch (err) {
262 ctx.logger.warn(
263 { err },
264 'failed to update computed view; ignoring as it should be caught by the firehose'
265 )
266 }
267
268 return res.redirect('/')
269 })
270 )
271
272 return router
273}