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