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 } 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// Cache for redirect rules (per site) 47const redirectRulesCache = new Map<string, RedirectRule[]>(); 48 49/** 50 * Clear redirect rules cache for a specific site 51 * Should be called when a site is updated/recached 52 */ 53export function clearRedirectRulesCache(did: string, rkey: string) { 54 const cacheKey = `${did}:${rkey}`; 55 redirectRulesCache.delete(cacheKey); 56} 57 58// Helper to serve files from cache 59async function serveFromCache( 60 did: string, 61 rkey: string, 62 filePath: string, 63 fullUrl?: string, 64 headers?: Record<string, string> 65) { 66 // Check for redirect rules first 67 const redirectCacheKey = `${did}:${rkey}`; 68 let redirectRules = redirectRulesCache.get(redirectCacheKey); 69 70 if (redirectRules === undefined) { 71 // Load rules for the first time 72 redirectRules = await loadRedirectRules(did, rkey); 73 redirectRulesCache.set(redirectCacheKey, redirectRules); 74 } 75 76 // Apply redirect rules if any exist 77 if (redirectRules.length > 0) { 78 const requestPath = '/' + (filePath || ''); 79 const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 80 const cookies = parseCookies(headers?.['cookie']); 81 82 const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 83 queryParams, 84 headers, 85 cookies, 86 }); 87 88 if (redirectMatch) { 89 const { rule, targetPath, status } = redirectMatch; 90 91 // If not forced, check if the requested file exists before redirecting 92 if (!rule.force) { 93 // Build the expected file path 94 let checkPath = filePath || INDEX_FILES[0]; 95 if (checkPath.endsWith('/')) { 96 checkPath += INDEX_FILES[0]; 97 } 98 99 const cachedFile = getCachedFilePath(did, rkey, checkPath); 100 const fileExistsOnDisk = await fileExists(cachedFile); 101 102 // If file exists and redirect is not forced, serve the file normally 103 if (fileExistsOnDisk) { 104 return serveFileInternal(did, rkey, filePath); 105 } 106 } 107 108 // Handle different status codes 109 if (status === 200) { 110 // Rewrite: serve different content but keep URL the same 111 // Remove leading slash for internal path resolution 112 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 113 return serveFileInternal(did, rkey, rewritePath); 114 } else if (status === 301 || status === 302) { 115 // External redirect: change the URL 116 return new Response(null, { 117 status, 118 headers: { 119 'Location': targetPath, 120 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 121 }, 122 }); 123 } else if (status === 404) { 124 // Custom 404 page 125 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 126 const response = await serveFileInternal(did, rkey, custom404Path); 127 // Override status to 404 128 return new Response(response.body, { 129 status: 404, 130 headers: response.headers, 131 }); 132 } 133 } 134 } 135 136 // No redirect matched, serve normally 137 return serveFileInternal(did, rkey, filePath); 138} 139 140// Internal function to serve a file (used by both normal serving and rewrites) 141async function serveFileInternal(did: string, rkey: string, filePath: string) { 142 // Default to first index file if path is empty 143 let requestPath = filePath || INDEX_FILES[0]; 144 145 // If path ends with /, append first index file 146 if (requestPath.endsWith('/')) { 147 requestPath += INDEX_FILES[0]; 148 } 149 150 const cacheKey = getCacheKey(did, rkey, requestPath); 151 const cachedFile = getCachedFilePath(did, rkey, requestPath); 152 153 // Check if the cached file path is a directory 154 if (await fileExists(cachedFile)) { 155 const { stat } = await import('fs/promises'); 156 try { 157 const stats = await stat(cachedFile); 158 if (stats.isDirectory()) { 159 // It's a directory, try each index file in order 160 for (const indexFile of INDEX_FILES) { 161 const indexPath = `${requestPath}/${indexFile}`; 162 const indexFilePath = getCachedFilePath(did, rkey, indexPath); 163 if (await fileExists(indexFilePath)) { 164 return serveFileInternal(did, rkey, indexPath); 165 } 166 } 167 // No index file found, fall through to 404 168 } 169 } catch (err) { 170 // If stat fails, continue with normal flow 171 } 172 } 173 174 // Check in-memory cache first 175 let content = fileCache.get(cacheKey); 176 let meta = metadataCache.get(cacheKey); 177 178 if (!content && await fileExists(cachedFile)) { 179 // Read from disk and cache 180 content = await readFile(cachedFile); 181 fileCache.set(cacheKey, content, content.length); 182 183 const metaFile = `${cachedFile}.meta`; 184 if (await fileExists(metaFile)) { 185 const metaJson = await readFile(metaFile, 'utf-8'); 186 meta = JSON.parse(metaJson); 187 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 188 } 189 } 190 191 if (content) { 192 // Build headers with caching 193 const headers: Record<string, string> = {}; 194 195 if (meta && meta.encoding === 'gzip' && meta.mimeType) { 196 const shouldServeCompressed = shouldCompressMimeType(meta.mimeType); 197 198 if (!shouldServeCompressed) { 199 // Verify content is actually gzipped before attempting decompression 200 const isGzipped = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 201 if (isGzipped) { 202 const { gunzipSync } = await import('zlib'); 203 const decompressed = gunzipSync(content); 204 headers['Content-Type'] = meta.mimeType; 205 headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 206 return new Response(decompressed, { headers }); 207 } else { 208 // Meta says gzipped but content isn't - serve as-is 209 console.warn(`File ${filePath} has gzip encoding in meta but content lacks gzip magic bytes`); 210 headers['Content-Type'] = meta.mimeType; 211 headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 212 return new Response(content, { headers }); 213 } 214 } 215 216 headers['Content-Type'] = meta.mimeType; 217 headers['Content-Encoding'] = 'gzip'; 218 headers['Cache-Control'] = meta.mimeType.startsWith('text/html') 219 ? 'public, max-age=300' 220 : 'public, max-age=31536000, immutable'; 221 return new Response(content, { headers }); 222 } 223 224 // Non-compressed files 225 const mimeType = lookup(cachedFile) || 'application/octet-stream'; 226 headers['Content-Type'] = mimeType; 227 headers['Cache-Control'] = mimeType.startsWith('text/html') 228 ? 'public, max-age=300' 229 : 'public, max-age=31536000, immutable'; 230 return new Response(content, { headers }); 231 } 232 233 // Try index files for directory-like paths 234 if (!requestPath.includes('.')) { 235 for (const indexFileName of INDEX_FILES) { 236 const indexPath = `${requestPath}/${indexFileName}`; 237 const indexCacheKey = getCacheKey(did, rkey, indexPath); 238 const indexFile = getCachedFilePath(did, rkey, indexPath); 239 240 let indexContent = fileCache.get(indexCacheKey); 241 let indexMeta = metadataCache.get(indexCacheKey); 242 243 if (!indexContent && await fileExists(indexFile)) { 244 indexContent = await readFile(indexFile); 245 fileCache.set(indexCacheKey, indexContent, indexContent.length); 246 247 const indexMetaFile = `${indexFile}.meta`; 248 if (await fileExists(indexMetaFile)) { 249 const metaJson = await readFile(indexMetaFile, 'utf-8'); 250 indexMeta = JSON.parse(metaJson); 251 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 252 } 253 } 254 255 if (indexContent) { 256 const headers: Record<string, string> = { 257 'Content-Type': 'text/html; charset=utf-8', 258 'Cache-Control': 'public, max-age=300', 259 }; 260 261 if (indexMeta && indexMeta.encoding === 'gzip') { 262 headers['Content-Encoding'] = 'gzip'; 263 } 264 265 return new Response(indexContent, { headers }); 266 } 267 } 268 } 269 270 return new Response('Not Found', { status: 404 }); 271} 272 273// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes 274async function serveFromCacheWithRewrite( 275 did: string, 276 rkey: string, 277 filePath: string, 278 basePath: string, 279 fullUrl?: string, 280 headers?: Record<string, string> 281) { 282 // Check for redirect rules first 283 const redirectCacheKey = `${did}:${rkey}`; 284 let redirectRules = redirectRulesCache.get(redirectCacheKey); 285 286 if (redirectRules === undefined) { 287 // Load rules for the first time 288 redirectRules = await loadRedirectRules(did, rkey); 289 redirectRulesCache.set(redirectCacheKey, redirectRules); 290 } 291 292 // Apply redirect rules if any exist 293 if (redirectRules.length > 0) { 294 const requestPath = '/' + (filePath || ''); 295 const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 296 const cookies = parseCookies(headers?.['cookie']); 297 298 const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 299 queryParams, 300 headers, 301 cookies, 302 }); 303 304 if (redirectMatch) { 305 const { rule, targetPath, status } = redirectMatch; 306 307 // If not forced, check if the requested file exists before redirecting 308 if (!rule.force) { 309 // Build the expected file path 310 let checkPath = filePath || INDEX_FILES[0]; 311 if (checkPath.endsWith('/')) { 312 checkPath += INDEX_FILES[0]; 313 } 314 315 const cachedFile = getCachedFilePath(did, rkey, checkPath); 316 const fileExistsOnDisk = await fileExists(cachedFile); 317 318 // If file exists and redirect is not forced, serve the file normally 319 if (fileExistsOnDisk) { 320 return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 321 } 322 } 323 324 // Handle different status codes 325 if (status === 200) { 326 // Rewrite: serve different content but keep URL the same 327 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 328 return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath); 329 } else if (status === 301 || status === 302) { 330 // External redirect: change the URL 331 // For sites.wisp.place, we need to adjust the target path to include the base path 332 // unless it's an absolute URL 333 let redirectTarget = targetPath; 334 if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) { 335 redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath); 336 } 337 return new Response(null, { 338 status, 339 headers: { 340 'Location': redirectTarget, 341 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 342 }, 343 }); 344 } else if (status === 404) { 345 // Custom 404 page 346 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 347 const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath); 348 // Override status to 404 349 return new Response(response.body, { 350 status: 404, 351 headers: response.headers, 352 }); 353 } 354 } 355 } 356 357 // No redirect matched, serve normally 358 return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 359} 360 361// Internal function to serve a file with rewriting 362async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) { 363 // Default to first index file if path is empty 364 let requestPath = filePath || INDEX_FILES[0]; 365 366 // If path ends with /, append first index file 367 if (requestPath.endsWith('/')) { 368 requestPath += INDEX_FILES[0]; 369 } 370 371 const cacheKey = getCacheKey(did, rkey, requestPath); 372 const cachedFile = getCachedFilePath(did, rkey, requestPath); 373 374 // Check if the cached file path is a directory 375 if (await fileExists(cachedFile)) { 376 const { stat } = await import('fs/promises'); 377 try { 378 const stats = await stat(cachedFile); 379 if (stats.isDirectory()) { 380 // It's a directory, try each index file in order 381 for (const indexFile of INDEX_FILES) { 382 const indexPath = `${requestPath}/${indexFile}`; 383 const indexFilePath = getCachedFilePath(did, rkey, indexPath); 384 if (await fileExists(indexFilePath)) { 385 return serveFileInternalWithRewrite(did, rkey, indexPath, basePath); 386 } 387 } 388 // No index file found, fall through to 404 389 } 390 } catch (err) { 391 // If stat fails, continue with normal flow 392 } 393 } 394 395 // Check for rewritten HTML in cache first (if it's HTML) 396 const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream'; 397 if (isHtmlContent(requestPath, mimeTypeGuess)) { 398 const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 399 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 400 if (rewrittenContent) { 401 return new Response(rewrittenContent, { 402 headers: { 403 'Content-Type': 'text/html; charset=utf-8', 404 'Content-Encoding': 'gzip', 405 'Cache-Control': 'public, max-age=300', 406 }, 407 }); 408 } 409 } 410 411 // Check in-memory file cache 412 let content = fileCache.get(cacheKey); 413 let meta = metadataCache.get(cacheKey); 414 415 if (!content && await fileExists(cachedFile)) { 416 // Read from disk and cache 417 content = await readFile(cachedFile); 418 fileCache.set(cacheKey, content, content.length); 419 420 const metaFile = `${cachedFile}.meta`; 421 if (await fileExists(metaFile)) { 422 const metaJson = await readFile(metaFile, 'utf-8'); 423 meta = JSON.parse(metaJson); 424 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 425 } 426 } 427 428 if (content) { 429 const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream'; 430 const isGzipped = meta?.encoding === 'gzip'; 431 432 // Check if this is HTML content that needs rewriting 433 if (isHtmlContent(requestPath, mimeType)) { 434 let htmlContent: string; 435 if (isGzipped) { 436 // Verify content is actually gzipped 437 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 438 if (hasGzipMagic) { 439 const { gunzipSync } = await import('zlib'); 440 htmlContent = gunzipSync(content).toString('utf-8'); 441 } else { 442 console.warn(`File ${requestPath} marked as gzipped but lacks magic bytes, serving as-is`); 443 htmlContent = content.toString('utf-8'); 444 } 445 } else { 446 htmlContent = content.toString('utf-8'); 447 } 448 const rewritten = rewriteHtmlPaths(htmlContent, basePath, requestPath); 449 450 // Recompress and cache the rewritten HTML 451 const { gzipSync } = await import('zlib'); 452 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 453 454 const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 455 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 456 457 return new Response(recompressed, { 458 headers: { 459 'Content-Type': 'text/html; charset=utf-8', 460 'Content-Encoding': 'gzip', 461 'Cache-Control': 'public, max-age=300', 462 }, 463 }); 464 } 465 466 // Non-HTML files: serve as-is 467 const headers: Record<string, string> = { 468 'Content-Type': mimeType, 469 'Cache-Control': 'public, max-age=31536000, immutable', 470 }; 471 472 if (isGzipped) { 473 const shouldServeCompressed = shouldCompressMimeType(mimeType); 474 if (!shouldServeCompressed) { 475 // Verify content is actually gzipped 476 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 477 if (hasGzipMagic) { 478 const { gunzipSync } = await import('zlib'); 479 const decompressed = gunzipSync(content); 480 return new Response(decompressed, { headers }); 481 } else { 482 console.warn(`File ${requestPath} marked as gzipped but lacks magic bytes, serving as-is`); 483 return new Response(content, { headers }); 484 } 485 } 486 headers['Content-Encoding'] = 'gzip'; 487 } 488 489 return new Response(content, { headers }); 490 } 491 492 // Try index files for directory-like paths 493 if (!requestPath.includes('.')) { 494 for (const indexFileName of INDEX_FILES) { 495 const indexPath = `${requestPath}/${indexFileName}`; 496 const indexCacheKey = getCacheKey(did, rkey, indexPath); 497 const indexFile = getCachedFilePath(did, rkey, indexPath); 498 499 // Check for rewritten index file in cache 500 const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`); 501 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 502 if (rewrittenContent) { 503 return new Response(rewrittenContent, { 504 headers: { 505 'Content-Type': 'text/html; charset=utf-8', 506 'Content-Encoding': 'gzip', 507 'Cache-Control': 'public, max-age=300', 508 }, 509 }); 510 } 511 512 let indexContent = fileCache.get(indexCacheKey); 513 let indexMeta = metadataCache.get(indexCacheKey); 514 515 if (!indexContent && await fileExists(indexFile)) { 516 indexContent = await readFile(indexFile); 517 fileCache.set(indexCacheKey, indexContent, indexContent.length); 518 519 const indexMetaFile = `${indexFile}.meta`; 520 if (await fileExists(indexMetaFile)) { 521 const metaJson = await readFile(indexMetaFile, 'utf-8'); 522 indexMeta = JSON.parse(metaJson); 523 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 524 } 525 } 526 527 if (indexContent) { 528 const isGzipped = indexMeta?.encoding === 'gzip'; 529 530 let htmlContent: string; 531 if (isGzipped) { 532 // Verify content is actually gzipped 533 const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b; 534 if (hasGzipMagic) { 535 const { gunzipSync } = await import('zlib'); 536 htmlContent = gunzipSync(indexContent).toString('utf-8'); 537 } else { 538 console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`); 539 htmlContent = indexContent.toString('utf-8'); 540 } 541 } else { 542 htmlContent = indexContent.toString('utf-8'); 543 } 544 const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath); 545 546 const { gzipSync } = await import('zlib'); 547 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 548 549 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 550 551 return new Response(recompressed, { 552 headers: { 553 'Content-Type': 'text/html; charset=utf-8', 554 'Content-Encoding': 'gzip', 555 'Cache-Control': 'public, max-age=300', 556 }, 557 }); 558 } 559 } 560 } 561 562 return new Response('Not Found', { status: 404 }); 563} 564 565// Helper to ensure site is cached 566async function ensureSiteCached(did: string, rkey: string): Promise<boolean> { 567 if (isCached(did, rkey)) { 568 return true; 569 } 570 571 // Fetch and cache the site 572 const siteData = await fetchSiteRecord(did, rkey); 573 if (!siteData) { 574 logger.error('Site record not found', null, { did, rkey }); 575 return false; 576 } 577 578 const pdsEndpoint = await getPdsForDid(did); 579 if (!pdsEndpoint) { 580 logger.error('PDS not found for DID', null, { did }); 581 return false; 582 } 583 584 try { 585 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 586 // Clear redirect rules cache since the site was updated 587 clearRedirectRulesCache(did, rkey); 588 logger.info('Site cached successfully', { did, rkey }); 589 return true; 590 } catch (err) { 591 logger.error('Failed to cache site', err, { did, rkey }); 592 return false; 593 } 594} 595 596const app = new Hono(); 597 598// Add CORS middleware - allow all origins for static site hosting 599app.use('*', cors({ 600 origin: '*', 601 allowMethods: ['GET', 'HEAD', 'OPTIONS'], 602 allowHeaders: ['Content-Type', 'Authorization'], 603 exposeHeaders: ['Content-Length', 'Content-Type', 'Content-Encoding', 'Cache-Control'], 604 maxAge: 86400, // 24 hours 605 credentials: false, 606})); 607 608// Add observability middleware 609app.use('*', observabilityMiddleware('hosting-service')); 610 611// Error handler 612app.onError(observabilityErrorHandler('hosting-service')); 613 614// Main site serving route 615app.get('/*', async (c) => { 616 const url = new URL(c.req.url); 617 const hostname = c.req.header('host') || ''; 618 const rawPath = url.pathname.replace(/^\//, ''); 619 const path = sanitizePath(rawPath); 620 621 // Check if this is sites.wisp.place subdomain 622 if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 623 // Sanitize the path FIRST to prevent path traversal 624 const sanitizedFullPath = sanitizePath(rawPath); 625 626 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 627 const pathParts = sanitizedFullPath.split('/'); 628 if (pathParts.length < 2) { 629 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 630 } 631 632 const identifier = pathParts[0]; 633 const site = pathParts[1]; 634 const filePath = pathParts.slice(2).join('/'); 635 636 // Additional validation: identifier must be a valid DID or handle format 637 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 638 return c.text('Invalid identifier', 400); 639 } 640 641 // Validate site parameter exists 642 if (!site) { 643 return c.text('Site name required', 400); 644 } 645 646 // Validate site name (rkey) 647 if (!isValidRkey(site)) { 648 return c.text('Invalid site name', 400); 649 } 650 651 // Resolve identifier to DID 652 const did = await resolveDid(identifier); 653 if (!did) { 654 return c.text('Invalid identifier', 400); 655 } 656 657 // Ensure site is cached 658 const cached = await ensureSiteCached(did, site); 659 if (!cached) { 660 return c.text('Site not found', 404); 661 } 662 663 // Serve with HTML path rewriting to handle absolute paths 664 const basePath = `/${identifier}/${site}/`; 665 const headers: Record<string, string> = {}; 666 c.req.raw.headers.forEach((value, key) => { 667 headers[key.toLowerCase()] = value; 668 }); 669 return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers); 670 } 671 672 // Check if this is a DNS hash subdomain 673 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 674 if (dnsMatch) { 675 const hash = dnsMatch[1]; 676 const baseDomain = dnsMatch[2]; 677 678 if (!hash) { 679 return c.text('Invalid DNS hash', 400); 680 } 681 682 if (baseDomain !== BASE_HOST) { 683 return c.text('Invalid base domain', 400); 684 } 685 686 const customDomain = await getCustomDomainByHash(hash); 687 if (!customDomain) { 688 return c.text('Custom domain not found or not verified', 404); 689 } 690 691 if (!customDomain.rkey) { 692 return c.text('Domain not mapped to a site', 404); 693 } 694 695 const rkey = customDomain.rkey; 696 if (!isValidRkey(rkey)) { 697 return c.text('Invalid site configuration', 500); 698 } 699 700 const cached = await ensureSiteCached(customDomain.did, rkey); 701 if (!cached) { 702 return c.text('Site not found', 404); 703 } 704 705 const headers: Record<string, string> = {}; 706 c.req.raw.headers.forEach((value, key) => { 707 headers[key.toLowerCase()] = value; 708 }); 709 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 710 } 711 712 // Route 2: Registered subdomains - /*.wisp.place/* 713 if (hostname.endsWith(`.${BASE_HOST}`)) { 714 const domainInfo = await getWispDomain(hostname); 715 if (!domainInfo) { 716 return c.text('Subdomain not registered', 404); 717 } 718 719 if (!domainInfo.rkey) { 720 return c.text('Domain not mapped to a site', 404); 721 } 722 723 const rkey = domainInfo.rkey; 724 if (!isValidRkey(rkey)) { 725 return c.text('Invalid site configuration', 500); 726 } 727 728 const cached = await ensureSiteCached(domainInfo.did, rkey); 729 if (!cached) { 730 return c.text('Site not found', 404); 731 } 732 733 const headers: Record<string, string> = {}; 734 c.req.raw.headers.forEach((value, key) => { 735 headers[key.toLowerCase()] = value; 736 }); 737 return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers); 738 } 739 740 // Route 1: Custom domains - /* 741 const customDomain = await getCustomDomain(hostname); 742 if (!customDomain) { 743 return c.text('Custom domain not found or not verified', 404); 744 } 745 746 if (!customDomain.rkey) { 747 return c.text('Domain not mapped to a site', 404); 748 } 749 750 const rkey = customDomain.rkey; 751 if (!isValidRkey(rkey)) { 752 return c.text('Invalid site configuration', 500); 753 } 754 755 const cached = await ensureSiteCached(customDomain.did, rkey); 756 if (!cached) { 757 return c.text('Site not found', 404); 758 } 759 760 const headers: Record<string, string> = {}; 761 c.req.raw.headers.forEach((value, key) => { 762 headers[key.toLowerCase()] = value; 763 }); 764 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 765}); 766 767// Internal observability endpoints (for admin panel) 768app.get('/__internal__/observability/logs', (c) => { 769 const query = c.req.query(); 770 const filter: any = {}; 771 if (query.level) filter.level = query.level; 772 if (query.service) filter.service = query.service; 773 if (query.search) filter.search = query.search; 774 if (query.eventType) filter.eventType = query.eventType; 775 if (query.limit) filter.limit = parseInt(query.limit as string); 776 return c.json({ logs: logCollector.getLogs(filter) }); 777}); 778 779app.get('/__internal__/observability/errors', (c) => { 780 const query = c.req.query(); 781 const filter: any = {}; 782 if (query.service) filter.service = query.service; 783 if (query.limit) filter.limit = parseInt(query.limit as string); 784 return c.json({ errors: errorTracker.getErrors(filter) }); 785}); 786 787app.get('/__internal__/observability/metrics', (c) => { 788 const query = c.req.query(); 789 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 790 const stats = metricsCollector.getStats('hosting-service', timeWindow); 791 return c.json({ stats, timeWindow }); 792}); 793 794app.get('/__internal__/observability/cache', async (c) => { 795 const { getCacheStats } = await import('./lib/cache'); 796 const stats = getCacheStats(); 797 return c.json({ cache: stats }); 798}); 799 800export default app;