Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
1import { Elysia } from 'elysia' 2import { requireAuth, type AuthenticatedContext } from '../lib/wisp-auth' 3import { NodeOAuthClient } from '@atproto/oauth-client-node' 4import { Agent } from '@atproto/api' 5import { 6 type UploadedFile, 7 type FileUploadResult, 8 processUploadedFiles, 9 createManifest, 10 updateFileBlobs 11} from '../lib/wisp-utils' 12import { upsertSite } from '../lib/db' 13import { logger } from '../lib/logger' 14 15function isValidSiteName(siteName: string): boolean { 16 if (!siteName || typeof siteName !== 'string') return false; 17 18 // Length check (AT Protocol rkey limit) 19 if (siteName.length < 1 || siteName.length > 512) return false; 20 21 // Check for path traversal 22 if (siteName === '.' || siteName === '..') return false; 23 if (siteName.includes('/') || siteName.includes('\\')) return false; 24 if (siteName.includes('\0')) return false; 25 26 // AT Protocol rkey format: alphanumeric, dots, dashes, underscores, tildes, colons 27 // Based on NSID format rules 28 const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/; 29 if (!validRkeyPattern.test(siteName)) return false; 30 31 return true; 32} 33 34export const wispRoutes = (client: NodeOAuthClient) => 35 new Elysia({ prefix: '/wisp' }) 36 .derive(async ({ cookie }) => { 37 const auth = await requireAuth(client, cookie) 38 return { auth } 39 }) 40 .post( 41 '/upload-files', 42 async ({ body, auth }) => { 43 const { siteName, files } = body as { 44 siteName: string; 45 files: File | File[] 46 }; 47 48 try { 49 if (!siteName) { 50 throw new Error('Site name is required') 51 } 52 53 if (!isValidSiteName(siteName)) { 54 throw new Error('Invalid site name: must be 1-512 characters and contain only alphanumeric, dots, dashes, underscores, tildes, and colons') 55 } 56 57 // Check if files were provided 58 const hasFiles = files && (Array.isArray(files) ? files.length > 0 : !!files); 59 60 if (!hasFiles) { 61 // Create agent with OAuth session 62 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 63 64 // Create empty manifest 65 const emptyManifest = { 66 $type: 'place.wisp.fs', 67 site: siteName, 68 root: { 69 type: 'directory', 70 entries: [] 71 }, 72 fileCount: 0, 73 createdAt: new Date().toISOString() 74 }; 75 76 // Use site name as rkey 77 const rkey = siteName; 78 79 const record = await agent.com.atproto.repo.putRecord({ 80 repo: auth.did, 81 collection: 'place.wisp.fs', 82 rkey: rkey, 83 record: emptyManifest 84 }); 85 86 await upsertSite(auth.did, rkey, siteName); 87 88 return { 89 success: true, 90 uri: record.data.uri, 91 cid: record.data.cid, 92 fileCount: 0, 93 siteName 94 }; 95 } 96 97 // Create agent with OAuth session 98 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 99 100 // Convert File objects to UploadedFile format 101 // Elysia gives us File objects directly, handle both single file and array 102 const fileArray = Array.isArray(files) ? files : [files]; 103 const uploadedFiles: UploadedFile[] = []; 104 105 // Define allowed file extensions for static site hosting 106 const allowedExtensions = new Set([ 107 // HTML 108 '.html', '.htm', 109 // CSS 110 '.css', 111 // JavaScript 112 '.js', '.mjs', '.jsx', '.ts', '.tsx', 113 // Images 114 '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif', 115 // Fonts 116 '.woff', '.woff2', '.ttf', '.otf', '.eot', 117 // Documents 118 '.pdf', '.txt', 119 // JSON (for config files, but not .map files) 120 '.json', 121 // Audio/Video 122 '.mp3', '.mp4', '.webm', '.ogg', '.wav', 123 // Other web assets 124 '.xml', '.rss', '.atom' 125 ]); 126 127 // Files to explicitly exclude 128 const excludedFiles = new Set([ 129 '.map', '.DS_Store', 'Thumbs.db' 130 ]); 131 132 for (let i = 0; i < fileArray.length; i++) { 133 const file = fileArray[i]; 134 const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); 135 136 // Skip excluded files 137 if (excludedFiles.has(fileExtension)) { 138 continue; 139 } 140 141 // Skip files that aren't in allowed extensions 142 if (!allowedExtensions.has(fileExtension)) { 143 continue; 144 } 145 146 // Skip files that are too large (limit to 100MB per file) 147 const maxSize = 100 * 1024 * 1024; // 100MB 148 if (file.size > maxSize) { 149 continue; 150 } 151 152 const arrayBuffer = await file.arrayBuffer(); 153 uploadedFiles.push({ 154 name: file.name, 155 content: Buffer.from(arrayBuffer), 156 mimeType: 'application/octet-stream', 157 size: file.size 158 }); 159 } 160 161 // Check total size limit (300MB) 162 const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0); 163 const maxTotalSize = 300 * 1024 * 1024; // 300MB 164 165 if (totalSize > maxTotalSize) { 166 throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`); 167 } 168 169 if (uploadedFiles.length === 0) { 170 171 // Create empty manifest 172 const emptyManifest = { 173 $type: 'place.wisp.fs', 174 site: siteName, 175 root: { 176 type: 'directory', 177 entries: [] 178 }, 179 fileCount: 0, 180 createdAt: new Date().toISOString() 181 }; 182 183 // Use site name as rkey 184 const rkey = siteName; 185 186 const record = await agent.com.atproto.repo.putRecord({ 187 repo: auth.did, 188 collection: 'place.wisp.fs', 189 rkey: rkey, 190 record: emptyManifest 191 }); 192 193 await upsertSite(auth.did, rkey, siteName); 194 195 return { 196 success: true, 197 uri: record.data.uri, 198 cid: record.data.cid, 199 fileCount: 0, 200 siteName, 201 message: 'Site created but no valid web files were found to upload' 202 }; 203 } 204 205 // Process files into directory structure 206 const { directory, fileCount } = processUploadedFiles(uploadedFiles); 207 208 // Upload files as blobs in parallel (always as octet-stream) 209 const uploadPromises = uploadedFiles.map(async (file, i) => { 210 try { 211 const uploadResult = await agent.com.atproto.repo.uploadBlob( 212 file.content, 213 { 214 encoding: 'application/octet-stream' 215 } 216 ); 217 218 const sentMimeType = file.mimeType; 219 const returnedBlobRef = uploadResult.data.blob; 220 221 // Use the blob ref exactly as returned from PDS 222 return { 223 result: { 224 hash: returnedBlobRef.ref.toString(), 225 blobRef: returnedBlobRef 226 }, 227 filePath: file.name, 228 sentMimeType, 229 returnedMimeType: returnedBlobRef.mimeType 230 }; 231 } catch (uploadError) { 232 logger.error('[Wisp] Upload failed for file', uploadError); 233 throw uploadError; 234 } 235 }); 236 237 // Wait for all uploads to complete 238 const uploadedBlobs = await Promise.all(uploadPromises); 239 240 // Extract results and file paths in correct order 241 const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result); 242 const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath); 243 244 // Update directory with file blobs 245 const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths); 246 247 // Create manifest 248 const manifest = createManifest(siteName, updatedDirectory, fileCount); 249 250 // Use site name as rkey 251 const rkey = siteName; 252 253 let record; 254 try { 255 record = await agent.com.atproto.repo.putRecord({ 256 repo: auth.did, 257 collection: 'place.wisp.fs', 258 rkey: rkey, 259 record: manifest 260 }); 261 } catch (putRecordError: any) { 262 logger.error('[Wisp] Failed to create record on PDS'); 263 logger.error('[Wisp] Record creation error', putRecordError); 264 265 throw putRecordError; 266 } 267 268 // Store site in database cache 269 await upsertSite(auth.did, rkey, siteName); 270 271 const result = { 272 success: true, 273 uri: record.data.uri, 274 cid: record.data.cid, 275 fileCount, 276 siteName 277 }; 278 279 return result; 280 } catch (error) { 281 logger.error('[Wisp] Upload error', error); 282 logger.errorWithContext('[Wisp] Upload error details', { 283 message: error instanceof Error ? error.message : 'Unknown error', 284 name: error instanceof Error ? error.name : undefined 285 }, error); 286 throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`); 287 } 288 } 289 )