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