Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import { Hono } from 'hono'; 2import { cors } from 'hono/cors'; 3import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 4import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils'; 5import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 6import { existsSync } from 'fs'; 7import { readFile, access } from 'fs/promises'; 8import { lookup } from 'mime-types'; 9import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 10import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata, markSiteAsBeingCached, unmarkSiteAsBeingCached, isSiteBeingCached } from './lib/cache'; 11import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects'; 12 13const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 14 15/** 16 * Configurable index file names to check for directory requests 17 * Will be checked in order until one is found 18 */ 19const INDEX_FILES = ['index.html', 'index.htm']; 20 21/** 22 * Validate site name (rkey) to prevent injection attacks 23 * Must match AT Protocol rkey format 24 */ 25function isValidRkey(rkey: string): boolean { 26 if (!rkey || typeof rkey !== 'string') return false; 27 if (rkey.length < 1 || rkey.length > 512) return false; 28 if (rkey === '.' || rkey === '..') return false; 29 if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false; 30 const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/; 31 return validRkeyPattern.test(rkey); 32} 33 34/** 35 * Async file existence check 36 */ 37async function fileExists(path: string): Promise<boolean> { 38 try { 39 await access(path); 40 return true; 41 } catch { 42 return false; 43 } 44} 45 46/** 47 * Return a response indicating the site is being updated 48 */ 49function siteUpdatingResponse(): Response { 50 const html = `<!DOCTYPE html> 51<html> 52<head> 53 <meta charset="utf-8"> 54 <meta name="viewport" content="width=device-width, initial-scale=1"> 55 <title>Site Updating</title> 56 <style> 57 @media (prefers-color-scheme: light) { 58 :root { 59 --background: oklch(0.90 0.012 35); 60 --foreground: oklch(0.18 0.01 30); 61 --primary: oklch(0.35 0.02 35); 62 --accent: oklch(0.78 0.15 345); 63 } 64 } 65 @media (prefers-color-scheme: dark) { 66 :root { 67 --background: oklch(0.23 0.015 285); 68 --foreground: oklch(0.90 0.005 285); 69 --primary: oklch(0.70 0.10 295); 70 --accent: oklch(0.85 0.08 5); 71 } 72 } 73 body { 74 font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 75 display: flex; 76 align-items: center; 77 justify-content: center; 78 min-height: 100vh; 79 margin: 0; 80 background: var(--background); 81 color: var(--foreground); 82 } 83 .container { 84 text-align: center; 85 padding: 2rem; 86 max-width: 500px; 87 } 88 h1 { 89 font-size: 2.5rem; 90 margin-bottom: 1rem; 91 font-weight: 600; 92 color: var(--primary); 93 } 94 p { 95 font-size: 1.25rem; 96 opacity: 0.8; 97 margin-bottom: 2rem; 98 color: var(--foreground); 99 } 100 .spinner { 101 border: 4px solid var(--accent); 102 border-radius: 50%; 103 border-top: 4px solid var(--primary); 104 width: 40px; 105 height: 40px; 106 animation: spin 1s linear infinite; 107 margin: 0 auto; 108 } 109 @keyframes spin { 110 0% { transform: rotate(0deg); } 111 100% { transform: rotate(360deg); } 112 } 113 </style> 114 <meta http-equiv="refresh" content="3"> 115</head> 116<body> 117 <div class="container"> 118 <h1>Site Updating</h1> 119 <p>This site is undergoing an update right now. Check back in a moment...</p> 120 <div class="spinner"></div> 121 </div> 122</body> 123</html>`; 124 125 return new Response(html, { 126 status: 503, 127 headers: { 128 'Content-Type': 'text/html; charset=utf-8', 129 'Cache-Control': 'no-store, no-cache, must-revalidate', 130 'Retry-After': '3', 131 }, 132 }); 133} 134 135// Cache for redirect rules (per site) 136const redirectRulesCache = new Map<string, RedirectRule[]>(); 137 138/** 139 * Clear redirect rules cache for a specific site 140 * Should be called when a site is updated/recached 141 */ 142export function clearRedirectRulesCache(did: string, rkey: string) { 143 const cacheKey = `${did}:${rkey}`; 144 redirectRulesCache.delete(cacheKey); 145} 146 147// Helper to serve files from cache 148async function serveFromCache( 149 did: string, 150 rkey: string, 151 filePath: string, 152 fullUrl?: string, 153 headers?: Record<string, string> 154) { 155 // Check for redirect rules first 156 const redirectCacheKey = `${did}:${rkey}`; 157 let redirectRules = redirectRulesCache.get(redirectCacheKey); 158 159 if (redirectRules === undefined) { 160 // Load rules for the first time 161 redirectRules = await loadRedirectRules(did, rkey); 162 redirectRulesCache.set(redirectCacheKey, redirectRules); 163 } 164 165 // Apply redirect rules if any exist 166 if (redirectRules.length > 0) { 167 const requestPath = '/' + (filePath || ''); 168 const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 169 const cookies = parseCookies(headers?.['cookie']); 170 171 const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 172 queryParams, 173 headers, 174 cookies, 175 }); 176 177 if (redirectMatch) { 178 const { rule, targetPath, status } = redirectMatch; 179 180 // If not forced, check if the requested file exists before redirecting 181 if (!rule.force) { 182 // Build the expected file path 183 let checkPath = filePath || INDEX_FILES[0]; 184 if (checkPath.endsWith('/')) { 185 checkPath += INDEX_FILES[0]; 186 } 187 188 const cachedFile = getCachedFilePath(did, rkey, checkPath); 189 const fileExistsOnDisk = await fileExists(cachedFile); 190 191 // If file exists and redirect is not forced, serve the file normally 192 if (fileExistsOnDisk) { 193 return serveFileInternal(did, rkey, filePath); 194 } 195 } 196 197 // Handle different status codes 198 if (status === 200) { 199 // Rewrite: serve different content but keep URL the same 200 // Remove leading slash for internal path resolution 201 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 202 return serveFileInternal(did, rkey, rewritePath); 203 } else if (status === 301 || status === 302) { 204 // External redirect: change the URL 205 return new Response(null, { 206 status, 207 headers: { 208 'Location': targetPath, 209 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 210 }, 211 }); 212 } else if (status === 404) { 213 // Custom 404 page 214 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 215 const response = await serveFileInternal(did, rkey, custom404Path); 216 // Override status to 404 217 return new Response(response.body, { 218 status: 404, 219 headers: response.headers, 220 }); 221 } 222 } 223 } 224 225 // No redirect matched, serve normally 226 return serveFileInternal(did, rkey, filePath); 227} 228 229// Internal function to serve a file (used by both normal serving and rewrites) 230async function serveFileInternal(did: string, rkey: string, filePath: string) { 231 // Check if site is currently being cached - if so, return updating response 232 if (isSiteBeingCached(did, rkey)) { 233 return siteUpdatingResponse(); 234 } 235 236 // Default to first index file if path is empty 237 let requestPath = filePath || INDEX_FILES[0]; 238 239 // If path ends with /, append first index file 240 if (requestPath.endsWith('/')) { 241 requestPath += INDEX_FILES[0]; 242 } 243 244 const cacheKey = getCacheKey(did, rkey, requestPath); 245 const cachedFile = getCachedFilePath(did, rkey, requestPath); 246 247 // Check if the cached file path is a directory 248 if (await fileExists(cachedFile)) { 249 const { stat } = await import('fs/promises'); 250 try { 251 const stats = await stat(cachedFile); 252 if (stats.isDirectory()) { 253 // It's a directory, try each index file in order 254 for (const indexFile of INDEX_FILES) { 255 const indexPath = `${requestPath}/${indexFile}`; 256 const indexFilePath = getCachedFilePath(did, rkey, indexPath); 257 if (await fileExists(indexFilePath)) { 258 return serveFileInternal(did, rkey, indexPath); 259 } 260 } 261 // No index file found, fall through to 404 262 } 263 } catch (err) { 264 // If stat fails, continue with normal flow 265 } 266 } 267 268 // Check in-memory cache first 269 let content = fileCache.get(cacheKey); 270 let meta = metadataCache.get(cacheKey); 271 272 if (!content && await fileExists(cachedFile)) { 273 // Read from disk and cache 274 content = await readFile(cachedFile); 275 fileCache.set(cacheKey, content, content.length); 276 277 const metaFile = `${cachedFile}.meta`; 278 if (await fileExists(metaFile)) { 279 const metaJson = await readFile(metaFile, 'utf-8'); 280 meta = JSON.parse(metaJson); 281 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 282 } 283 } 284 285 if (content) { 286 // Build headers with caching 287 const headers: Record<string, string> = {}; 288 289 if (meta && meta.encoding === 'gzip' && meta.mimeType) { 290 const shouldServeCompressed = shouldCompressMimeType(meta.mimeType); 291 292 if (!shouldServeCompressed) { 293 // Verify content is actually gzipped before attempting decompression 294 const isGzipped = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 295 if (isGzipped) { 296 const { gunzipSync } = await import('zlib'); 297 const decompressed = gunzipSync(content); 298 headers['Content-Type'] = meta.mimeType; 299 headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 300 return new Response(decompressed, { headers }); 301 } else { 302 // Meta says gzipped but content isn't - serve as-is 303 console.warn(`File ${filePath} has gzip encoding in meta but content lacks gzip magic bytes`); 304 headers['Content-Type'] = meta.mimeType; 305 headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 306 return new Response(content, { headers }); 307 } 308 } 309 310 headers['Content-Type'] = meta.mimeType; 311 headers['Content-Encoding'] = 'gzip'; 312 headers['Cache-Control'] = meta.mimeType.startsWith('text/html') 313 ? 'public, max-age=300' 314 : 'public, max-age=31536000, immutable'; 315 return new Response(content, { headers }); 316 } 317 318 // Non-compressed files 319 const mimeType = lookup(cachedFile) || 'application/octet-stream'; 320 headers['Content-Type'] = mimeType; 321 headers['Cache-Control'] = mimeType.startsWith('text/html') 322 ? 'public, max-age=300' 323 : 'public, max-age=31536000, immutable'; 324 return new Response(content, { headers }); 325 } 326 327 // Try index files for directory-like paths 328 if (!requestPath.includes('.')) { 329 for (const indexFileName of INDEX_FILES) { 330 const indexPath = `${requestPath}/${indexFileName}`; 331 const indexCacheKey = getCacheKey(did, rkey, indexPath); 332 const indexFile = getCachedFilePath(did, rkey, indexPath); 333 334 let indexContent = fileCache.get(indexCacheKey); 335 let indexMeta = metadataCache.get(indexCacheKey); 336 337 if (!indexContent && await fileExists(indexFile)) { 338 indexContent = await readFile(indexFile); 339 fileCache.set(indexCacheKey, indexContent, indexContent.length); 340 341 const indexMetaFile = `${indexFile}.meta`; 342 if (await fileExists(indexMetaFile)) { 343 const metaJson = await readFile(indexMetaFile, 'utf-8'); 344 indexMeta = JSON.parse(metaJson); 345 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 346 } 347 } 348 349 if (indexContent) { 350 const headers: Record<string, string> = { 351 'Content-Type': 'text/html; charset=utf-8', 352 'Cache-Control': 'public, max-age=300', 353 }; 354 355 if (indexMeta && indexMeta.encoding === 'gzip') { 356 headers['Content-Encoding'] = 'gzip'; 357 } 358 359 return new Response(indexContent, { headers }); 360 } 361 } 362 } 363 364 return new Response('Not Found', { status: 404 }); 365} 366 367// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes 368async function serveFromCacheWithRewrite( 369 did: string, 370 rkey: string, 371 filePath: string, 372 basePath: string, 373 fullUrl?: string, 374 headers?: Record<string, string> 375) { 376 // Check for redirect rules first 377 const redirectCacheKey = `${did}:${rkey}`; 378 let redirectRules = redirectRulesCache.get(redirectCacheKey); 379 380 if (redirectRules === undefined) { 381 // Load rules for the first time 382 redirectRules = await loadRedirectRules(did, rkey); 383 redirectRulesCache.set(redirectCacheKey, redirectRules); 384 } 385 386 // Apply redirect rules if any exist 387 if (redirectRules.length > 0) { 388 const requestPath = '/' + (filePath || ''); 389 const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 390 const cookies = parseCookies(headers?.['cookie']); 391 392 const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 393 queryParams, 394 headers, 395 cookies, 396 }); 397 398 if (redirectMatch) { 399 const { rule, targetPath, status } = redirectMatch; 400 401 // If not forced, check if the requested file exists before redirecting 402 if (!rule.force) { 403 // Build the expected file path 404 let checkPath = filePath || INDEX_FILES[0]; 405 if (checkPath.endsWith('/')) { 406 checkPath += INDEX_FILES[0]; 407 } 408 409 const cachedFile = getCachedFilePath(did, rkey, checkPath); 410 const fileExistsOnDisk = await fileExists(cachedFile); 411 412 // If file exists and redirect is not forced, serve the file normally 413 if (fileExistsOnDisk) { 414 return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 415 } 416 } 417 418 // Handle different status codes 419 if (status === 200) { 420 // Rewrite: serve different content but keep URL the same 421 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 422 return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath); 423 } else if (status === 301 || status === 302) { 424 // External redirect: change the URL 425 // For sites.wisp.place, we need to adjust the target path to include the base path 426 // unless it's an absolute URL 427 let redirectTarget = targetPath; 428 if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) { 429 redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath); 430 } 431 return new Response(null, { 432 status, 433 headers: { 434 'Location': redirectTarget, 435 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 436 }, 437 }); 438 } else if (status === 404) { 439 // Custom 404 page 440 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 441 const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath); 442 // Override status to 404 443 return new Response(response.body, { 444 status: 404, 445 headers: response.headers, 446 }); 447 } 448 } 449 } 450 451 // No redirect matched, serve normally 452 return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 453} 454 455// Internal function to serve a file with rewriting 456async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) { 457 // Check if site is currently being cached - if so, return updating response 458 if (isSiteBeingCached(did, rkey)) { 459 return siteUpdatingResponse(); 460 } 461 462 // Default to first index file if path is empty 463 let requestPath = filePath || INDEX_FILES[0]; 464 465 // If path ends with /, append first index file 466 if (requestPath.endsWith('/')) { 467 requestPath += INDEX_FILES[0]; 468 } 469 470 const cacheKey = getCacheKey(did, rkey, requestPath); 471 const cachedFile = getCachedFilePath(did, rkey, requestPath); 472 473 // Check if the cached file path is a directory 474 if (await fileExists(cachedFile)) { 475 const { stat } = await import('fs/promises'); 476 try { 477 const stats = await stat(cachedFile); 478 if (stats.isDirectory()) { 479 // It's a directory, try each index file in order 480 for (const indexFile of INDEX_FILES) { 481 const indexPath = `${requestPath}/${indexFile}`; 482 const indexFilePath = getCachedFilePath(did, rkey, indexPath); 483 if (await fileExists(indexFilePath)) { 484 return serveFileInternalWithRewrite(did, rkey, indexPath, basePath); 485 } 486 } 487 // No index file found, fall through to 404 488 } 489 } catch (err) { 490 // If stat fails, continue with normal flow 491 } 492 } 493 494 // Check for rewritten HTML in cache first (if it's HTML) 495 const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream'; 496 if (isHtmlContent(requestPath, mimeTypeGuess)) { 497 const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 498 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 499 if (rewrittenContent) { 500 return new Response(rewrittenContent, { 501 headers: { 502 'Content-Type': 'text/html; charset=utf-8', 503 'Content-Encoding': 'gzip', 504 'Cache-Control': 'public, max-age=300', 505 }, 506 }); 507 } 508 } 509 510 // Check in-memory file cache 511 let content = fileCache.get(cacheKey); 512 let meta = metadataCache.get(cacheKey); 513 514 if (!content && await fileExists(cachedFile)) { 515 // Read from disk and cache 516 content = await readFile(cachedFile); 517 fileCache.set(cacheKey, content, content.length); 518 519 const metaFile = `${cachedFile}.meta`; 520 if (await fileExists(metaFile)) { 521 const metaJson = await readFile(metaFile, 'utf-8'); 522 meta = JSON.parse(metaJson); 523 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 524 } 525 } 526 527 if (content) { 528 const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream'; 529 const isGzipped = meta?.encoding === 'gzip'; 530 531 // Check if this is HTML content that needs rewriting 532 if (isHtmlContent(requestPath, mimeType)) { 533 let htmlContent: string; 534 if (isGzipped) { 535 // Verify content is actually gzipped 536 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 537 if (hasGzipMagic) { 538 const { gunzipSync } = await import('zlib'); 539 htmlContent = gunzipSync(content).toString('utf-8'); 540 } else { 541 console.warn(`File ${requestPath} marked as gzipped but lacks magic bytes, serving as-is`); 542 htmlContent = content.toString('utf-8'); 543 } 544 } else { 545 htmlContent = content.toString('utf-8'); 546 } 547 const rewritten = rewriteHtmlPaths(htmlContent, basePath, requestPath); 548 549 // Recompress and cache the rewritten HTML 550 const { gzipSync } = await import('zlib'); 551 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 552 553 const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 554 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 555 556 return new Response(recompressed, { 557 headers: { 558 'Content-Type': 'text/html; charset=utf-8', 559 'Content-Encoding': 'gzip', 560 'Cache-Control': 'public, max-age=300', 561 }, 562 }); 563 } 564 565 // Non-HTML files: serve as-is 566 const headers: Record<string, string> = { 567 'Content-Type': mimeType, 568 'Cache-Control': 'public, max-age=31536000, immutable', 569 }; 570 571 if (isGzipped) { 572 const shouldServeCompressed = shouldCompressMimeType(mimeType); 573 if (!shouldServeCompressed) { 574 // Verify content is actually gzipped 575 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 576 if (hasGzipMagic) { 577 const { gunzipSync } = await import('zlib'); 578 const decompressed = gunzipSync(content); 579 return new Response(decompressed, { headers }); 580 } else { 581 console.warn(`File ${requestPath} marked as gzipped but lacks magic bytes, serving as-is`); 582 return new Response(content, { headers }); 583 } 584 } 585 headers['Content-Encoding'] = 'gzip'; 586 } 587 588 return new Response(content, { headers }); 589 } 590 591 // Try index files for directory-like paths 592 if (!requestPath.includes('.')) { 593 for (const indexFileName of INDEX_FILES) { 594 const indexPath = `${requestPath}/${indexFileName}`; 595 const indexCacheKey = getCacheKey(did, rkey, indexPath); 596 const indexFile = getCachedFilePath(did, rkey, indexPath); 597 598 // Check for rewritten index file in cache 599 const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`); 600 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 601 if (rewrittenContent) { 602 return new Response(rewrittenContent, { 603 headers: { 604 'Content-Type': 'text/html; charset=utf-8', 605 'Content-Encoding': 'gzip', 606 'Cache-Control': 'public, max-age=300', 607 }, 608 }); 609 } 610 611 let indexContent = fileCache.get(indexCacheKey); 612 let indexMeta = metadataCache.get(indexCacheKey); 613 614 if (!indexContent && await fileExists(indexFile)) { 615 indexContent = await readFile(indexFile); 616 fileCache.set(indexCacheKey, indexContent, indexContent.length); 617 618 const indexMetaFile = `${indexFile}.meta`; 619 if (await fileExists(indexMetaFile)) { 620 const metaJson = await readFile(indexMetaFile, 'utf-8'); 621 indexMeta = JSON.parse(metaJson); 622 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 623 } 624 } 625 626 if (indexContent) { 627 const isGzipped = indexMeta?.encoding === 'gzip'; 628 629 let htmlContent: string; 630 if (isGzipped) { 631 // Verify content is actually gzipped 632 const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b; 633 if (hasGzipMagic) { 634 const { gunzipSync } = await import('zlib'); 635 htmlContent = gunzipSync(indexContent).toString('utf-8'); 636 } else { 637 console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`); 638 htmlContent = indexContent.toString('utf-8'); 639 } 640 } else { 641 htmlContent = indexContent.toString('utf-8'); 642 } 643 const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath); 644 645 const { gzipSync } = await import('zlib'); 646 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 647 648 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 649 650 return new Response(recompressed, { 651 headers: { 652 'Content-Type': 'text/html; charset=utf-8', 653 'Content-Encoding': 'gzip', 654 'Cache-Control': 'public, max-age=300', 655 }, 656 }); 657 } 658 } 659 } 660 661 return new Response('Not Found', { status: 404 }); 662} 663 664// Helper to ensure site is cached 665async function ensureSiteCached(did: string, rkey: string): Promise<boolean> { 666 if (isCached(did, rkey)) { 667 return true; 668 } 669 670 // Fetch and cache the site 671 const siteData = await fetchSiteRecord(did, rkey); 672 if (!siteData) { 673 logger.error('Site record not found', null, { did, rkey }); 674 return false; 675 } 676 677 const pdsEndpoint = await getPdsForDid(did); 678 if (!pdsEndpoint) { 679 logger.error('PDS not found for DID', null, { did }); 680 return false; 681 } 682 683 // Mark site as being cached to prevent serving stale content during update 684 markSiteAsBeingCached(did, rkey); 685 686 try { 687 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 688 // Clear redirect rules cache since the site was updated 689 clearRedirectRulesCache(did, rkey); 690 logger.info('Site cached successfully', { did, rkey }); 691 return true; 692 } catch (err) { 693 logger.error('Failed to cache site', err, { did, rkey }); 694 return false; 695 } finally { 696 // Always unmark, even if caching fails 697 unmarkSiteAsBeingCached(did, rkey); 698 } 699} 700 701const app = new Hono(); 702 703// Add CORS middleware - allow all origins for static site hosting 704app.use('*', cors({ 705 origin: '*', 706 allowMethods: ['GET', 'HEAD', 'OPTIONS'], 707 allowHeaders: ['Content-Type', 'Authorization'], 708 exposeHeaders: ['Content-Length', 'Content-Type', 'Content-Encoding', 'Cache-Control'], 709 maxAge: 86400, // 24 hours 710 credentials: false, 711})); 712 713// Add observability middleware 714app.use('*', observabilityMiddleware('hosting-service')); 715 716// Error handler 717app.onError(observabilityErrorHandler('hosting-service')); 718 719// Main site serving route 720app.get('/*', async (c) => { 721 const url = new URL(c.req.url); 722 const hostname = c.req.header('host') || ''; 723 const rawPath = url.pathname.replace(/^\//, ''); 724 const path = sanitizePath(rawPath); 725 726 // Check if this is sites.wisp.place subdomain (strip port for comparison) 727 const hostnameWithoutPort = hostname.split(':')[0]; 728 if (hostnameWithoutPort === `sites.${BASE_HOST}`) { 729 // Sanitize the path FIRST to prevent path traversal 730 const sanitizedFullPath = sanitizePath(rawPath); 731 732 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 733 const pathParts = sanitizedFullPath.split('/'); 734 if (pathParts.length < 2) { 735 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 736 } 737 738 const identifier = pathParts[0]; 739 const site = pathParts[1]; 740 const filePath = pathParts.slice(2).join('/'); 741 742 // Additional validation: identifier must be a valid DID or handle format 743 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 744 return c.text('Invalid identifier', 400); 745 } 746 747 // Validate site parameter exists 748 if (!site) { 749 return c.text('Site name required', 400); 750 } 751 752 // Validate site name (rkey) 753 if (!isValidRkey(site)) { 754 return c.text('Invalid site name', 400); 755 } 756 757 // Resolve identifier to DID 758 const did = await resolveDid(identifier); 759 if (!did) { 760 return c.text('Invalid identifier', 400); 761 } 762 763 // Check if site is currently being cached - return updating response early 764 if (isSiteBeingCached(did, site)) { 765 return siteUpdatingResponse(); 766 } 767 768 // Ensure site is cached 769 const cached = await ensureSiteCached(did, site); 770 if (!cached) { 771 return c.text('Site not found', 404); 772 } 773 774 // Serve with HTML path rewriting to handle absolute paths 775 const basePath = `/${identifier}/${site}/`; 776 const headers: Record<string, string> = {}; 777 c.req.raw.headers.forEach((value, key) => { 778 headers[key.toLowerCase()] = value; 779 }); 780 return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers); 781 } 782 783 // Check if this is a DNS hash subdomain 784 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 785 if (dnsMatch) { 786 const hash = dnsMatch[1]; 787 const baseDomain = dnsMatch[2]; 788 789 if (!hash) { 790 return c.text('Invalid DNS hash', 400); 791 } 792 793 if (baseDomain !== BASE_HOST) { 794 return c.text('Invalid base domain', 400); 795 } 796 797 const customDomain = await getCustomDomainByHash(hash); 798 if (!customDomain) { 799 return c.text('Custom domain not found or not verified', 404); 800 } 801 802 if (!customDomain.rkey) { 803 return c.text('Domain not mapped to a site', 404); 804 } 805 806 const rkey = customDomain.rkey; 807 if (!isValidRkey(rkey)) { 808 return c.text('Invalid site configuration', 500); 809 } 810 811 // Check if site is currently being cached - return updating response early 812 if (isSiteBeingCached(customDomain.did, rkey)) { 813 return siteUpdatingResponse(); 814 } 815 816 const cached = await ensureSiteCached(customDomain.did, rkey); 817 if (!cached) { 818 return c.text('Site not found', 404); 819 } 820 821 const headers: Record<string, string> = {}; 822 c.req.raw.headers.forEach((value, key) => { 823 headers[key.toLowerCase()] = value; 824 }); 825 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 826 } 827 828 // Route 2: Registered subdomains - /*.wisp.place/* 829 if (hostname.endsWith(`.${BASE_HOST}`)) { 830 const domainInfo = await getWispDomain(hostname); 831 if (!domainInfo) { 832 return c.text('Subdomain not registered', 404); 833 } 834 835 if (!domainInfo.rkey) { 836 return c.text('Domain not mapped to a site', 404); 837 } 838 839 const rkey = domainInfo.rkey; 840 if (!isValidRkey(rkey)) { 841 return c.text('Invalid site configuration', 500); 842 } 843 844 // Check if site is currently being cached - return updating response early 845 if (isSiteBeingCached(domainInfo.did, rkey)) { 846 return siteUpdatingResponse(); 847 } 848 849 const cached = await ensureSiteCached(domainInfo.did, rkey); 850 if (!cached) { 851 return c.text('Site not found', 404); 852 } 853 854 const headers: Record<string, string> = {}; 855 c.req.raw.headers.forEach((value, key) => { 856 headers[key.toLowerCase()] = value; 857 }); 858 return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers); 859 } 860 861 // Route 1: Custom domains - /* 862 const customDomain = await getCustomDomain(hostname); 863 if (!customDomain) { 864 return c.text('Custom domain not found or not verified', 404); 865 } 866 867 if (!customDomain.rkey) { 868 return c.text('Domain not mapped to a site', 404); 869 } 870 871 const rkey = customDomain.rkey; 872 if (!isValidRkey(rkey)) { 873 return c.text('Invalid site configuration', 500); 874 } 875 876 // Check if site is currently being cached - return updating response early 877 if (isSiteBeingCached(customDomain.did, rkey)) { 878 return siteUpdatingResponse(); 879 } 880 881 const cached = await ensureSiteCached(customDomain.did, rkey); 882 if (!cached) { 883 return c.text('Site not found', 404); 884 } 885 886 const headers: Record<string, string> = {}; 887 c.req.raw.headers.forEach((value, key) => { 888 headers[key.toLowerCase()] = value; 889 }); 890 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 891}); 892 893// Internal observability endpoints (for admin panel) 894app.get('/__internal__/observability/logs', (c) => { 895 const query = c.req.query(); 896 const filter: any = {}; 897 if (query.level) filter.level = query.level; 898 if (query.service) filter.service = query.service; 899 if (query.search) filter.search = query.search; 900 if (query.eventType) filter.eventType = query.eventType; 901 if (query.limit) filter.limit = parseInt(query.limit as string); 902 return c.json({ logs: logCollector.getLogs(filter) }); 903}); 904 905app.get('/__internal__/observability/errors', (c) => { 906 const query = c.req.query(); 907 const filter: any = {}; 908 if (query.service) filter.service = query.service; 909 if (query.limit) filter.limit = parseInt(query.limit as string); 910 return c.json({ errors: errorTracker.getErrors(filter) }); 911}); 912 913app.get('/__internal__/observability/metrics', (c) => { 914 const query = c.req.query(); 915 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 916 const stats = metricsCollector.getStats('hosting-service', timeWindow); 917 return c.json({ stats, timeWindow }); 918}); 919 920app.get('/__internal__/observability/cache', async (c) => { 921 const { getCacheStats } = await import('./lib/cache'); 922 const stats = getCacheStats(); 923 return c.json({ cache: stats }); 924}); 925 926export default app;