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