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 '#/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 agent = await getSessionAgent(req, res, ctx)
85 const statuses = await ctx.db
86 .selectFrom('status')
87 .selectAll()
88 .orderBy('indexedAt', 'desc')
89 .limit(10)
90 .execute()
91 const myStatus = agent
92 ? await ctx.db
93 .selectFrom('status')
94 .selectAll()
95 .where('authorDid', '=', agent.accountDid)
96 .executeTakeFirst()
97 : undefined
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({
105 actor: agent.accountDid,
106 })
107 return res
108 .type('html')
109 .send(page(home({ statuses, didHandleMap, profile, myStatus })))
110 })
111 )
112
113 router.post(
114 '/status',
115 handler(async (req, res) => {
116 const agent = await getSessionAgent(req, res, ctx)
117 if (!agent) {
118 return res.status(401).json({ error: 'Session required' })
119 }
120
121 const record = {
122 $type: 'com.example.status',
123 status: req.body?.status,
124 updatedAt: new Date().toISOString(),
125 }
126 if (!Status.validateRecord(record).success) {
127 return res.status(400).json({ error: 'Invalid status' })
128 }
129
130 try {
131 await agent.com.atproto.repo.putRecord({
132 repo: agent.accountDid,
133 collection: 'com.example.status',
134 rkey: 'self',
135 record,
136 validate: false,
137 })
138 } catch (err) {
139 ctx.logger.warn({ err }, 'failed to write record')
140 return res.status(500).json({ error: 'Failed to write record' })
141 }
142
143 try {
144 await ctx.db
145 .insertInto('status')
146 .values({
147 authorDid: agent.accountDid,
148 status: record.status,
149 updatedAt: record.updatedAt,
150 indexedAt: new Date().toISOString(),
151 })
152 .onConflict((oc) =>
153 oc.column('authorDid').doUpdateSet({
154 status: record.status,
155 updatedAt: record.updatedAt,
156 indexedAt: new Date().toISOString(),
157 })
158 )
159 .execute()
160 } catch (err) {
161 ctx.logger.warn(
162 { err },
163 'failed to update computed view; ignoring as it should be caught by the firehose'
164 )
165 }
166
167 res.status(200).json({})
168 })
169 )
170
171 return router
172}