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/xyz/statusphere/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 profileResponse = await agent.com.atproto.repo.getRecord({
180 repo: agent.assertDid,
181 collection: 'app.bsky.actor.profile',
182 rkey: 'self',
183 }).catch(() => undefined);
184
185 const profileRecord = profileResponse?.data;
186
187 const profile = profileRecord &&
188 Profile.isRecord(profileRecord.value) &&
189 Profile.validateRecord(profileRecord.value).success
190 ? profileRecord.value
191 : {}
192
193 // Serve the logged-in view
194 return res.type('html').send(
195 page(
196 home({
197 statuses,
198 didHandleMap,
199 profile,
200 myStatus,
201 })
202 )
203 )
204 })
205 )
206
207 // "Set status" handler
208 router.post(
209 '/status',
210 handler(async (req, res) => {
211 // If the user is signed in, get an agent which communicates with their server
212 const agent = await getSessionAgent(req, res, ctx)
213 if (!agent) {
214 return res
215 .status(401)
216 .type('html')
217 .send('<h1>Error: Session required</h1>')
218 }
219
220 // Construct & validate their status record
221 const rkey = TID.nextStr()
222 const record = {
223 $type: 'xyz.statusphere.status',
224 status: req.body?.status,
225 createdAt: new Date().toISOString(),
226 }
227 if (!Status.validateRecord(record).success) {
228 return res
229 .status(400)
230 .type('html')
231 .send('<h1>Error: Invalid status</h1>')
232 }
233
234 let uri
235 try {
236 // Write the status record to the user's repository
237 const res = await agent.com.atproto.repo.putRecord({
238 repo: agent.assertDid,
239 collection: 'xyz.statusphere.status',
240 rkey,
241 record,
242 validate: false,
243 })
244 uri = res.data.uri
245 } catch (err) {
246 ctx.logger.warn({ err }, 'failed to write record')
247 return res
248 .status(500)
249 .type('html')
250 .send('<h1>Error: Failed to write record</h1>')
251 }
252
253 try {
254 // Optimistically update our SQLite
255 // This isn't strictly necessary because the write event will be
256 // handled in #/firehose/ingestor.ts, but it ensures that future reads
257 // will be up-to-date after this method finishes.
258 await ctx.db
259 .insertInto('status')
260 .values({
261 uri,
262 authorDid: agent.assertDid,
263 status: record.status,
264 createdAt: record.createdAt,
265 indexedAt: new Date().toISOString(),
266 })
267 .execute()
268 } catch (err) {
269 ctx.logger.warn(
270 { err },
271 'failed to update computed view; ignoring as it should be caught by the firehose'
272 )
273 }
274
275 return res.redirect('/')
276 })
277 )
278
279 return router
280}