Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import { Hono } from 'hono'; 2import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 3import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils'; 4import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 5import { existsSync, readFileSync } from 'fs'; 6import { lookup } from 'mime-types'; 7import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 8 9const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 10 11/** 12 * Validate site name (rkey) to prevent injection attacks 13 * Must match AT Protocol rkey format 14 */ 15function isValidRkey(rkey: string): boolean { 16 if (!rkey || typeof rkey !== 'string') return false; 17 if (rkey.length < 1 || rkey.length > 512) return false; 18 if (rkey === '.' || rkey === '..') return false; 19 if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false; 20 const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/; 21 return validRkeyPattern.test(rkey); 22} 23 24// Helper to serve files from cache 25async function serveFromCache(did: string, rkey: string, filePath: string) { 26 // Default to index.html if path is empty or ends with / 27 let requestPath = filePath || 'index.html'; 28 if (requestPath.endsWith('/')) { 29 requestPath += 'index.html'; 30 } 31 32 const cachedFile = getCachedFilePath(did, rkey, requestPath); 33 34 if (existsSync(cachedFile)) { 35 const content = readFileSync(cachedFile); 36 const metaFile = `${cachedFile}.meta`; 37 38 console.log(`[DEBUG SERVE] ${requestPath}: file size=${content.length} bytes, metaFile exists=${existsSync(metaFile)}`); 39 40 // Check if file has compression metadata 41 if (existsSync(metaFile)) { 42 const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 43 console.log(`[DEBUG SERVE] ${requestPath}: meta=${JSON.stringify(meta)}`); 44 45 // Check actual content for gzip magic bytes 46 if (content.length >= 2) { 47 const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b; 48 const byte0 = content[0]; 49 const byte1 = content[1]; 50 console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic} (0x${byte0?.toString(16)}, 0x${byte1?.toString(16)})`); 51 } 52 53 if (meta.encoding === 'gzip' && meta.mimeType) { 54 // Don't serve already-compressed media formats with Content-Encoding: gzip 55 // These formats (video, audio, images) are already compressed and the browser 56 // can't decode them if we add another layer of compression 57 const alreadyCompressedTypes = [ 58 'video/', 'audio/', 'image/jpeg', 'image/jpg', 'image/png', 59 'image/gif', 'image/webp', 'application/pdf' 60 ]; 61 62 const isAlreadyCompressed = alreadyCompressedTypes.some(type => 63 meta.mimeType.toLowerCase().startsWith(type) 64 ); 65 66 if (isAlreadyCompressed) { 67 // Decompress the file before serving 68 console.log(`[DEBUG SERVE] ${requestPath}: decompressing already-compressed media type`); 69 const { gunzipSync } = await import('zlib'); 70 const decompressed = gunzipSync(content); 71 console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`); 72 return new Response(decompressed, { 73 headers: { 74 'Content-Type': meta.mimeType, 75 }, 76 }); 77 } 78 79 // Serve gzipped content with proper headers (for HTML, CSS, JS, etc.) 80 console.log(`[DEBUG SERVE] ${requestPath}: serving as gzipped with Content-Encoding header`); 81 return new Response(content, { 82 headers: { 83 'Content-Type': meta.mimeType, 84 'Content-Encoding': 'gzip', 85 }, 86 }); 87 } 88 } 89 90 // Serve non-compressed files normally 91 const mimeType = lookup(cachedFile) || 'application/octet-stream'; 92 return new Response(content, { 93 headers: { 94 'Content-Type': mimeType, 95 }, 96 }); 97 } 98 99 // Try index.html for directory-like paths 100 if (!requestPath.includes('.')) { 101 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 102 if (existsSync(indexFile)) { 103 const content = readFileSync(indexFile); 104 const metaFile = `${indexFile}.meta`; 105 106 // Check if file has compression metadata 107 if (existsSync(metaFile)) { 108 const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 109 if (meta.encoding === 'gzip' && meta.mimeType) { 110 return new Response(content, { 111 headers: { 112 'Content-Type': meta.mimeType, 113 'Content-Encoding': 'gzip', 114 }, 115 }); 116 } 117 } 118 119 return new Response(content, { 120 headers: { 121 'Content-Type': 'text/html; charset=utf-8', 122 }, 123 }); 124 } 125 } 126 127 return new Response('Not Found', { status: 404 }); 128} 129 130// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes 131async function serveFromCacheWithRewrite( 132 did: string, 133 rkey: string, 134 filePath: string, 135 basePath: string 136) { 137 // Default to index.html if path is empty or ends with / 138 let requestPath = filePath || 'index.html'; 139 if (requestPath.endsWith('/')) { 140 requestPath += 'index.html'; 141 } 142 143 const cachedFile = getCachedFilePath(did, rkey, requestPath); 144 145 if (existsSync(cachedFile)) { 146 const metaFile = `${cachedFile}.meta`; 147 let mimeType = lookup(cachedFile) || 'application/octet-stream'; 148 let isGzipped = false; 149 150 // Check if file has compression metadata 151 if (existsSync(metaFile)) { 152 const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 153 if (meta.encoding === 'gzip' && meta.mimeType) { 154 mimeType = meta.mimeType; 155 isGzipped = true; 156 } 157 } 158 159 // Check if this is HTML content that needs rewriting 160 // Note: For gzipped HTML with path rewriting, we need to decompress, rewrite, and serve uncompressed 161 // This is a trade-off for the sites.wisp.place domain which needs path rewriting 162 if (isHtmlContent(requestPath, mimeType)) { 163 let content: string; 164 if (isGzipped) { 165 const { gunzipSync } = await import('zlib'); 166 const compressed = readFileSync(cachedFile); 167 content = gunzipSync(compressed).toString('utf-8'); 168 } else { 169 content = readFileSync(cachedFile, 'utf-8'); 170 } 171 const rewritten = rewriteHtmlPaths(content, basePath); 172 return new Response(rewritten, { 173 headers: { 174 'Content-Type': 'text/html; charset=utf-8', 175 }, 176 }); 177 } 178 179 // Non-HTML files: serve gzipped content as-is with proper headers 180 const content = readFileSync(cachedFile); 181 if (isGzipped) { 182 // Don't serve already-compressed media formats with Content-Encoding: gzip 183 const alreadyCompressedTypes = [ 184 'video/', 'audio/', 'image/jpeg', 'image/jpg', 'image/png', 185 'image/gif', 'image/webp', 'application/pdf' 186 ]; 187 188 const isAlreadyCompressed = alreadyCompressedTypes.some(type => 189 mimeType.toLowerCase().startsWith(type) 190 ); 191 192 if (isAlreadyCompressed) { 193 // Decompress the file before serving 194 const { gunzipSync } = await import('zlib'); 195 const decompressed = gunzipSync(content); 196 return new Response(decompressed, { 197 headers: { 198 'Content-Type': mimeType, 199 }, 200 }); 201 } 202 203 return new Response(content, { 204 headers: { 205 'Content-Type': mimeType, 206 'Content-Encoding': 'gzip', 207 }, 208 }); 209 } 210 return new Response(content, { 211 headers: { 212 'Content-Type': mimeType, 213 }, 214 }); 215 } 216 217 // Try index.html for directory-like paths 218 if (!requestPath.includes('.')) { 219 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 220 if (existsSync(indexFile)) { 221 const metaFile = `${indexFile}.meta`; 222 let isGzipped = false; 223 224 if (existsSync(metaFile)) { 225 const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 226 if (meta.encoding === 'gzip') { 227 isGzipped = true; 228 } 229 } 230 231 // HTML needs path rewriting, so decompress if needed 232 let content: string; 233 if (isGzipped) { 234 const { gunzipSync } = await import('zlib'); 235 const compressed = readFileSync(indexFile); 236 content = gunzipSync(compressed).toString('utf-8'); 237 } else { 238 content = readFileSync(indexFile, 'utf-8'); 239 } 240 const rewritten = rewriteHtmlPaths(content, basePath); 241 return new Response(rewritten, { 242 headers: { 243 'Content-Type': 'text/html; charset=utf-8', 244 }, 245 }); 246 } 247 } 248 249 return new Response('Not Found', { status: 404 }); 250} 251 252// Helper to ensure site is cached 253async function ensureSiteCached(did: string, rkey: string): Promise<boolean> { 254 if (isCached(did, rkey)) { 255 return true; 256 } 257 258 // Fetch and cache the site 259 const siteData = await fetchSiteRecord(did, rkey); 260 if (!siteData) { 261 logger.error('Site record not found', null, { did, rkey }); 262 return false; 263 } 264 265 const pdsEndpoint = await getPdsForDid(did); 266 if (!pdsEndpoint) { 267 logger.error('PDS not found for DID', null, { did }); 268 return false; 269 } 270 271 try { 272 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 273 logger.info('Site cached successfully', { did, rkey }); 274 return true; 275 } catch (err) { 276 logger.error('Failed to cache site', err, { did, rkey }); 277 return false; 278 } 279} 280 281const app = new Hono(); 282 283// Add observability middleware 284app.use('*', observabilityMiddleware('hosting-service')); 285 286// Error handler 287app.onError(observabilityErrorHandler('hosting-service')); 288 289// Main site serving route 290app.get('/*', async (c) => { 291 const url = new URL(c.req.url); 292 const hostname = c.req.header('host') || ''; 293 const rawPath = url.pathname.replace(/^\//, ''); 294 const path = sanitizePath(rawPath); 295 296 // Check if this is sites.wisp.place subdomain 297 if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 298 // Sanitize the path FIRST to prevent path traversal 299 const sanitizedFullPath = sanitizePath(rawPath); 300 301 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 302 const pathParts = sanitizedFullPath.split('/'); 303 if (pathParts.length < 2) { 304 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 305 } 306 307 const identifier = pathParts[0]; 308 const site = pathParts[1]; 309 const filePath = pathParts.slice(2).join('/'); 310 311 // Additional validation: identifier must be a valid DID or handle format 312 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 313 return c.text('Invalid identifier', 400); 314 } 315 316 // Validate site parameter exists 317 if (!site) { 318 return c.text('Site name required', 400); 319 } 320 321 // Validate site name (rkey) 322 if (!isValidRkey(site)) { 323 return c.text('Invalid site name', 400); 324 } 325 326 // Resolve identifier to DID 327 const did = await resolveDid(identifier); 328 if (!did) { 329 return c.text('Invalid identifier', 400); 330 } 331 332 // Ensure site is cached 333 const cached = await ensureSiteCached(did, site); 334 if (!cached) { 335 return c.text('Site not found', 404); 336 } 337 338 // Serve with HTML path rewriting to handle absolute paths 339 const basePath = `/${identifier}/${site}/`; 340 return serveFromCacheWithRewrite(did, site, filePath, basePath); 341 } 342 343 // Check if this is a DNS hash subdomain 344 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 345 if (dnsMatch) { 346 const hash = dnsMatch[1]; 347 const baseDomain = dnsMatch[2]; 348 349 if (!hash) { 350 return c.text('Invalid DNS hash', 400); 351 } 352 353 if (baseDomain !== BASE_HOST) { 354 return c.text('Invalid base domain', 400); 355 } 356 357 const customDomain = await getCustomDomainByHash(hash); 358 if (!customDomain) { 359 return c.text('Custom domain not found or not verified', 404); 360 } 361 362 if (!customDomain.rkey) { 363 return c.text('Domain not mapped to a site', 404); 364 } 365 366 const rkey = customDomain.rkey; 367 if (!isValidRkey(rkey)) { 368 return c.text('Invalid site configuration', 500); 369 } 370 371 const cached = await ensureSiteCached(customDomain.did, rkey); 372 if (!cached) { 373 return c.text('Site not found', 404); 374 } 375 376 return serveFromCache(customDomain.did, rkey, path); 377 } 378 379 // Route 2: Registered subdomains - /*.wisp.place/* 380 if (hostname.endsWith(`.${BASE_HOST}`)) { 381 const domainInfo = await getWispDomain(hostname); 382 if (!domainInfo) { 383 return c.text('Subdomain not registered', 404); 384 } 385 386 if (!domainInfo.rkey) { 387 return c.text('Domain not mapped to a site', 404); 388 } 389 390 const rkey = domainInfo.rkey; 391 if (!isValidRkey(rkey)) { 392 return c.text('Invalid site configuration', 500); 393 } 394 395 const cached = await ensureSiteCached(domainInfo.did, rkey); 396 if (!cached) { 397 return c.text('Site not found', 404); 398 } 399 400 return serveFromCache(domainInfo.did, rkey, path); 401 } 402 403 // Route 1: Custom domains - /* 404 const customDomain = await getCustomDomain(hostname); 405 if (!customDomain) { 406 return c.text('Custom domain not found or not verified', 404); 407 } 408 409 if (!customDomain.rkey) { 410 return c.text('Domain not mapped to a site', 404); 411 } 412 413 const rkey = customDomain.rkey; 414 if (!isValidRkey(rkey)) { 415 return c.text('Invalid site configuration', 500); 416 } 417 418 const cached = await ensureSiteCached(customDomain.did, rkey); 419 if (!cached) { 420 return c.text('Site not found', 404); 421 } 422 423 return serveFromCache(customDomain.did, rkey, path); 424}); 425 426// Internal observability endpoints (for admin panel) 427app.get('/__internal__/observability/logs', (c) => { 428 const query = c.req.query(); 429 const filter: any = {}; 430 if (query.level) filter.level = query.level; 431 if (query.service) filter.service = query.service; 432 if (query.search) filter.search = query.search; 433 if (query.eventType) filter.eventType = query.eventType; 434 if (query.limit) filter.limit = parseInt(query.limit as string); 435 return c.json({ logs: logCollector.getLogs(filter) }); 436}); 437 438app.get('/__internal__/observability/errors', (c) => { 439 const query = c.req.query(); 440 const filter: any = {}; 441 if (query.service) filter.service = query.service; 442 if (query.limit) filter.limit = parseInt(query.limit as string); 443 return c.json({ errors: errorTracker.getErrors(filter) }); 444}); 445 446app.get('/__internal__/observability/metrics', (c) => { 447 const query = c.req.query(); 448 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 449 const stats = metricsCollector.getStats('hosting-service', timeWindow); 450 return c.json({ stats, timeWindow }); 451}); 452 453export default app;