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