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 })