Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { Elysia } from 'elysia'
2import { requireAuth } from '../lib/wisp-auth'
3import { NodeOAuthClient } from '@atproto/oauth-client-node'
4import { Agent } from '@atproto/api'
5import { deleteSite } from '../lib/db'
6import { createLogger } from '@wisp/observability'
7import { extractSubfsUris } from '@wisp/atproto-utils'
8
9const logger = createLogger('main-app')
10
11export const siteRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
12 new Elysia({
13 prefix: '/api/site',
14 cookie: {
15 secrets: cookieSecret,
16 sign: ['did']
17 }
18 })
19 .derive(async ({ cookie }) => {
20 const auth = await requireAuth(client, cookie)
21 return { auth }
22 })
23 .delete('/:rkey', async ({ params, auth }) => {
24 const { rkey } = params
25
26 if (!rkey) {
27 return {
28 success: false,
29 error: 'Site rkey is required'
30 }
31 }
32
33 try {
34 // Create agent with OAuth session
35 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
36
37 // First, fetch the site record to find any subfs references
38 let subfsUris: Array<{ uri: string; path: string }> = [];
39 try {
40 const existingRecord = await agent.com.atproto.repo.getRecord({
41 repo: auth.did,
42 collection: 'place.wisp.fs',
43 rkey: rkey
44 });
45
46 if (existingRecord.data.value && typeof existingRecord.data.value === 'object' && 'root' in existingRecord.data.value) {
47 const manifest = existingRecord.data.value as any;
48 subfsUris = extractSubfsUris(manifest.root);
49
50 if (subfsUris.length > 0) {
51 console.log(`Found ${subfsUris.length} subfs records to delete`);
52 logger.info(`[Site] Found ${subfsUris.length} subfs records associated with ${rkey}`);
53 }
54 }
55 } catch (err) {
56 // Record might not exist, continue with deletion
57 console.log('Could not fetch site record for subfs cleanup, continuing...');
58 }
59
60 // Delete the main record from AT Protocol
61 try {
62 await agent.com.atproto.repo.deleteRecord({
63 repo: auth.did,
64 collection: 'place.wisp.fs',
65 rkey: rkey
66 })
67 logger.info(`[Site] Deleted site ${rkey} from PDS for ${auth.did}`)
68 } catch (err) {
69 logger.error(`[Site] Failed to delete site ${rkey} from PDS`, err)
70 throw new Error('Failed to delete site from AT Protocol')
71 }
72
73 // Delete associated subfs records
74 if (subfsUris.length > 0) {
75 console.log(`Deleting ${subfsUris.length} associated subfs records...`);
76
77 await Promise.all(
78 subfsUris.map(async ({ uri }) => {
79 try {
80 // Parse URI: at://did/collection/rkey
81 const parts = uri.replace('at://', '').split('/');
82 const subRkey = parts[2];
83
84 await agent.com.atproto.repo.deleteRecord({
85 repo: auth.did,
86 collection: 'place.wisp.subfs',
87 rkey: subRkey
88 });
89
90 console.log(` 🗑️ Deleted subfs: ${uri}`);
91 logger.info(`[Site] Deleted subfs record: ${uri}`);
92 } catch (err: any) {
93 // Log but don't fail if subfs deletion fails
94 console.warn(`Failed to delete subfs ${uri}:`, err?.message);
95 logger.warn(`[Site] Failed to delete subfs ${uri}`, err);
96 }
97 })
98 );
99
100 logger.info(`[Site] Deleted ${subfsUris.length} subfs records for ${rkey}`);
101 }
102
103 // Delete from database
104 const result = await deleteSite(auth.did, rkey)
105 if (!result.success) {
106 throw new Error('Failed to delete site from database')
107 }
108
109 logger.info(`[Site] Successfully deleted site ${rkey} for ${auth.did}`)
110
111 return {
112 success: true,
113 message: 'Site deleted successfully'
114 }
115 } catch (err) {
116 logger.error('[Site] Delete error', err)
117 return {
118 success: false,
119 error: err instanceof Error ? err.message : 'Failed to delete site'
120 }
121 }
122 })
123 .get('/:rkey/settings', async ({ params, auth }) => {
124 const { rkey } = params
125
126 if (!rkey) {
127 return {
128 success: false,
129 error: 'Site rkey is required'
130 }
131 }
132
133 try {
134 // Create agent with OAuth session
135 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
136
137 // Fetch settings record
138 try {
139 const record = await agent.com.atproto.repo.getRecord({
140 repo: auth.did,
141 collection: 'place.wisp.settings',
142 rkey: rkey
143 })
144
145 if (record.data.value) {
146 return record.data.value
147 }
148 } catch (err: any) {
149 // Record doesn't exist, return defaults
150 if (err?.error === 'RecordNotFound') {
151 return {
152 indexFiles: ['index.html'],
153 cleanUrls: false,
154 directoryListing: false
155 }
156 }
157 throw err
158 }
159
160 // Default settings
161 return {
162 indexFiles: ['index.html'],
163 cleanUrls: false,
164 directoryListing: false
165 }
166 } catch (err) {
167 logger.error('[Site] Get settings error', err)
168 return {
169 success: false,
170 error: err instanceof Error ? err.message : 'Failed to fetch settings'
171 }
172 }
173 })
174 .post('/:rkey/settings', async ({ params, body, auth }) => {
175 const { rkey } = params
176
177 if (!rkey) {
178 return {
179 success: false,
180 error: 'Site rkey is required'
181 }
182 }
183
184 // Validate settings
185 const settings = body as any
186
187 // Ensure mutual exclusivity of routing modes
188 const modes = [
189 settings.spaMode,
190 settings.directoryListing,
191 settings.custom404
192 ].filter(Boolean)
193
194 if (modes.length > 1) {
195 return {
196 success: false,
197 error: 'Only one of spaMode, directoryListing, or custom404 can be enabled'
198 }
199 }
200
201 try {
202 // Create agent with OAuth session
203 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
204
205 // Create or update settings record
206 const record = await agent.com.atproto.repo.putRecord({
207 repo: auth.did,
208 collection: 'place.wisp.settings',
209 rkey: rkey,
210 record: {
211 $type: 'place.wisp.settings',
212 ...settings
213 }
214 })
215
216 logger.info(`[Site] Saved settings for ${rkey} (${auth.did})`)
217
218 return {
219 success: true,
220 uri: record.data.uri,
221 cid: record.data.cid
222 }
223 } catch (err) {
224 logger.error('[Site] Save settings error', err)
225 return {
226 success: false,
227 error: err instanceof Error ? err.message : 'Failed to save settings'
228 }
229 }
230 })