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