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, getCachedSettings } from './lib/utils'; 5import type { Record as WispSettings } from './lexicon/types/place/wisp/settings'; 6import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 7import { existsSync } from 'fs'; 8import { readFile, access } from 'fs/promises'; 9import { lookup } from 'mime-types'; 10import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 11import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata, markSiteAsBeingCached, unmarkSiteAsBeingCached, isSiteBeingCached } from './lib/cache'; 12import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects'; 13 14const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 15 16/** 17 * Default index file names to check for directory requests 18 * Will be checked in order until one is found 19 */ 20const DEFAULT_INDEX_FILES = ['index.html', 'index.htm']; 21 22/** 23 * Get index files list from settings or use defaults 24 */ 25function getIndexFiles(settings: WispSettings | null): string[] { 26 if (settings?.indexFiles && settings.indexFiles.length > 0) { 27 return settings.indexFiles; 28 } 29 return DEFAULT_INDEX_FILES; 30} 31 32/** 33 * Match a file path against a glob pattern 34 * Supports * wildcard and basic path matching 35 */ 36function matchGlob(path: string, pattern: string): boolean { 37 // Normalize paths 38 const normalizedPath = path.startsWith('/') ? path : '/' + path; 39 const normalizedPattern = pattern.startsWith('/') ? pattern : '/' + pattern; 40 41 // Convert glob pattern to regex 42 const regexPattern = normalizedPattern 43 .replace(/\./g, '\\.') 44 .replace(/\*/g, '.*') 45 .replace(/\?/g, '.'); 46 47 const regex = new RegExp('^' + regexPattern + '$'); 48 return regex.test(normalizedPath); 49} 50 51/** 52 * Apply custom headers from settings to response headers 53 */ 54function applyCustomHeaders(headers: Record<string, string>, filePath: string, settings: WispSettings | null) { 55 if (!settings?.headers || settings.headers.length === 0) return; 56 57 for (const customHeader of settings.headers) { 58 // If path glob is specified, check if it matches 59 if (customHeader.path) { 60 if (!matchGlob(filePath, customHeader.path)) { 61 continue; 62 } 63 } 64 // Apply the header 65 headers[customHeader.name] = customHeader.value; 66 } 67} 68 69/** 70 * Generate 404 page HTML 71 */ 72function generate404Page(): string { 73 const html = `<!DOCTYPE html> 74<html> 75<head> 76 <meta charset="utf-8"> 77 <meta name="viewport" content="width=device-width, initial-scale=1"> 78 <title>404 - Not Found</title> 79 <style> 80 @media (prefers-color-scheme: light) { 81 :root { 82 /* Warm beige background */ 83 --background: oklch(0.90 0.012 35); 84 /* Very dark brown text */ 85 --foreground: oklch(0.18 0.01 30); 86 --border: oklch(0.75 0.015 30); 87 /* Bright pink accent for links */ 88 --accent: oklch(0.78 0.15 345); 89 } 90 } 91 @media (prefers-color-scheme: dark) { 92 :root { 93 /* Slate violet background */ 94 --background: oklch(0.23 0.015 285); 95 /* Light gray text */ 96 --foreground: oklch(0.90 0.005 285); 97 /* Subtle borders */ 98 --border: oklch(0.38 0.02 285); 99 /* Soft pink accent */ 100 --accent: oklch(0.85 0.08 5); 101 } 102 } 103 body { 104 font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; 105 background: var(--background); 106 color: var(--foreground); 107 padding: 2rem; 108 max-width: 800px; 109 margin: 0 auto; 110 display: flex; 111 flex-direction: column; 112 min-height: 100vh; 113 justify-content: center; 114 align-items: center; 115 text-align: center; 116 } 117 h1 { 118 font-size: 6rem; 119 margin: 0; 120 font-weight: 700; 121 line-height: 1; 122 } 123 h2 { 124 font-size: 1.5rem; 125 margin: 1rem 0 2rem; 126 font-weight: 400; 127 opacity: 0.8; 128 } 129 p { 130 font-size: 1rem; 131 opacity: 0.7; 132 margin-bottom: 2rem; 133 } 134 a { 135 color: var(--accent); 136 text-decoration: none; 137 font-size: 1rem; 138 } 139 a:hover { 140 text-decoration: underline; 141 } 142 footer { 143 margin-top: 2rem; 144 padding-top: 1.5rem; 145 border-top: 1px solid var(--border); 146 text-align: center; 147 font-size: 0.875rem; 148 opacity: 0.7; 149 color: var(--foreground); 150 } 151 footer a { 152 color: var(--accent); 153 text-decoration: none; 154 display: inline; 155 } 156 footer a:hover { 157 text-decoration: underline; 158 } 159 </style> 160</head> 161<body> 162 <div> 163 <h1>404</h1> 164 <h2>Page not found</h2> 165 <p>The page you're looking for doesn't exist.</p> 166 <a href="/">← Back to home</a> 167 </div> 168 <footer> 169 Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a> 170 </footer> 171</body> 172</html>`; 173 return html; 174} 175 176/** 177 * Generate directory listing HTML 178 */ 179function generateDirectoryListing(path: string, entries: Array<{name: string, isDirectory: boolean}>): string { 180 const title = path || 'Index'; 181 182 // Sort: directories first, then files, alphabetically within each group 183 const sortedEntries = [...entries].sort((a, b) => { 184 if (a.isDirectory && !b.isDirectory) return -1; 185 if (!a.isDirectory && b.isDirectory) return 1; 186 return a.name.localeCompare(b.name); 187 }); 188 189 const html = `<!DOCTYPE html> 190<html> 191<head> 192 <meta charset="utf-8"> 193 <meta name="viewport" content="width=device-width, initial-scale=1"> 194 <title>Index of /${path}</title> 195 <style> 196 @media (prefers-color-scheme: light) { 197 :root { 198 /* Warm beige background */ 199 --background: oklch(0.90 0.012 35); 200 /* Very dark brown text */ 201 --foreground: oklch(0.18 0.01 30); 202 --border: oklch(0.75 0.015 30); 203 /* Bright pink accent for links */ 204 --accent: oklch(0.78 0.15 345); 205 /* Lavender for folders */ 206 --folder: oklch(0.60 0.12 295); 207 --icon: oklch(0.28 0.01 30); 208 } 209 } 210 @media (prefers-color-scheme: dark) { 211 :root { 212 /* Slate violet background */ 213 --background: oklch(0.23 0.015 285); 214 /* Light gray text */ 215 --foreground: oklch(0.90 0.005 285); 216 /* Subtle borders */ 217 --border: oklch(0.38 0.02 285); 218 /* Soft pink accent */ 219 --accent: oklch(0.85 0.08 5); 220 /* Lavender for folders */ 221 --folder: oklch(0.70 0.10 295); 222 --icon: oklch(0.85 0.005 285); 223 } 224 } 225 body { 226 font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; 227 background: var(--background); 228 color: var(--foreground); 229 padding: 2rem; 230 max-width: 800px; 231 margin: 0 auto; 232 } 233 h1 { 234 font-size: 1.5rem; 235 margin-bottom: 2rem; 236 padding-bottom: 0.5rem; 237 border-bottom: 1px solid var(--border); 238 } 239 ul { 240 list-style: none; 241 padding: 0; 242 } 243 li { 244 padding: 0.5rem 0; 245 border-bottom: 1px solid var(--border); 246 } 247 li:last-child { 248 border-bottom: none; 249 } 250 li a { 251 color: var(--accent); 252 text-decoration: none; 253 display: flex; 254 align-items: center; 255 gap: 0.75rem; 256 } 257 li a:hover { 258 text-decoration: underline; 259 } 260 .folder { 261 color: var(--folder); 262 font-weight: 600; 263 } 264 .file { 265 color: var(--accent); 266 } 267 .folder::before, 268 .file::before, 269 .parent::before { 270 content: ""; 271 display: inline-block; 272 width: 1.25em; 273 height: 1.25em; 274 background-color: var(--icon); 275 flex-shrink: 0; 276 -webkit-mask-size: contain; 277 mask-size: contain; 278 -webkit-mask-repeat: no-repeat; 279 mask-repeat: no-repeat; 280 -webkit-mask-position: center; 281 mask-position: center; 282 } 283 .folder::before { 284 -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>'); 285 mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>'); 286 } 287 .file::before { 288 -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>'); 289 mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>'); 290 } 291 .parent::before { 292 -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>'); 293 mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>'); 294 } 295 footer { 296 margin-top: 2rem; 297 padding-top: 1.5rem; 298 border-top: 1px solid var(--border); 299 text-align: center; 300 font-size: 0.875rem; 301 opacity: 0.7; 302 color: var(--foreground); 303 } 304 footer a { 305 color: var(--accent); 306 text-decoration: none; 307 display: inline; 308 } 309 footer a:hover { 310 text-decoration: underline; 311 } 312 </style> 313</head> 314<body> 315 <h1>Index of /${path}</h1> 316 <ul> 317 ${path ? '<li><a href="../" class="parent">../</a></li>' : ''} 318 ${sortedEntries.map(e => 319 `<li><a href="${e.name}${e.isDirectory ? '/' : ''}" class="${e.isDirectory ? 'folder' : 'file'}">${e.name}${e.isDirectory ? '/' : ''}</a></li>` 320 ).join('\n ')} 321 </ul> 322 <footer> 323 Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a> 324 </footer> 325</body> 326</html>`; 327 return html; 328} 329 330/** 331 * Validate site name (rkey) to prevent injection attacks 332 * Must match AT Protocol rkey format 333 */ 334function isValidRkey(rkey: string): boolean { 335 if (!rkey || typeof rkey !== 'string') return false; 336 if (rkey.length < 1 || rkey.length > 512) return false; 337 if (rkey === '.' || rkey === '..') return false; 338 if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false; 339 const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/; 340 return validRkeyPattern.test(rkey); 341} 342 343/** 344 * Async file existence check 345 */ 346async function fileExists(path: string): Promise<boolean> { 347 try { 348 await access(path); 349 return true; 350 } catch { 351 return false; 352 } 353} 354 355/** 356 * Return a response indicating the site is being updated 357 */ 358function siteUpdatingResponse(): Response { 359 const html = `<!DOCTYPE html> 360<html> 361<head> 362 <meta charset="utf-8"> 363 <meta name="viewport" content="width=device-width, initial-scale=1"> 364 <title>Site Updating</title> 365 <style> 366 @media (prefers-color-scheme: light) { 367 :root { 368 --background: oklch(0.90 0.012 35); 369 --foreground: oklch(0.18 0.01 30); 370 --primary: oklch(0.35 0.02 35); 371 --accent: oklch(0.78 0.15 345); 372 } 373 } 374 @media (prefers-color-scheme: dark) { 375 :root { 376 --background: oklch(0.23 0.015 285); 377 --foreground: oklch(0.90 0.005 285); 378 --primary: oklch(0.70 0.10 295); 379 --accent: oklch(0.85 0.08 5); 380 } 381 } 382 body { 383 font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 384 display: flex; 385 align-items: center; 386 justify-content: center; 387 min-height: 100vh; 388 margin: 0; 389 background: var(--background); 390 color: var(--foreground); 391 } 392 .container { 393 text-align: center; 394 padding: 2rem; 395 max-width: 500px; 396 } 397 h1 { 398 font-size: 2.5rem; 399 margin-bottom: 1rem; 400 font-weight: 600; 401 color: var(--primary); 402 } 403 p { 404 font-size: 1.25rem; 405 opacity: 0.8; 406 margin-bottom: 2rem; 407 color: var(--foreground); 408 } 409 .spinner { 410 border: 4px solid var(--accent); 411 border-radius: 50%; 412 border-top: 4px solid var(--primary); 413 width: 40px; 414 height: 40px; 415 animation: spin 1s linear infinite; 416 margin: 0 auto; 417 } 418 @keyframes spin { 419 0% { transform: rotate(0deg); } 420 100% { transform: rotate(360deg); } 421 } 422 </style> 423 <meta http-equiv="refresh" content="3"> 424</head> 425<body> 426 <div class="container"> 427 <h1>Site Updating</h1> 428 <p>This site is undergoing an update right now. Check back in a moment...</p> 429 <div class="spinner"></div> 430 </div> 431</body> 432</html>`; 433 434 return new Response(html, { 435 status: 503, 436 headers: { 437 'Content-Type': 'text/html; charset=utf-8', 438 'Cache-Control': 'no-store, no-cache, must-revalidate', 439 'Retry-After': '3', 440 }, 441 }); 442} 443 444// Cache for redirect rules (per site) 445const redirectRulesCache = new Map<string, RedirectRule[]>(); 446 447/** 448 * Clear redirect rules cache for a specific site 449 * Should be called when a site is updated/recached 450 */ 451export function clearRedirectRulesCache(did: string, rkey: string) { 452 const cacheKey = `${did}:${rkey}`; 453 redirectRulesCache.delete(cacheKey); 454} 455 456// Helper to serve files from cache 457async function serveFromCache( 458 did: string, 459 rkey: string, 460 filePath: string, 461 fullUrl?: string, 462 headers?: Record<string, string> 463) { 464 // Load settings for this site 465 const settings = await getCachedSettings(did, rkey); 466 const indexFiles = getIndexFiles(settings); 467 468 // Check for redirect rules first (_redirects wins over settings) 469 const redirectCacheKey = `${did}:${rkey}`; 470 let redirectRules = redirectRulesCache.get(redirectCacheKey); 471 472 if (redirectRules === undefined) { 473 // Load rules for the first time 474 redirectRules = await loadRedirectRules(did, rkey); 475 redirectRulesCache.set(redirectCacheKey, redirectRules); 476 } 477 478 // Apply redirect rules if any exist 479 if (redirectRules.length > 0) { 480 const requestPath = '/' + (filePath || ''); 481 const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 482 const cookies = parseCookies(headers?.['cookie']); 483 484 const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 485 queryParams, 486 headers, 487 cookies, 488 }); 489 490 if (redirectMatch) { 491 const { rule, targetPath, status } = redirectMatch; 492 493 // If not forced, check if the requested file exists before redirecting 494 if (!rule.force) { 495 // Build the expected file path 496 let checkPath = filePath || indexFiles[0]; 497 if (checkPath.endsWith('/')) { 498 checkPath += indexFiles[0]; 499 } 500 501 const cachedFile = getCachedFilePath(did, rkey, checkPath); 502 const fileExistsOnDisk = await fileExists(cachedFile); 503 504 // If file exists and redirect is not forced, serve the file normally 505 if (fileExistsOnDisk) { 506 return serveFileInternal(did, rkey, filePath, settings); 507 } 508 } 509 510 // Handle different status codes 511 if (status === 200) { 512 // Rewrite: serve different content but keep URL the same 513 // Remove leading slash for internal path resolution 514 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 515 return serveFileInternal(did, rkey, rewritePath, settings); 516 } else if (status === 301 || status === 302) { 517 // External redirect: change the URL 518 return new Response(null, { 519 status, 520 headers: { 521 'Location': targetPath, 522 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 523 }, 524 }); 525 } else if (status === 404) { 526 // Custom 404 page from _redirects (wins over settings.custom404) 527 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 528 const response = await serveFileInternal(did, rkey, custom404Path, settings); 529 // Override status to 404 530 return new Response(response.body, { 531 status: 404, 532 headers: response.headers, 533 }); 534 } 535 } 536 } 537 538 // No redirect matched, serve normally with settings 539 return serveFileInternal(did, rkey, filePath, settings); 540} 541 542// Internal function to serve a file (used by both normal serving and rewrites) 543async function serveFileInternal(did: string, rkey: string, filePath: string, settings: WispSettings | null = null) { 544 // Check if site is currently being cached - if so, return updating response 545 if (isSiteBeingCached(did, rkey)) { 546 return siteUpdatingResponse(); 547 } 548 549 const indexFiles = getIndexFiles(settings); 550 551 // Normalize the request path (keep empty for root, remove trailing slash for others) 552 let requestPath = filePath || ''; 553 if (requestPath.endsWith('/') && requestPath.length > 1) { 554 requestPath = requestPath.slice(0, -1); 555 } 556 557 // Check if this path is a directory first 558 const directoryPath = getCachedFilePath(did, rkey, requestPath); 559 if (await fileExists(directoryPath)) { 560 const { stat, readdir } = await import('fs/promises'); 561 try { 562 const stats = await stat(directoryPath); 563 if (stats.isDirectory()) { 564 // It's a directory, try each index file in order 565 for (const indexFile of indexFiles) { 566 const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile; 567 const indexFilePath = getCachedFilePath(did, rkey, indexPath); 568 if (await fileExists(indexFilePath)) { 569 return serveFileInternal(did, rkey, indexPath, settings); 570 } 571 } 572 // No index file found - check if directory listing is enabled 573 if (settings?.directoryListing) { 574 const { stat } = await import('fs/promises'); 575 const entries = await readdir(directoryPath); 576 // Filter out .meta files and other hidden files 577 const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json'); 578 579 // Check which entries are directories 580 const entriesWithType = await Promise.all( 581 visibleEntries.map(async (name) => { 582 try { 583 const entryPath = `${directoryPath}/${name}`; 584 const stats = await stat(entryPath); 585 return { name, isDirectory: stats.isDirectory() }; 586 } catch { 587 return { name, isDirectory: false }; 588 } 589 }) 590 ); 591 592 const html = generateDirectoryListing(requestPath, entriesWithType); 593 return new Response(html, { 594 headers: { 595 'Content-Type': 'text/html; charset=utf-8', 596 'Cache-Control': 'public, max-age=300', 597 }, 598 }); 599 } 600 // Fall through to 404/SPA handling 601 } 602 } catch (err) { 603 // If stat fails, continue with normal flow 604 } 605 } 606 607 // Not a directory, try to serve as a file 608 const fileRequestPath = requestPath || indexFiles[0]; 609 const cacheKey = getCacheKey(did, rkey, fileRequestPath); 610 const cachedFile = getCachedFilePath(did, rkey, fileRequestPath); 611 612 // Check in-memory cache first 613 let content = fileCache.get(cacheKey); 614 let meta = metadataCache.get(cacheKey); 615 616 if (!content && await fileExists(cachedFile)) { 617 // Read from disk and cache 618 content = await readFile(cachedFile); 619 fileCache.set(cacheKey, content, content.length); 620 621 const metaFile = `${cachedFile}.meta`; 622 if (await fileExists(metaFile)) { 623 const metaJson = await readFile(metaFile, 'utf-8'); 624 meta = JSON.parse(metaJson); 625 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 626 } 627 } 628 629 if (content) { 630 // Build headers with caching 631 const headers: Record<string, string> = {}; 632 633 if (meta && meta.encoding === 'gzip' && meta.mimeType) { 634 const shouldServeCompressed = shouldCompressMimeType(meta.mimeType); 635 636 if (!shouldServeCompressed) { 637 // Verify content is actually gzipped before attempting decompression 638 const isGzipped = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 639 if (isGzipped) { 640 const { gunzipSync } = await import('zlib'); 641 const decompressed = gunzipSync(content); 642 headers['Content-Type'] = meta.mimeType; 643 headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 644 applyCustomHeaders(headers, fileRequestPath, settings); 645 return new Response(decompressed, { headers }); 646 } else { 647 // Meta says gzipped but content isn't - serve as-is 648 console.warn(`File ${filePath} has gzip encoding in meta but content lacks gzip magic bytes`); 649 headers['Content-Type'] = meta.mimeType; 650 headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 651 applyCustomHeaders(headers, fileRequestPath, settings); 652 return new Response(content, { headers }); 653 } 654 } 655 656 headers['Content-Type'] = meta.mimeType; 657 headers['Content-Encoding'] = 'gzip'; 658 headers['Cache-Control'] = meta.mimeType.startsWith('text/html') 659 ? 'public, max-age=300' 660 : 'public, max-age=31536000, immutable'; 661 applyCustomHeaders(headers, fileRequestPath, settings); 662 return new Response(content, { headers }); 663 } 664 665 // Non-compressed files 666 const mimeType = lookup(cachedFile) || 'application/octet-stream'; 667 headers['Content-Type'] = mimeType; 668 headers['Cache-Control'] = mimeType.startsWith('text/html') 669 ? 'public, max-age=300' 670 : 'public, max-age=31536000, immutable'; 671 applyCustomHeaders(headers, fileRequestPath, settings); 672 return new Response(content, { headers }); 673 } 674 675 // Try index files for directory-like paths 676 if (!fileRequestPath.includes('.')) { 677 for (const indexFileName of indexFiles) { 678 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName; 679 const indexCacheKey = getCacheKey(did, rkey, indexPath); 680 const indexFile = getCachedFilePath(did, rkey, indexPath); 681 682 let indexContent = fileCache.get(indexCacheKey); 683 let indexMeta = metadataCache.get(indexCacheKey); 684 685 if (!indexContent && await fileExists(indexFile)) { 686 indexContent = await readFile(indexFile); 687 fileCache.set(indexCacheKey, indexContent, indexContent.length); 688 689 const indexMetaFile = `${indexFile}.meta`; 690 if (await fileExists(indexMetaFile)) { 691 const metaJson = await readFile(indexMetaFile, 'utf-8'); 692 indexMeta = JSON.parse(metaJson); 693 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 694 } 695 } 696 697 if (indexContent) { 698 const headers: Record<string, string> = { 699 'Content-Type': 'text/html; charset=utf-8', 700 'Cache-Control': 'public, max-age=300', 701 }; 702 703 if (indexMeta && indexMeta.encoding === 'gzip') { 704 headers['Content-Encoding'] = 'gzip'; 705 } 706 707 applyCustomHeaders(headers, indexPath, settings); 708 return new Response(indexContent, { headers }); 709 } 710 } 711 } 712 713 // Try clean URLs: /about -> /about.html 714 if (settings?.cleanUrls && !fileRequestPath.includes('.')) { 715 const htmlPath = `${fileRequestPath}.html`; 716 const htmlFile = getCachedFilePath(did, rkey, htmlPath); 717 if (await fileExists(htmlFile)) { 718 return serveFileInternal(did, rkey, htmlPath, settings); 719 } 720 721 // Also try /about/index.html 722 for (const indexFileName of indexFiles) { 723 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName; 724 const indexFile = getCachedFilePath(did, rkey, indexPath); 725 if (await fileExists(indexFile)) { 726 return serveFileInternal(did, rkey, indexPath, settings); 727 } 728 } 729 } 730 731 // SPA mode: serve SPA file for all non-existing routes (wins over custom404 but loses to _redirects) 732 if (settings?.spaMode) { 733 const spaFile = settings.spaMode; 734 const spaFilePath = getCachedFilePath(did, rkey, spaFile); 735 if (await fileExists(spaFilePath)) { 736 return serveFileInternal(did, rkey, spaFile, settings); 737 } 738 } 739 740 // Custom 404: serve custom 404 file if configured (wins conflict battle) 741 if (settings?.custom404) { 742 const custom404File = settings.custom404; 743 const custom404Path = getCachedFilePath(did, rkey, custom404File); 744 if (await fileExists(custom404Path)) { 745 const response = await serveFileInternal(did, rkey, custom404File, settings); 746 // Override status to 404 747 return new Response(response.body, { 748 status: 404, 749 headers: response.headers, 750 }); 751 } 752 } 753 754 // Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html) 755 const auto404Pages = ['404.html', 'not_found.html']; 756 for (const auto404Page of auto404Pages) { 757 const auto404Path = getCachedFilePath(did, rkey, auto404Page); 758 if (await fileExists(auto404Path)) { 759 const response = await serveFileInternal(did, rkey, auto404Page, settings); 760 // Override status to 404 761 return new Response(response.body, { 762 status: 404, 763 headers: response.headers, 764 }); 765 } 766 } 767 768 // Directory listing fallback: if enabled, show root directory listing on 404 769 if (settings?.directoryListing) { 770 const rootPath = getCachedFilePath(did, rkey, ''); 771 if (await fileExists(rootPath)) { 772 const { stat, readdir } = await import('fs/promises'); 773 try { 774 const stats = await stat(rootPath); 775 if (stats.isDirectory()) { 776 const entries = await readdir(rootPath); 777 // Filter out .meta files and metadata 778 const visibleEntries = entries.filter(entry => 779 !entry.endsWith('.meta') && entry !== '.metadata.json' 780 ); 781 782 // Check which entries are directories 783 const entriesWithType = await Promise.all( 784 visibleEntries.map(async (name) => { 785 try { 786 const entryPath = `${rootPath}/${name}`; 787 const entryStats = await stat(entryPath); 788 return { name, isDirectory: entryStats.isDirectory() }; 789 } catch { 790 return { name, isDirectory: false }; 791 } 792 }) 793 ); 794 795 const html = generateDirectoryListing('', entriesWithType); 796 return new Response(html, { 797 status: 404, 798 headers: { 799 'Content-Type': 'text/html; charset=utf-8', 800 'Cache-Control': 'public, max-age=300', 801 }, 802 }); 803 } 804 } catch (err) { 805 // If directory listing fails, fall through to 404 806 } 807 } 808 } 809 810 // Default styled 404 page 811 const html = generate404Page(); 812 return new Response(html, { 813 status: 404, 814 headers: { 815 'Content-Type': 'text/html; charset=utf-8', 816 'Cache-Control': 'public, max-age=300', 817 }, 818 }); 819} 820 821// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes 822async function serveFromCacheWithRewrite( 823 did: string, 824 rkey: string, 825 filePath: string, 826 basePath: string, 827 fullUrl?: string, 828 headers?: Record<string, string> 829) { 830 // Load settings for this site 831 const settings = await getCachedSettings(did, rkey); 832 const indexFiles = getIndexFiles(settings); 833 834 // Check for redirect rules first (_redirects wins over settings) 835 const redirectCacheKey = `${did}:${rkey}`; 836 let redirectRules = redirectRulesCache.get(redirectCacheKey); 837 838 if (redirectRules === undefined) { 839 // Load rules for the first time 840 redirectRules = await loadRedirectRules(did, rkey); 841 redirectRulesCache.set(redirectCacheKey, redirectRules); 842 } 843 844 // Apply redirect rules if any exist 845 if (redirectRules.length > 0) { 846 const requestPath = '/' + (filePath || ''); 847 const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 848 const cookies = parseCookies(headers?.['cookie']); 849 850 const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 851 queryParams, 852 headers, 853 cookies, 854 }); 855 856 if (redirectMatch) { 857 const { rule, targetPath, status } = redirectMatch; 858 859 // If not forced, check if the requested file exists before redirecting 860 if (!rule.force) { 861 // Build the expected file path 862 let checkPath = filePath || indexFiles[0]; 863 if (checkPath.endsWith('/')) { 864 checkPath += indexFiles[0]; 865 } 866 867 const cachedFile = getCachedFilePath(did, rkey, checkPath); 868 const fileExistsOnDisk = await fileExists(cachedFile); 869 870 // If file exists and redirect is not forced, serve the file normally 871 if (fileExistsOnDisk) { 872 return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings); 873 } 874 } 875 876 // Handle different status codes 877 if (status === 200) { 878 // Rewrite: serve different content but keep URL the same 879 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 880 return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath, settings); 881 } else if (status === 301 || status === 302) { 882 // External redirect: change the URL 883 // For sites.wisp.place, we need to adjust the target path to include the base path 884 // unless it's an absolute URL 885 let redirectTarget = targetPath; 886 if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) { 887 redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath); 888 } 889 return new Response(null, { 890 status, 891 headers: { 892 'Location': redirectTarget, 893 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 894 }, 895 }); 896 } else if (status === 404) { 897 // Custom 404 page from _redirects (wins over settings.custom404) 898 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 899 const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath, settings); 900 // Override status to 404 901 return new Response(response.body, { 902 status: 404, 903 headers: response.headers, 904 }); 905 } 906 } 907 } 908 909 // No redirect matched, serve normally with settings 910 return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings); 911} 912 913// Internal function to serve a file with rewriting 914async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string, settings: WispSettings | null = null) { 915 // Check if site is currently being cached - if so, return updating response 916 if (isSiteBeingCached(did, rkey)) { 917 return siteUpdatingResponse(); 918 } 919 920 const indexFiles = getIndexFiles(settings); 921 922 // Normalize the request path (keep empty for root, remove trailing slash for others) 923 let requestPath = filePath || ''; 924 if (requestPath.endsWith('/') && requestPath.length > 1) { 925 requestPath = requestPath.slice(0, -1); 926 } 927 928 // Check if this path is a directory first 929 const directoryPath = getCachedFilePath(did, rkey, requestPath); 930 if (await fileExists(directoryPath)) { 931 const { stat, readdir } = await import('fs/promises'); 932 try { 933 const stats = await stat(directoryPath); 934 if (stats.isDirectory()) { 935 // It's a directory, try each index file in order 936 for (const indexFile of indexFiles) { 937 const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile; 938 const indexFilePath = getCachedFilePath(did, rkey, indexPath); 939 if (await fileExists(indexFilePath)) { 940 return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings); 941 } 942 } 943 // No index file found - check if directory listing is enabled 944 if (settings?.directoryListing) { 945 const { stat } = await import('fs/promises'); 946 const entries = await readdir(directoryPath); 947 // Filter out .meta files and other hidden files 948 const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json'); 949 950 // Check which entries are directories 951 const entriesWithType = await Promise.all( 952 visibleEntries.map(async (name) => { 953 try { 954 const entryPath = `${directoryPath}/${name}`; 955 const stats = await stat(entryPath); 956 return { name, isDirectory: stats.isDirectory() }; 957 } catch { 958 return { name, isDirectory: false }; 959 } 960 }) 961 ); 962 963 const html = generateDirectoryListing(requestPath, entriesWithType); 964 return new Response(html, { 965 headers: { 966 'Content-Type': 'text/html; charset=utf-8', 967 'Cache-Control': 'public, max-age=300', 968 }, 969 }); 970 } 971 // Fall through to 404/SPA handling 972 } 973 } catch (err) { 974 // If stat fails, continue with normal flow 975 } 976 } 977 978 // Not a directory, try to serve as a file 979 const fileRequestPath = requestPath || indexFiles[0]; 980 const cacheKey = getCacheKey(did, rkey, fileRequestPath); 981 const cachedFile = getCachedFilePath(did, rkey, fileRequestPath); 982 983 // Check for rewritten HTML in cache first (if it's HTML) 984 const mimeTypeGuess = lookup(fileRequestPath) || 'application/octet-stream'; 985 if (isHtmlContent(fileRequestPath, mimeTypeGuess)) { 986 const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`); 987 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 988 if (rewrittenContent) { 989 const headers: Record<string, string> = { 990 'Content-Type': 'text/html; charset=utf-8', 991 'Content-Encoding': 'gzip', 992 'Cache-Control': 'public, max-age=300', 993 }; 994 applyCustomHeaders(headers, fileRequestPath, settings); 995 return new Response(rewrittenContent, { headers }); 996 } 997 } 998 999 // Check in-memory file cache 1000 let content = fileCache.get(cacheKey); 1001 let meta = metadataCache.get(cacheKey); 1002 1003 if (!content && await fileExists(cachedFile)) { 1004 // Read from disk and cache 1005 content = await readFile(cachedFile); 1006 fileCache.set(cacheKey, content, content.length); 1007 1008 const metaFile = `${cachedFile}.meta`; 1009 if (await fileExists(metaFile)) { 1010 const metaJson = await readFile(metaFile, 'utf-8'); 1011 meta = JSON.parse(metaJson); 1012 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 1013 } 1014 } 1015 1016 if (content) { 1017 const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream'; 1018 const isGzipped = meta?.encoding === 'gzip'; 1019 1020 // Check if this is HTML content that needs rewriting 1021 if (isHtmlContent(fileRequestPath, mimeType)) { 1022 let htmlContent: string; 1023 if (isGzipped) { 1024 // Verify content is actually gzipped 1025 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 1026 if (hasGzipMagic) { 1027 const { gunzipSync } = await import('zlib'); 1028 htmlContent = gunzipSync(content).toString('utf-8'); 1029 } else { 1030 console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`); 1031 htmlContent = content.toString('utf-8'); 1032 } 1033 } else { 1034 htmlContent = content.toString('utf-8'); 1035 } 1036 const rewritten = rewriteHtmlPaths(htmlContent, basePath, fileRequestPath); 1037 1038 // Recompress and cache the rewritten HTML 1039 const { gzipSync } = await import('zlib'); 1040 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 1041 1042 const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`); 1043 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 1044 1045 const htmlHeaders: Record<string, string> = { 1046 'Content-Type': 'text/html; charset=utf-8', 1047 'Content-Encoding': 'gzip', 1048 'Cache-Control': 'public, max-age=300', 1049 }; 1050 applyCustomHeaders(htmlHeaders, fileRequestPath, settings); 1051 return new Response(recompressed, { headers: htmlHeaders }); 1052 } 1053 1054 // Non-HTML files: serve as-is 1055 const headers: Record<string, string> = { 1056 'Content-Type': mimeType, 1057 'Cache-Control': 'public, max-age=31536000, immutable', 1058 }; 1059 1060 if (isGzipped) { 1061 const shouldServeCompressed = shouldCompressMimeType(mimeType); 1062 if (!shouldServeCompressed) { 1063 // Verify content is actually gzipped 1064 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b; 1065 if (hasGzipMagic) { 1066 const { gunzipSync } = await import('zlib'); 1067 const decompressed = gunzipSync(content); 1068 applyCustomHeaders(headers, fileRequestPath, settings); 1069 return new Response(decompressed, { headers }); 1070 } else { 1071 console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`); 1072 applyCustomHeaders(headers, fileRequestPath, settings); 1073 return new Response(content, { headers }); 1074 } 1075 } 1076 headers['Content-Encoding'] = 'gzip'; 1077 } 1078 1079 applyCustomHeaders(headers, fileRequestPath, settings); 1080 return new Response(content, { headers }); 1081 } 1082 1083 // Try index files for directory-like paths 1084 if (!fileRequestPath.includes('.')) { 1085 for (const indexFileName of indexFiles) { 1086 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName; 1087 const indexCacheKey = getCacheKey(did, rkey, indexPath); 1088 const indexFile = getCachedFilePath(did, rkey, indexPath); 1089 1090 // Check for rewritten index file in cache 1091 const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`); 1092 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 1093 if (rewrittenContent) { 1094 const headers: Record<string, string> = { 1095 'Content-Type': 'text/html; charset=utf-8', 1096 'Content-Encoding': 'gzip', 1097 'Cache-Control': 'public, max-age=300', 1098 }; 1099 applyCustomHeaders(headers, indexPath, settings); 1100 return new Response(rewrittenContent, { headers }); 1101 } 1102 1103 let indexContent = fileCache.get(indexCacheKey); 1104 let indexMeta = metadataCache.get(indexCacheKey); 1105 1106 if (!indexContent && await fileExists(indexFile)) { 1107 indexContent = await readFile(indexFile); 1108 fileCache.set(indexCacheKey, indexContent, indexContent.length); 1109 1110 const indexMetaFile = `${indexFile}.meta`; 1111 if (await fileExists(indexMetaFile)) { 1112 const metaJson = await readFile(indexMetaFile, 'utf-8'); 1113 indexMeta = JSON.parse(metaJson); 1114 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 1115 } 1116 } 1117 1118 if (indexContent) { 1119 const isGzipped = indexMeta?.encoding === 'gzip'; 1120 1121 let htmlContent: string; 1122 if (isGzipped) { 1123 // Verify content is actually gzipped 1124 const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b; 1125 if (hasGzipMagic) { 1126 const { gunzipSync } = await import('zlib'); 1127 htmlContent = gunzipSync(indexContent).toString('utf-8'); 1128 } else { 1129 console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`); 1130 htmlContent = indexContent.toString('utf-8'); 1131 } 1132 } else { 1133 htmlContent = indexContent.toString('utf-8'); 1134 } 1135 const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath); 1136 1137 const { gzipSync } = await import('zlib'); 1138 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 1139 1140 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 1141 1142 const headers: Record<string, string> = { 1143 'Content-Type': 'text/html; charset=utf-8', 1144 'Content-Encoding': 'gzip', 1145 'Cache-Control': 'public, max-age=300', 1146 }; 1147 applyCustomHeaders(headers, indexPath, settings); 1148 return new Response(recompressed, { headers }); 1149 } 1150 } 1151 } 1152 1153 // Try clean URLs: /about -> /about.html 1154 if (settings?.cleanUrls && !fileRequestPath.includes('.')) { 1155 const htmlPath = `${fileRequestPath}.html`; 1156 const htmlFile = getCachedFilePath(did, rkey, htmlPath); 1157 if (await fileExists(htmlFile)) { 1158 return serveFileInternalWithRewrite(did, rkey, htmlPath, basePath, settings); 1159 } 1160 1161 // Also try /about/index.html 1162 for (const indexFileName of indexFiles) { 1163 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName; 1164 const indexFile = getCachedFilePath(did, rkey, indexPath); 1165 if (await fileExists(indexFile)) { 1166 return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings); 1167 } 1168 } 1169 } 1170 1171 // SPA mode: serve SPA file for all non-existing routes 1172 if (settings?.spaMode) { 1173 const spaFile = settings.spaMode; 1174 const spaFilePath = getCachedFilePath(did, rkey, spaFile); 1175 if (await fileExists(spaFilePath)) { 1176 return serveFileInternalWithRewrite(did, rkey, spaFile, basePath, settings); 1177 } 1178 } 1179 1180 // Custom 404: serve custom 404 file if configured (wins conflict battle) 1181 if (settings?.custom404) { 1182 const custom404File = settings.custom404; 1183 const custom404Path = getCachedFilePath(did, rkey, custom404File); 1184 if (await fileExists(custom404Path)) { 1185 const response = await serveFileInternalWithRewrite(did, rkey, custom404File, basePath, settings); 1186 // Override status to 404 1187 return new Response(response.body, { 1188 status: 404, 1189 headers: response.headers, 1190 }); 1191 } 1192 } 1193 1194 // Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html) 1195 const auto404Pages = ['404.html', 'not_found.html']; 1196 for (const auto404Page of auto404Pages) { 1197 const auto404Path = getCachedFilePath(did, rkey, auto404Page); 1198 if (await fileExists(auto404Path)) { 1199 const response = await serveFileInternalWithRewrite(did, rkey, auto404Page, basePath, settings); 1200 // Override status to 404 1201 return new Response(response.body, { 1202 status: 404, 1203 headers: response.headers, 1204 }); 1205 } 1206 } 1207 1208 // Directory listing fallback: if enabled, show root directory listing on 404 1209 if (settings?.directoryListing) { 1210 const rootPath = getCachedFilePath(did, rkey, ''); 1211 if (await fileExists(rootPath)) { 1212 const { stat, readdir } = await import('fs/promises'); 1213 try { 1214 const stats = await stat(rootPath); 1215 if (stats.isDirectory()) { 1216 const entries = await readdir(rootPath); 1217 // Filter out .meta files and metadata 1218 const visibleEntries = entries.filter(entry => 1219 !entry.endsWith('.meta') && entry !== '.metadata.json' 1220 ); 1221 1222 // Check which entries are directories 1223 const entriesWithType = await Promise.all( 1224 visibleEntries.map(async (name) => { 1225 try { 1226 const entryPath = `${rootPath}/${name}`; 1227 const entryStats = await stat(entryPath); 1228 return { name, isDirectory: entryStats.isDirectory() }; 1229 } catch { 1230 return { name, isDirectory: false }; 1231 } 1232 }) 1233 ); 1234 1235 const html = generateDirectoryListing('', entriesWithType); 1236 return new Response(html, { 1237 status: 404, 1238 headers: { 1239 'Content-Type': 'text/html; charset=utf-8', 1240 'Cache-Control': 'public, max-age=300', 1241 }, 1242 }); 1243 } 1244 } catch (err) { 1245 // If directory listing fails, fall through to 404 1246 } 1247 } 1248 } 1249 1250 // Default styled 404 page 1251 const html = generate404Page(); 1252 return new Response(html, { 1253 status: 404, 1254 headers: { 1255 'Content-Type': 'text/html; charset=utf-8', 1256 'Cache-Control': 'public, max-age=300', 1257 }, 1258 }); 1259} 1260 1261// Helper to ensure site is cached 1262async function ensureSiteCached(did: string, rkey: string): Promise<boolean> { 1263 if (isCached(did, rkey)) { 1264 return true; 1265 } 1266 1267 // Fetch and cache the site 1268 const siteData = await fetchSiteRecord(did, rkey); 1269 if (!siteData) { 1270 logger.error('Site record not found', null, { did, rkey }); 1271 return false; 1272 } 1273 1274 const pdsEndpoint = await getPdsForDid(did); 1275 if (!pdsEndpoint) { 1276 logger.error('PDS not found for DID', null, { did }); 1277 return false; 1278 } 1279 1280 // Mark site as being cached to prevent serving stale content during update 1281 markSiteAsBeingCached(did, rkey); 1282 1283 try { 1284 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 1285 // Clear redirect rules cache since the site was updated 1286 clearRedirectRulesCache(did, rkey); 1287 logger.info('Site cached successfully', { did, rkey }); 1288 return true; 1289 } catch (err) { 1290 logger.error('Failed to cache site', err, { did, rkey }); 1291 return false; 1292 } finally { 1293 // Always unmark, even if caching fails 1294 unmarkSiteAsBeingCached(did, rkey); 1295 } 1296} 1297 1298const app = new Hono(); 1299 1300// Add CORS middleware - allow all origins for static site hosting 1301app.use('*', cors({ 1302 origin: '*', 1303 allowMethods: ['GET', 'HEAD', 'OPTIONS'], 1304 allowHeaders: ['Content-Type', 'Authorization'], 1305 exposeHeaders: ['Content-Length', 'Content-Type', 'Content-Encoding', 'Cache-Control'], 1306 maxAge: 86400, // 24 hours 1307 credentials: false, 1308})); 1309 1310// Add observability middleware 1311app.use('*', observabilityMiddleware('hosting-service')); 1312 1313// Error handler 1314app.onError(observabilityErrorHandler('hosting-service')); 1315 1316// Main site serving route 1317app.get('/*', async (c) => { 1318 const url = new URL(c.req.url); 1319 const hostname = c.req.header('host') || ''; 1320 const rawPath = url.pathname.replace(/^\//, ''); 1321 const path = sanitizePath(rawPath); 1322 1323 // Check if this is sites.wisp.place subdomain (strip port for comparison) 1324 const hostnameWithoutPort = hostname.split(':')[0]; 1325 if (hostnameWithoutPort === `sites.${BASE_HOST}`) { 1326 // Sanitize the path FIRST to prevent path traversal 1327 const sanitizedFullPath = sanitizePath(rawPath); 1328 1329 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 1330 const pathParts = sanitizedFullPath.split('/'); 1331 if (pathParts.length < 2) { 1332 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 1333 } 1334 1335 const identifier = pathParts[0]; 1336 const site = pathParts[1]; 1337 const filePath = pathParts.slice(2).join('/'); 1338 1339 // Additional validation: identifier must be a valid DID or handle format 1340 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 1341 return c.text('Invalid identifier', 400); 1342 } 1343 1344 // Validate site parameter exists 1345 if (!site) { 1346 return c.text('Site name required', 400); 1347 } 1348 1349 // Validate site name (rkey) 1350 if (!isValidRkey(site)) { 1351 return c.text('Invalid site name', 400); 1352 } 1353 1354 // Resolve identifier to DID 1355 const did = await resolveDid(identifier); 1356 if (!did) { 1357 return c.text('Invalid identifier', 400); 1358 } 1359 1360 // Check if site is currently being cached - return updating response early 1361 if (isSiteBeingCached(did, site)) { 1362 return siteUpdatingResponse(); 1363 } 1364 1365 // Ensure site is cached 1366 const cached = await ensureSiteCached(did, site); 1367 if (!cached) { 1368 return c.text('Site not found', 404); 1369 } 1370 1371 // Serve with HTML path rewriting to handle absolute paths 1372 const basePath = `/${identifier}/${site}/`; 1373 const headers: Record<string, string> = {}; 1374 c.req.raw.headers.forEach((value, key) => { 1375 headers[key.toLowerCase()] = value; 1376 }); 1377 return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers); 1378 } 1379 1380 // Check if this is a DNS hash subdomain 1381 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 1382 if (dnsMatch) { 1383 const hash = dnsMatch[1]; 1384 const baseDomain = dnsMatch[2]; 1385 1386 if (!hash) { 1387 return c.text('Invalid DNS hash', 400); 1388 } 1389 1390 if (baseDomain !== BASE_HOST) { 1391 return c.text('Invalid base domain', 400); 1392 } 1393 1394 const customDomain = await getCustomDomainByHash(hash); 1395 if (!customDomain) { 1396 return c.text('Custom domain not found or not verified', 404); 1397 } 1398 1399 if (!customDomain.rkey) { 1400 return c.text('Domain not mapped to a site', 404); 1401 } 1402 1403 const rkey = customDomain.rkey; 1404 if (!isValidRkey(rkey)) { 1405 return c.text('Invalid site configuration', 500); 1406 } 1407 1408 // Check if site is currently being cached - return updating response early 1409 if (isSiteBeingCached(customDomain.did, rkey)) { 1410 return siteUpdatingResponse(); 1411 } 1412 1413 const cached = await ensureSiteCached(customDomain.did, rkey); 1414 if (!cached) { 1415 return c.text('Site not found', 404); 1416 } 1417 1418 const headers: Record<string, string> = {}; 1419 c.req.raw.headers.forEach((value, key) => { 1420 headers[key.toLowerCase()] = value; 1421 }); 1422 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 1423 } 1424 1425 // Route 2: Registered subdomains - /*.wisp.place/* 1426 if (hostname.endsWith(`.${BASE_HOST}`)) { 1427 const domainInfo = await getWispDomain(hostname); 1428 if (!domainInfo) { 1429 return c.text('Subdomain not registered', 404); 1430 } 1431 1432 if (!domainInfo.rkey) { 1433 return c.text('Domain not mapped to a site', 404); 1434 } 1435 1436 const rkey = domainInfo.rkey; 1437 if (!isValidRkey(rkey)) { 1438 return c.text('Invalid site configuration', 500); 1439 } 1440 1441 // Check if site is currently being cached - return updating response early 1442 if (isSiteBeingCached(domainInfo.did, rkey)) { 1443 return siteUpdatingResponse(); 1444 } 1445 1446 const cached = await ensureSiteCached(domainInfo.did, rkey); 1447 if (!cached) { 1448 return c.text('Site not found', 404); 1449 } 1450 1451 const headers: Record<string, string> = {}; 1452 c.req.raw.headers.forEach((value, key) => { 1453 headers[key.toLowerCase()] = value; 1454 }); 1455 return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers); 1456 } 1457 1458 // Route 1: Custom domains - /* 1459 const customDomain = await getCustomDomain(hostname); 1460 if (!customDomain) { 1461 return c.text('Custom domain not found or not verified', 404); 1462 } 1463 1464 if (!customDomain.rkey) { 1465 return c.text('Domain not mapped to a site', 404); 1466 } 1467 1468 const rkey = customDomain.rkey; 1469 if (!isValidRkey(rkey)) { 1470 return c.text('Invalid site configuration', 500); 1471 } 1472 1473 // Check if site is currently being cached - return updating response early 1474 if (isSiteBeingCached(customDomain.did, rkey)) { 1475 return siteUpdatingResponse(); 1476 } 1477 1478 const cached = await ensureSiteCached(customDomain.did, rkey); 1479 if (!cached) { 1480 return c.text('Site not found', 404); 1481 } 1482 1483 const headers: Record<string, string> = {}; 1484 c.req.raw.headers.forEach((value, key) => { 1485 headers[key.toLowerCase()] = value; 1486 }); 1487 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 1488}); 1489 1490// Internal observability endpoints (for admin panel) 1491app.get('/__internal__/observability/logs', (c) => { 1492 const query = c.req.query(); 1493 const filter: any = {}; 1494 if (query.level) filter.level = query.level; 1495 if (query.service) filter.service = query.service; 1496 if (query.search) filter.search = query.search; 1497 if (query.eventType) filter.eventType = query.eventType; 1498 if (query.limit) filter.limit = parseInt(query.limit as string); 1499 return c.json({ logs: logCollector.getLogs(filter) }); 1500}); 1501 1502app.get('/__internal__/observability/errors', (c) => { 1503 const query = c.req.query(); 1504 const filter: any = {}; 1505 if (query.service) filter.service = query.service; 1506 if (query.limit) filter.limit = parseInt(query.limit as string); 1507 return c.json({ errors: errorTracker.getErrors(filter) }); 1508}); 1509 1510app.get('/__internal__/observability/metrics', (c) => { 1511 const query = c.req.query(); 1512 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 1513 const stats = metricsCollector.getStats('hosting-service', timeWindow); 1514 return c.json({ stats, timeWindow }); 1515}); 1516 1517app.get('/__internal__/observability/cache', async (c) => { 1518 const { getCacheStats } = await import('./lib/cache'); 1519 const stats = getCacheStats(); 1520 return c.json({ cache: stats }); 1521}); 1522 1523export default app;