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