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