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 didHandleMap = await ctx.resolver.resolveDidsToHandles(
99 statuses.map((s) => s.authorDid)
100 )
101 if (!agent) {
102 return res.type('html').send(page(home({ statuses, didHandleMap })))
103 }
104 const { data: profile } = await agent.getProfile({ actor: session.did })
105 return res
106 .type('html')
107 .send(page(home({ statuses, didHandleMap, profile })))
108 })
109 )
110
111 router.post(
112 '/status',
113 handler(async (req, res) => {
114 const session = await getSession(req, res)
115 const agent =
116 session &&
117 (await ctx.oauthClient.restore(session.did).catch(async (err) => {
118 ctx.logger.warn({ err }, 'oauth restore failed')
119 await destroySession(req, res)
120 return null
121 }))
122 if (!agent) {
123 return res.status(401).json({ error: 'Session required' })
124 }
125
126 const record = {
127 $type: 'com.example.status',
128 status: req.body?.status,
129 updatedAt: new Date().toISOString(),
130 }
131 if (!Status.validateRecord(record).success) {
132 return res.status(400).json({ error: 'Invalid status' })
133 }
134
135 try {
136 await agent.com.atproto.repo.putRecord({
137 repo: agent.accountDid,
138 collection: 'com.example.status',
139 rkey: 'self',
140 record,
141 validate: false,
142 })
143 } catch (err) {
144 ctx.logger.warn({ err }, 'failed to write record')
145 return res.status(500).json({ error: 'Failed to write record' })
146 }
147
148 try {
149 await ctx.db
150 .insertInto('status')
151 .values({
152 authorDid: agent.accountDid,
153 status: record.status,
154 updatedAt: record.updatedAt,
155 indexedAt: new Date().toISOString(),
156 })
157 .onConflict((oc) =>
158 oc.column('authorDid').doUpdateSet({
159 status: record.status,
160 updatedAt: record.updatedAt,
161 indexedAt: new Date().toISOString(),
162 })
163 )
164 .execute()
165 } catch (err) {
166 ctx.logger.warn(
167 { err },
168 'failed to update computed view; ignoring as it should be caught by the firehose'
169 )
170 }
171
172 res.status(200).json({})
173 })
174 )
175
176 return router
177}