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