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