Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
at main 6.2 kB view raw
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 })