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