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 '#/view'
10import * as Status from '#/lexicon/types/com/example/status'
11
12const handler =
13 (fn: express.Handler) =>
14 async (
15 req: express.Request,
16 res: express.Response,
17 next: express.NextFunction
18 ) => {
19 try {
20 await fn(req, res, next)
21 } catch (err) {
22 next(err)
23 }
24 }
25
26export const createRouter = (ctx: AppContext) => {
27 const router = express.Router()
28
29 router.use('/public', express.static(path.join(__dirname, 'pages', 'public')))
30
31 router.get(
32 '/client-metadata.json',
33 handler((_req, res) => {
34 return res.json(ctx.oauthClient.clientMetadata)
35 })
36 )
37
38 router.get(
39 '/oauth/callback',
40 handler(async (req, res) => {
41 const params = new URLSearchParams(req.originalUrl.split('?')[1])
42 try {
43 const { agent } = await ctx.oauthClient.callback(params)
44 await createSession(req, res, agent.accountDid)
45 } catch (err) {
46 ctx.logger.error({ err }, 'oauth callback failed')
47 return res.redirect('/?error')
48 }
49 return res.redirect('/')
50 })
51 )
52
53 router.get(
54 '/login',
55 handler(async (_req, res) => {
56 return res.type('html').send(page(login({})))
57 })
58 )
59
60 router.post(
61 '/login',
62 handler(async (req, res) => {
63 const handle = req.body?.handle
64 if (typeof handle !== 'string' || !isValidHandle(handle)) {
65 return res.type('html').send(page(login({ error: 'invalid handle' })))
66 }
67 try {
68 const url = await ctx.oauthClient.authorize(handle)
69 return res.redirect(url.toString())
70 } catch (err) {
71 ctx.logger.error({ err }, 'oauth authorize failed')
72 return res.type('html').send(
73 page(
74 login({
75 error:
76 err instanceof OAuthResolverError
77 ? err.message
78 : "couldn't initiate login",
79 })
80 )
81 )
82 }
83 })
84 )
85
86 router.post(
87 '/logout',
88 handler(async (req, res) => {
89 await destroySession(req, res)
90 return res.redirect('/')
91 })
92 )
93
94 router.get(
95 '/',
96 handler(async (req, res) => {
97 const agent = await getSessionAgent(req, res, ctx)
98 const statuses = await ctx.db
99 .selectFrom('status')
100 .selectAll()
101 .orderBy('indexedAt', 'desc')
102 .limit(10)
103 .execute()
104 const myStatus = agent
105 ? await ctx.db
106 .selectFrom('status')
107 .selectAll()
108 .where('authorDid', '=', agent.accountDid)
109 .executeTakeFirst()
110 : undefined
111 const didHandleMap = await ctx.resolver.resolveDidsToHandles(
112 statuses.map((s) => s.authorDid)
113 )
114 if (!agent) {
115 return res.type('html').send(page(home({ statuses, didHandleMap })))
116 }
117 const { data: profile } = await agent.getProfile({
118 actor: agent.accountDid,
119 })
120 return res
121 .type('html')
122 .send(page(home({ statuses, didHandleMap, profile, myStatus })))
123 })
124 )
125
126 router.post(
127 '/status',
128 handler(async (req, res) => {
129 const agent = await getSessionAgent(req, res, ctx)
130 if (!agent) {
131 return res.status(401).json({ error: 'Session required' })
132 }
133
134 const record = {
135 $type: 'com.example.status',
136 status: req.body?.status,
137 updatedAt: new Date().toISOString(),
138 }
139 if (!Status.validateRecord(record).success) {
140 return res.status(400).json({ error: 'Invalid status' })
141 }
142
143 try {
144 await agent.com.atproto.repo.putRecord({
145 repo: agent.accountDid,
146 collection: 'com.example.status',
147 rkey: 'self',
148 record,
149 validate: false,
150 })
151 } catch (err) {
152 ctx.logger.warn({ err }, 'failed to write record')
153 return res.status(500).json({ error: 'Failed to write record' })
154 }
155
156 try {
157 await ctx.db
158 .insertInto('status')
159 .values({
160 authorDid: agent.accountDid,
161 status: record.status,
162 updatedAt: record.updatedAt,
163 indexedAt: new Date().toISOString(),
164 })
165 .onConflict((oc) =>
166 oc.column('authorDid').doUpdateSet({
167 status: record.status,
168 updatedAt: record.updatedAt,
169 indexedAt: new Date().toISOString(),
170 })
171 )
172 .execute()
173 } catch (err) {
174 ctx.logger.warn(
175 { err },
176 'failed to update computed view; ignoring as it should be caught by the firehose'
177 )
178 }
179
180 res.status(200).json({})
181 })
182 )
183
184 return router
185}