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 const skippedFiles: Array<{ name: string; reason: string }> = []; 105 106 // Define allowed file extensions for static site hosting 107 const allowedExtensions = new Set([ 108 // HTML 109 '.html', '.htm', 110 // CSS 111 '.css', 112 // JavaScript 113 '.js', '.mjs', '.jsx', '.ts', '.tsx', 114 // Images 115 '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif', 116 // Fonts 117 '.woff', '.woff2', '.ttf', '.otf', '.eot', 118 // Documents 119 '.pdf', '.txt', 120 // JSON (for config files, but not .map files) 121 '.json', 122 // Audio/Video 123 '.mp3', '.mp4', '.webm', '.ogg', '.wav', 124 // Other web assets 125 '.xml', '.rss', '.atom' 126 ]); 127 128 // Files to explicitly exclude 129 const excludedFiles = new Set([ 130 '.map', '.DS_Store', 'Thumbs.db' 131 ]); 132 133 for (let i = 0; i < fileArray.length; i++) { 134 const file = fileArray[i]; 135 const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); 136 137 // Skip excluded files 138 if (excludedFiles.has(fileExtension)) { 139 skippedFiles.push({ name: file.name, reason: 'excluded file type' }); 140 continue; 141 } 142 143 // Skip files that aren't in allowed extensions 144 if (!allowedExtensions.has(fileExtension)) { 145 skippedFiles.push({ name: file.name, reason: 'unsupported file type' }); 146 continue; 147 } 148 149 // Skip files that are too large (limit to 100MB per file) 150 const maxSize = 100 * 1024 * 1024; // 100MB 151 if (file.size > maxSize) { 152 skippedFiles.push({ 153 name: file.name, 154 reason: `file too large (${(file.size / 1024 / 1024).toFixed(2)}MB, max 100MB)` 155 }); 156 continue; 157 } 158 159 const arrayBuffer = await file.arrayBuffer(); 160 uploadedFiles.push({ 161 name: file.name, 162 content: Buffer.from(arrayBuffer), 163 mimeType: 'application/octet-stream', 164 size: file.size 165 }); 166 } 167 168 // Check total size limit (300MB) 169 const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0); 170 const maxTotalSize = 300 * 1024 * 1024; // 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 178 // Create empty manifest 179 const emptyManifest = { 180 $type: 'place.wisp.fs', 181 site: siteName, 182 root: { 183 type: 'directory', 184 entries: [] 185 }, 186 fileCount: 0, 187 createdAt: new Date().toISOString() 188 }; 189 190 // Use site name as rkey 191 const rkey = siteName; 192 193 const record = await agent.com.atproto.repo.putRecord({ 194 repo: auth.did, 195 collection: 'place.wisp.fs', 196 rkey: rkey, 197 record: emptyManifest 198 }); 199 200 await upsertSite(auth.did, rkey, siteName); 201 202 return { 203 success: true, 204 uri: record.data.uri, 205 cid: record.data.cid, 206 fileCount: 0, 207 siteName, 208 skippedFiles, 209 message: 'Site created but no valid web files were found to upload' 210 }; 211 } 212 213 // Process files into directory structure 214 const { directory, fileCount } = processUploadedFiles(uploadedFiles); 215 216 // Upload files as blobs in parallel (always as octet-stream) 217 const uploadPromises = uploadedFiles.map(async (file, i) => { 218 try { 219 const uploadResult = await agent.com.atproto.repo.uploadBlob( 220 file.content, 221 { 222 encoding: 'application/octet-stream' 223 } 224 ); 225 226 const sentMimeType = file.mimeType; 227 const returnedBlobRef = uploadResult.data.blob; 228 229 // Use the blob ref exactly as returned from PDS 230 return { 231 result: { 232 hash: returnedBlobRef.ref.toString(), 233 blobRef: returnedBlobRef 234 }, 235 filePath: file.name, 236 sentMimeType, 237 returnedMimeType: returnedBlobRef.mimeType 238 }; 239 } catch (uploadError) { 240 logger.error('[Wisp] Upload failed for file', uploadError); 241 throw uploadError; 242 } 243 }); 244 245 // Wait for all uploads to complete 246 const uploadedBlobs = await Promise.all(uploadPromises); 247 248 // Extract results and file paths in correct order 249 const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result); 250 const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath); 251 252 // Update directory with file blobs 253 const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths); 254 255 // Create manifest 256 const manifest = createManifest(siteName, updatedDirectory, fileCount); 257 258 // Use site name as rkey 259 const rkey = siteName; 260 261 let record; 262 try { 263 record = await agent.com.atproto.repo.putRecord({ 264 repo: auth.did, 265 collection: 'place.wisp.fs', 266 rkey: rkey, 267 record: manifest 268 }); 269 } catch (putRecordError: any) { 270 logger.error('[Wisp] Failed to create record on PDS'); 271 logger.error('[Wisp] Record creation error', putRecordError); 272 273 throw putRecordError; 274 } 275 276 // Store site in database cache 277 await upsertSite(auth.did, rkey, siteName); 278 279 const result = { 280 success: true, 281 uri: record.data.uri, 282 cid: record.data.cid, 283 fileCount, 284 siteName, 285 skippedFiles, 286 uploadedCount: uploadedFiles.length 287 }; 288 289 return result; 290 } catch (error) { 291 logger.error('[Wisp] Upload error', error); 292 logger.errorWithContext('[Wisp] Upload error details', { 293 message: error instanceof Error ? error.message : 'Unknown error', 294 name: error instanceof Error ? error.name : undefined 295 }, error); 296 throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`); 297 } 298 } 299 )