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' 13 14export const wispRoutes = (client: NodeOAuthClient) => 15 new Elysia({ prefix: '/wisp' }) 16 .derive(async ({ cookie }) => { 17 const auth = await requireAuth(client, cookie) 18 return { auth } 19 }) 20 .post( 21 '/upload-files', 22 async ({ body, auth }) => { 23 const { siteName, files } = body as { 24 siteName: string; 25 files: File | File[] 26 }; 27 28 console.log('🚀 Starting upload process', { siteName, fileCount: Array.isArray(files) ? files.length : 1 }); 29 30 try { 31 if (!siteName) { 32 console.error('❌ Site name is required'); 33 throw new Error('Site name is required') 34 } 35 36 console.log('✅ Initial validation passed'); 37 38 // Check if files were provided 39 const hasFiles = files && (Array.isArray(files) ? files.length > 0 : !!files); 40 41 if (!hasFiles) { 42 console.log('📝 Creating empty site (no files provided)'); 43 44 // Create agent with OAuth session 45 console.log('🔐 Creating agent with OAuth session'); 46 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 47 console.log('✅ Agent created successfully'); 48 49 // Create empty manifest 50 const emptyManifest = { 51 $type: 'place.wisp.fs', 52 site: siteName, 53 root: { 54 type: 'directory', 55 entries: [] 56 }, 57 fileCount: 0, 58 createdAt: new Date().toISOString() 59 }; 60 61 // Use site name as rkey 62 const rkey = siteName; 63 64 // Create the record with explicit rkey 65 console.log(`📝 Creating empty site record in repo with rkey: ${rkey}`); 66 const record = await agent.com.atproto.repo.putRecord({ 67 repo: auth.did, 68 collection: 'place.wisp.fs', 69 rkey: rkey, 70 record: emptyManifest 71 }); 72 73 console.log('✅ Empty site record created successfully:', { 74 uri: record.data.uri, 75 cid: record.data.cid 76 }); 77 78 // Store site in database cache 79 console.log('💾 Storing site in database cache'); 80 await upsertSite(auth.did, rkey, siteName); 81 console.log('✅ Site stored in database'); 82 83 return { 84 success: true, 85 uri: record.data.uri, 86 cid: record.data.cid, 87 fileCount: 0, 88 siteName 89 }; 90 } 91 92 // Create agent with OAuth session 93 console.log('🔐 Creating agent with OAuth session'); 94 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 95 console.log('✅ Agent created successfully'); 96 97 // Convert File objects to UploadedFile format 98 // Elysia gives us File objects directly, handle both single file and array 99 const fileArray = Array.isArray(files) ? files : [files]; 100 console.log(`📁 Processing ${fileArray.length} files`); 101 const uploadedFiles: UploadedFile[] = []; 102 103 // Define allowed file extensions for static site hosting 104 const allowedExtensions = new Set([ 105 // HTML 106 '.html', '.htm', 107 // CSS 108 '.css', 109 // JavaScript 110 '.js', '.mjs', '.jsx', '.ts', '.tsx', 111 // Images 112 '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif', 113 // Fonts 114 '.woff', '.woff2', '.ttf', '.otf', '.eot', 115 // Documents 116 '.pdf', '.txt', 117 // JSON (for config files, but not .map files) 118 '.json', 119 // Audio/Video 120 '.mp3', '.mp4', '.webm', '.ogg', '.wav', 121 // Other web assets 122 '.xml', '.rss', '.atom' 123 ]); 124 125 // Files to explicitly exclude 126 const excludedFiles = new Set([ 127 '.map', '.DS_Store', 'Thumbs.db' 128 ]); 129 130 for (let i = 0; i < fileArray.length; i++) { 131 const file = fileArray[i]; 132 const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); 133 134 console.log(`📄 Processing file ${i + 1}/${fileArray.length}: ${file.name} (${file.size} bytes, ${file.type})`); 135 136 // Skip excluded files 137 if (excludedFiles.has(fileExtension)) { 138 console.log(`⏭️ Skipping excluded file: ${file.name}`); 139 continue; 140 } 141 142 // Skip files that aren't in allowed extensions 143 if (!allowedExtensions.has(fileExtension)) { 144 console.log(`⏭️ Skipping non-web file: ${file.name} (${fileExtension})`); 145 continue; 146 } 147 148 // Skip files that are too large (limit to 100MB per file) 149 const maxSize = 100 * 1024 * 1024; // 100MB 150 if (file.size > maxSize) { 151 console.log(`⏭️ Skipping large file: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB limit)`); 152 continue; 153 } 154 155 console.log(`✅ Including file: ${file.name}`); 156 const arrayBuffer = await file.arrayBuffer(); 157 uploadedFiles.push({ 158 name: file.name, 159 content: Buffer.from(arrayBuffer), 160 mimeType: file.type || 'application/octet-stream', 161 size: file.size 162 }); 163 } 164 165 // Check total size limit (300MB) 166 const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0); 167 const maxTotalSize = 300 * 1024 * 1024; // 300MB 168 169 console.log(`📊 Filtered to ${uploadedFiles.length} files from ${fileArray.length} total files`); 170 console.log(`📦 Total size: ${(totalSize / 1024 / 1024).toFixed(2)}MB (limit: 300MB)`); 171 172 if (totalSize > maxTotalSize) { 173 throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`); 174 } 175 176 if (uploadedFiles.length === 0) { 177 console.log('⚠️ No valid web files found, creating empty site instead'); 178 179 // Create empty manifest 180 const emptyManifest = { 181 $type: 'place.wisp.fs', 182 site: siteName, 183 root: { 184 type: 'directory', 185 entries: [] 186 }, 187 fileCount: 0, 188 createdAt: new Date().toISOString() 189 }; 190 191 // Use site name as rkey 192 const rkey = siteName; 193 194 // Create the record with explicit rkey 195 console.log(`📝 Creating empty site record in repo with rkey: ${rkey}`); 196 const record = await agent.com.atproto.repo.putRecord({ 197 repo: auth.did, 198 collection: 'place.wisp.fs', 199 rkey: rkey, 200 record: emptyManifest 201 }); 202 203 console.log('✅ Empty site record created successfully:', { 204 uri: record.data.uri, 205 cid: record.data.cid 206 }); 207 208 // Store site in database cache 209 console.log('💾 Storing site in database cache'); 210 await upsertSite(auth.did, rkey, siteName); 211 console.log('✅ Site stored in database'); 212 213 return { 214 success: true, 215 uri: record.data.uri, 216 cid: record.data.cid, 217 fileCount: 0, 218 siteName, 219 message: 'Site created but no valid web files were found to upload' 220 }; 221 } 222 223 console.log('✅ File conversion completed'); 224 225 // Process files into directory structure 226 console.log('🏗️ Building directory structure'); 227 const { directory, fileCount } = processUploadedFiles(uploadedFiles); 228 console.log(`✅ Directory structure created with ${fileCount} files`); 229 230 // Upload files as blobs 231 const uploadResults: FileUploadResult[] = []; 232 const filePaths: string[] = []; 233 234 console.log('⬆️ Starting blob upload process'); 235 for (let i = 0; i < uploadedFiles.length; i++) { 236 const file = uploadedFiles[i]; 237 console.log(`📤 Uploading blob ${i + 1}/${uploadedFiles.length}: ${file.name}`); 238 239 try { 240 console.log(`🔍 Upload details:`, { 241 fileName: file.name, 242 fileSize: file.size, 243 mimeType: file.mimeType, 244 contentLength: file.content.length 245 }); 246 247 const uploadResult = await agent.com.atproto.repo.uploadBlob( 248 file.content, 249 { 250 encoding: file.mimeType 251 } 252 ); 253 254 console.log(`✅ Upload successful for ${file.name}:`, { 255 hash: uploadResult.data.blob.ref.toString(), 256 mimeType: uploadResult.data.blob.mimeType, 257 size: uploadResult.data.blob.size 258 }); 259 260 uploadResults.push({ 261 hash: uploadResult.data.blob.ref.toString(), 262 blobRef: uploadResult.data.blob 263 }); 264 265 filePaths.push(file.name); 266 } catch (uploadError) { 267 console.error(`❌ Upload failed for file ${file.name}:`, uploadError); 268 console.error('Upload error details:', { 269 fileName: file.name, 270 fileSize: file.size, 271 mimeType: file.mimeType, 272 error: uploadError 273 }); 274 throw uploadError; 275 } 276 } 277 278 console.log('✅ All blobs uploaded successfully'); 279 280 // Update directory with file blobs 281 console.log('🔄 Updating file blobs in directory structure'); 282 const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths); 283 console.log('✅ File blobs updated'); 284 285 // Create manifest 286 console.log('📋 Creating manifest'); 287 const manifest = createManifest(siteName, updatedDirectory, fileCount); 288 console.log('✅ Manifest created'); 289 290 // Use site name as rkey 291 const rkey = siteName; 292 293 // Create the record with explicit rkey 294 console.log(`📝 Creating record in repo with rkey: ${rkey}`); 295 const record = await agent.com.atproto.repo.putRecord({ 296 repo: auth.did, 297 collection: 'place.wisp.fs', 298 rkey: rkey, 299 record: manifest 300 }); 301 302 console.log('✅ Record created successfully:', { 303 uri: record.data.uri, 304 cid: record.data.cid 305 }); 306 307 // Store site in database cache 308 console.log('💾 Storing site in database cache'); 309 await upsertSite(auth.did, rkey, siteName); 310 console.log('✅ Site stored in database'); 311 312 const result = { 313 success: true, 314 uri: record.data.uri, 315 cid: record.data.cid, 316 fileCount, 317 siteName 318 }; 319 320 console.log('🎉 Upload process completed successfully'); 321 return result; 322 } catch (error) { 323 console.error('❌ Upload error:', error); 324 console.error('Error details:', { 325 message: error instanceof Error ? error.message : 'Unknown error', 326 stack: error instanceof Error ? error.stack : undefined, 327 name: error instanceof Error ? error.name : undefined 328 }); 329 throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`); 330 } 331 } 332 )