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 // Default styled 404 page
769 const html = generate404Page();
770 return new Response(html, {
771 status: 404,
772 headers: {
773 'Content-Type': 'text/html; charset=utf-8',
774 'Cache-Control': 'public, max-age=300',
775 },
776 });
777}
778
779// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes
780async function serveFromCacheWithRewrite(
781 did: string,
782 rkey: string,
783 filePath: string,
784 basePath: string,
785 fullUrl?: string,
786 headers?: Record<string, string>
787) {
788 // Load settings for this site
789 const settings = await getCachedSettings(did, rkey);
790 const indexFiles = getIndexFiles(settings);
791
792 // Check for redirect rules first (_redirects wins over settings)
793 const redirectCacheKey = `${did}:${rkey}`;
794 let redirectRules = redirectRulesCache.get(redirectCacheKey);
795
796 if (redirectRules === undefined) {
797 // Load rules for the first time
798 redirectRules = await loadRedirectRules(did, rkey);
799 redirectRulesCache.set(redirectCacheKey, redirectRules);
800 }
801
802 // Apply redirect rules if any exist
803 if (redirectRules.length > 0) {
804 const requestPath = '/' + (filePath || '');
805 const queryParams = fullUrl ? parseQueryString(fullUrl) : {};
806 const cookies = parseCookies(headers?.['cookie']);
807
808 const redirectMatch = matchRedirectRule(requestPath, redirectRules, {
809 queryParams,
810 headers,
811 cookies,
812 });
813
814 if (redirectMatch) {
815 const { rule, targetPath, status } = redirectMatch;
816
817 // If not forced, check if the requested file exists before redirecting
818 if (!rule.force) {
819 // Build the expected file path
820 let checkPath = filePath || indexFiles[0];
821 if (checkPath.endsWith('/')) {
822 checkPath += indexFiles[0];
823 }
824
825 const cachedFile = getCachedFilePath(did, rkey, checkPath);
826 const fileExistsOnDisk = await fileExists(cachedFile);
827
828 // If file exists and redirect is not forced, serve the file normally
829 if (fileExistsOnDisk) {
830 return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
831 }
832 }
833
834 // Handle different status codes
835 if (status === 200) {
836 // Rewrite: serve different content but keep URL the same
837 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
838 return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath, settings);
839 } else if (status === 301 || status === 302) {
840 // External redirect: change the URL
841 // For sites.wisp.place, we need to adjust the target path to include the base path
842 // unless it's an absolute URL
843 let redirectTarget = targetPath;
844 if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) {
845 redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath);
846 }
847 return new Response(null, {
848 status,
849 headers: {
850 'Location': redirectTarget,
851 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0',
852 },
853 });
854 } else if (status === 404) {
855 // Custom 404 page from _redirects (wins over settings.custom404)
856 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
857 const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath, settings);
858 // Override status to 404
859 return new Response(response.body, {
860 status: 404,
861 headers: response.headers,
862 });
863 }
864 }
865 }
866
867 // No redirect matched, serve normally with settings
868 return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
869}
870
871// Internal function to serve a file with rewriting
872async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string, settings: WispSettings | null = null) {
873 // Check if site is currently being cached - if so, return updating response
874 if (isSiteBeingCached(did, rkey)) {
875 return siteUpdatingResponse();
876 }
877
878 const indexFiles = getIndexFiles(settings);
879
880 // Normalize the request path (keep empty for root, remove trailing slash for others)
881 let requestPath = filePath || '';
882 if (requestPath.endsWith('/') && requestPath.length > 1) {
883 requestPath = requestPath.slice(0, -1);
884 }
885
886 // Check if this path is a directory first
887 const directoryPath = getCachedFilePath(did, rkey, requestPath);
888 if (await fileExists(directoryPath)) {
889 const { stat, readdir } = await import('fs/promises');
890 try {
891 const stats = await stat(directoryPath);
892 if (stats.isDirectory()) {
893 // It's a directory, try each index file in order
894 for (const indexFile of indexFiles) {
895 const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
896 const indexFilePath = getCachedFilePath(did, rkey, indexPath);
897 if (await fileExists(indexFilePath)) {
898 return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
899 }
900 }
901 // No index file found - check if directory listing is enabled
902 if (settings?.directoryListing) {
903 const { stat } = await import('fs/promises');
904 const entries = await readdir(directoryPath);
905 // Filter out .meta files and other hidden files
906 const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
907
908 // Check which entries are directories
909 const entriesWithType = await Promise.all(
910 visibleEntries.map(async (name) => {
911 try {
912 const entryPath = `${directoryPath}/${name}`;
913 const stats = await stat(entryPath);
914 return { name, isDirectory: stats.isDirectory() };
915 } catch {
916 return { name, isDirectory: false };
917 }
918 })
919 );
920
921 const html = generateDirectoryListing(requestPath, entriesWithType);
922 return new Response(html, {
923 headers: {
924 'Content-Type': 'text/html; charset=utf-8',
925 'Cache-Control': 'public, max-age=300',
926 },
927 });
928 }
929 // Fall through to 404/SPA handling
930 }
931 } catch (err) {
932 // If stat fails, continue with normal flow
933 }
934 }
935
936 // Not a directory, try to serve as a file
937 const fileRequestPath = requestPath || indexFiles[0];
938 const cacheKey = getCacheKey(did, rkey, fileRequestPath);
939 const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
940
941 // Check for rewritten HTML in cache first (if it's HTML)
942 const mimeTypeGuess = lookup(fileRequestPath) || 'application/octet-stream';
943 if (isHtmlContent(fileRequestPath, mimeTypeGuess)) {
944 const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
945 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
946 if (rewrittenContent) {
947 const headers: Record<string, string> = {
948 'Content-Type': 'text/html; charset=utf-8',
949 'Content-Encoding': 'gzip',
950 'Cache-Control': 'public, max-age=300',
951 };
952 applyCustomHeaders(headers, fileRequestPath, settings);
953 return new Response(rewrittenContent, { headers });
954 }
955 }
956
957 // Check in-memory file cache
958 let content = fileCache.get(cacheKey);
959 let meta = metadataCache.get(cacheKey);
960
961 if (!content && await fileExists(cachedFile)) {
962 // Read from disk and cache
963 content = await readFile(cachedFile);
964 fileCache.set(cacheKey, content, content.length);
965
966 const metaFile = `${cachedFile}.meta`;
967 if (await fileExists(metaFile)) {
968 const metaJson = await readFile(metaFile, 'utf-8');
969 meta = JSON.parse(metaJson);
970 metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length);
971 }
972 }
973
974 if (content) {
975 const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream';
976 const isGzipped = meta?.encoding === 'gzip';
977
978 // Check if this is HTML content that needs rewriting
979 if (isHtmlContent(fileRequestPath, mimeType)) {
980 let htmlContent: string;
981 if (isGzipped) {
982 // Verify content is actually gzipped
983 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
984 if (hasGzipMagic) {
985 const { gunzipSync } = await import('zlib');
986 htmlContent = gunzipSync(content).toString('utf-8');
987 } else {
988 console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
989 htmlContent = content.toString('utf-8');
990 }
991 } else {
992 htmlContent = content.toString('utf-8');
993 }
994 const rewritten = rewriteHtmlPaths(htmlContent, basePath, fileRequestPath);
995
996 // Recompress and cache the rewritten HTML
997 const { gzipSync } = await import('zlib');
998 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
999
1000 const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
1001 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
1002
1003 const htmlHeaders: Record<string, string> = {
1004 'Content-Type': 'text/html; charset=utf-8',
1005 'Content-Encoding': 'gzip',
1006 'Cache-Control': 'public, max-age=300',
1007 };
1008 applyCustomHeaders(htmlHeaders, fileRequestPath, settings);
1009 return new Response(recompressed, { headers: htmlHeaders });
1010 }
1011
1012 // Non-HTML files: serve as-is
1013 const headers: Record<string, string> = {
1014 'Content-Type': mimeType,
1015 'Cache-Control': 'public, max-age=31536000, immutable',
1016 };
1017
1018 if (isGzipped) {
1019 const shouldServeCompressed = shouldCompressMimeType(mimeType);
1020 if (!shouldServeCompressed) {
1021 // Verify content is actually gzipped
1022 const hasGzipMagic = content.length >= 2 && content[0] === 0x1f && content[1] === 0x8b;
1023 if (hasGzipMagic) {
1024 const { gunzipSync } = await import('zlib');
1025 const decompressed = gunzipSync(content);
1026 applyCustomHeaders(headers, fileRequestPath, settings);
1027 return new Response(decompressed, { headers });
1028 } else {
1029 console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
1030 applyCustomHeaders(headers, fileRequestPath, settings);
1031 return new Response(content, { headers });
1032 }
1033 }
1034 headers['Content-Encoding'] = 'gzip';
1035 }
1036
1037 applyCustomHeaders(headers, fileRequestPath, settings);
1038 return new Response(content, { headers });
1039 }
1040
1041 // Try index files for directory-like paths
1042 if (!fileRequestPath.includes('.')) {
1043 for (const indexFileName of indexFiles) {
1044 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
1045 const indexCacheKey = getCacheKey(did, rkey, indexPath);
1046 const indexFile = getCachedFilePath(did, rkey, indexPath);
1047
1048 // Check for rewritten index file in cache
1049 const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`);
1050 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
1051 if (rewrittenContent) {
1052 const headers: Record<string, string> = {
1053 'Content-Type': 'text/html; charset=utf-8',
1054 'Content-Encoding': 'gzip',
1055 'Cache-Control': 'public, max-age=300',
1056 };
1057 applyCustomHeaders(headers, indexPath, settings);
1058 return new Response(rewrittenContent, { headers });
1059 }
1060
1061 let indexContent = fileCache.get(indexCacheKey);
1062 let indexMeta = metadataCache.get(indexCacheKey);
1063
1064 if (!indexContent && await fileExists(indexFile)) {
1065 indexContent = await readFile(indexFile);
1066 fileCache.set(indexCacheKey, indexContent, indexContent.length);
1067
1068 const indexMetaFile = `${indexFile}.meta`;
1069 if (await fileExists(indexMetaFile)) {
1070 const metaJson = await readFile(indexMetaFile, 'utf-8');
1071 indexMeta = JSON.parse(metaJson);
1072 metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
1073 }
1074 }
1075
1076 if (indexContent) {
1077 const isGzipped = indexMeta?.encoding === 'gzip';
1078
1079 let htmlContent: string;
1080 if (isGzipped) {
1081 // Verify content is actually gzipped
1082 const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b;
1083 if (hasGzipMagic) {
1084 const { gunzipSync } = await import('zlib');
1085 htmlContent = gunzipSync(indexContent).toString('utf-8');
1086 } else {
1087 console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`);
1088 htmlContent = indexContent.toString('utf-8');
1089 }
1090 } else {
1091 htmlContent = indexContent.toString('utf-8');
1092 }
1093 const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath);
1094
1095 const { gzipSync } = await import('zlib');
1096 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
1097
1098 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
1099
1100 const headers: Record<string, string> = {
1101 'Content-Type': 'text/html; charset=utf-8',
1102 'Content-Encoding': 'gzip',
1103 'Cache-Control': 'public, max-age=300',
1104 };
1105 applyCustomHeaders(headers, indexPath, settings);
1106 return new Response(recompressed, { headers });
1107 }
1108 }
1109 }
1110
1111 // Try clean URLs: /about -> /about.html
1112 if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
1113 const htmlPath = `${fileRequestPath}.html`;
1114 const htmlFile = getCachedFilePath(did, rkey, htmlPath);
1115 if (await fileExists(htmlFile)) {
1116 return serveFileInternalWithRewrite(did, rkey, htmlPath, basePath, settings);
1117 }
1118
1119 // Also try /about/index.html
1120 for (const indexFileName of indexFiles) {
1121 const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
1122 const indexFile = getCachedFilePath(did, rkey, indexPath);
1123 if (await fileExists(indexFile)) {
1124 return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
1125 }
1126 }
1127 }
1128
1129 // SPA mode: serve SPA file for all non-existing routes
1130 if (settings?.spaMode) {
1131 const spaFile = settings.spaMode;
1132 const spaFilePath = getCachedFilePath(did, rkey, spaFile);
1133 if (await fileExists(spaFilePath)) {
1134 return serveFileInternalWithRewrite(did, rkey, spaFile, basePath, settings);
1135 }
1136 }
1137
1138 // Custom 404: serve custom 404 file if configured (wins conflict battle)
1139 if (settings?.custom404) {
1140 const custom404File = settings.custom404;
1141 const custom404Path = getCachedFilePath(did, rkey, custom404File);
1142 if (await fileExists(custom404Path)) {
1143 const response = await serveFileInternalWithRewrite(did, rkey, custom404File, basePath, settings);
1144 // Override status to 404
1145 return new Response(response.body, {
1146 status: 404,
1147 headers: response.headers,
1148 });
1149 }
1150 }
1151
1152 // Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
1153 const auto404Pages = ['404.html', 'not_found.html'];
1154 for (const auto404Page of auto404Pages) {
1155 const auto404Path = getCachedFilePath(did, rkey, auto404Page);
1156 if (await fileExists(auto404Path)) {
1157 const response = await serveFileInternalWithRewrite(did, rkey, auto404Page, basePath, settings);
1158 // Override status to 404
1159 return new Response(response.body, {
1160 status: 404,
1161 headers: response.headers,
1162 });
1163 }
1164 }
1165
1166 // Default styled 404 page
1167 const html = generate404Page();
1168 return new Response(html, {
1169 status: 404,
1170 headers: {
1171 'Content-Type': 'text/html; charset=utf-8',
1172 'Cache-Control': 'public, max-age=300',
1173 },
1174 });
1175}
1176
1177// Helper to ensure site is cached
1178async function ensureSiteCached(did: string, rkey: string): Promise<boolean> {
1179 if (isCached(did, rkey)) {
1180 return true;
1181 }
1182
1183 // Fetch and cache the site
1184 const siteData = await fetchSiteRecord(did, rkey);
1185 if (!siteData) {
1186 logger.error('Site record not found', null, { did, rkey });
1187 return false;
1188 }
1189
1190 const pdsEndpoint = await getPdsForDid(did);
1191 if (!pdsEndpoint) {
1192 logger.error('PDS not found for DID', null, { did });
1193 return false;
1194 }
1195
1196 // Mark site as being cached to prevent serving stale content during update
1197 markSiteAsBeingCached(did, rkey);
1198
1199 try {
1200 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
1201 // Clear redirect rules cache since the site was updated
1202 clearRedirectRulesCache(did, rkey);
1203 logger.info('Site cached successfully', { did, rkey });
1204 return true;
1205 } catch (err) {
1206 logger.error('Failed to cache site', err, { did, rkey });
1207 return false;
1208 } finally {
1209 // Always unmark, even if caching fails
1210 unmarkSiteAsBeingCached(did, rkey);
1211 }
1212}
1213
1214const app = new Hono();
1215
1216// Add CORS middleware - allow all origins for static site hosting
1217app.use('*', cors({
1218 origin: '*',
1219 allowMethods: ['GET', 'HEAD', 'OPTIONS'],
1220 allowHeaders: ['Content-Type', 'Authorization'],
1221 exposeHeaders: ['Content-Length', 'Content-Type', 'Content-Encoding', 'Cache-Control'],
1222 maxAge: 86400, // 24 hours
1223 credentials: false,
1224}));
1225
1226// Add observability middleware
1227app.use('*', observabilityMiddleware('hosting-service'));
1228
1229// Error handler
1230app.onError(observabilityErrorHandler('hosting-service'));
1231
1232// Main site serving route
1233app.get('/*', async (c) => {
1234 const url = new URL(c.req.url);
1235 const hostname = c.req.header('host') || '';
1236 const rawPath = url.pathname.replace(/^\//, '');
1237 const path = sanitizePath(rawPath);
1238
1239 // Check if this is sites.wisp.place subdomain (strip port for comparison)
1240 const hostnameWithoutPort = hostname.split(':')[0];
1241 if (hostnameWithoutPort === `sites.${BASE_HOST}`) {
1242 // Sanitize the path FIRST to prevent path traversal
1243 const sanitizedFullPath = sanitizePath(rawPath);
1244
1245 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
1246 const pathParts = sanitizedFullPath.split('/');
1247 if (pathParts.length < 2) {
1248 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
1249 }
1250
1251 const identifier = pathParts[0];
1252 const site = pathParts[1];
1253 const filePath = pathParts.slice(2).join('/');
1254
1255 // Additional validation: identifier must be a valid DID or handle format
1256 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
1257 return c.text('Invalid identifier', 400);
1258 }
1259
1260 // Validate site parameter exists
1261 if (!site) {
1262 return c.text('Site name required', 400);
1263 }
1264
1265 // Validate site name (rkey)
1266 if (!isValidRkey(site)) {
1267 return c.text('Invalid site name', 400);
1268 }
1269
1270 // Resolve identifier to DID
1271 const did = await resolveDid(identifier);
1272 if (!did) {
1273 return c.text('Invalid identifier', 400);
1274 }
1275
1276 // Check if site is currently being cached - return updating response early
1277 if (isSiteBeingCached(did, site)) {
1278 return siteUpdatingResponse();
1279 }
1280
1281 // Ensure site is cached
1282 const cached = await ensureSiteCached(did, site);
1283 if (!cached) {
1284 return c.text('Site not found', 404);
1285 }
1286
1287 // Serve with HTML path rewriting to handle absolute paths
1288 const basePath = `/${identifier}/${site}/`;
1289 const headers: Record<string, string> = {};
1290 c.req.raw.headers.forEach((value, key) => {
1291 headers[key.toLowerCase()] = value;
1292 });
1293 return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers);
1294 }
1295
1296 // Check if this is a DNS hash subdomain
1297 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
1298 if (dnsMatch) {
1299 const hash = dnsMatch[1];
1300 const baseDomain = dnsMatch[2];
1301
1302 if (!hash) {
1303 return c.text('Invalid DNS hash', 400);
1304 }
1305
1306 if (baseDomain !== BASE_HOST) {
1307 return c.text('Invalid base domain', 400);
1308 }
1309
1310 const customDomain = await getCustomDomainByHash(hash);
1311 if (!customDomain) {
1312 return c.text('Custom domain not found or not verified', 404);
1313 }
1314
1315 if (!customDomain.rkey) {
1316 return c.text('Domain not mapped to a site', 404);
1317 }
1318
1319 const rkey = customDomain.rkey;
1320 if (!isValidRkey(rkey)) {
1321 return c.text('Invalid site configuration', 500);
1322 }
1323
1324 // Check if site is currently being cached - return updating response early
1325 if (isSiteBeingCached(customDomain.did, rkey)) {
1326 return siteUpdatingResponse();
1327 }
1328
1329 const cached = await ensureSiteCached(customDomain.did, rkey);
1330 if (!cached) {
1331 return c.text('Site not found', 404);
1332 }
1333
1334 const headers: Record<string, string> = {};
1335 c.req.raw.headers.forEach((value, key) => {
1336 headers[key.toLowerCase()] = value;
1337 });
1338 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
1339 }
1340
1341 // Route 2: Registered subdomains - /*.wisp.place/*
1342 if (hostname.endsWith(`.${BASE_HOST}`)) {
1343 const domainInfo = await getWispDomain(hostname);
1344 if (!domainInfo) {
1345 return c.text('Subdomain not registered', 404);
1346 }
1347
1348 if (!domainInfo.rkey) {
1349 return c.text('Domain not mapped to a site', 404);
1350 }
1351
1352 const rkey = domainInfo.rkey;
1353 if (!isValidRkey(rkey)) {
1354 return c.text('Invalid site configuration', 500);
1355 }
1356
1357 // Check if site is currently being cached - return updating response early
1358 if (isSiteBeingCached(domainInfo.did, rkey)) {
1359 return siteUpdatingResponse();
1360 }
1361
1362 const cached = await ensureSiteCached(domainInfo.did, rkey);
1363 if (!cached) {
1364 return c.text('Site not found', 404);
1365 }
1366
1367 const headers: Record<string, string> = {};
1368 c.req.raw.headers.forEach((value, key) => {
1369 headers[key.toLowerCase()] = value;
1370 });
1371 return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers);
1372 }
1373
1374 // Route 1: Custom domains - /*
1375 const customDomain = await getCustomDomain(hostname);
1376 if (!customDomain) {
1377 return c.text('Custom domain not found or not verified', 404);
1378 }
1379
1380 if (!customDomain.rkey) {
1381 return c.text('Domain not mapped to a site', 404);
1382 }
1383
1384 const rkey = customDomain.rkey;
1385 if (!isValidRkey(rkey)) {
1386 return c.text('Invalid site configuration', 500);
1387 }
1388
1389 // Check if site is currently being cached - return updating response early
1390 if (isSiteBeingCached(customDomain.did, rkey)) {
1391 return siteUpdatingResponse();
1392 }
1393
1394 const cached = await ensureSiteCached(customDomain.did, rkey);
1395 if (!cached) {
1396 return c.text('Site not found', 404);
1397 }
1398
1399 const headers: Record<string, string> = {};
1400 c.req.raw.headers.forEach((value, key) => {
1401 headers[key.toLowerCase()] = value;
1402 });
1403 return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
1404});
1405
1406// Internal observability endpoints (for admin panel)
1407app.get('/__internal__/observability/logs', (c) => {
1408 const query = c.req.query();
1409 const filter: any = {};
1410 if (query.level) filter.level = query.level;
1411 if (query.service) filter.service = query.service;
1412 if (query.search) filter.search = query.search;
1413 if (query.eventType) filter.eventType = query.eventType;
1414 if (query.limit) filter.limit = parseInt(query.limit as string);
1415 return c.json({ logs: logCollector.getLogs(filter) });
1416});
1417
1418app.get('/__internal__/observability/errors', (c) => {
1419 const query = c.req.query();
1420 const filter: any = {};
1421 if (query.service) filter.service = query.service;
1422 if (query.limit) filter.limit = parseInt(query.limit as string);
1423 return c.json({ errors: errorTracker.getErrors(filter) });
1424});
1425
1426app.get('/__internal__/observability/metrics', (c) => {
1427 const query = c.req.query();
1428 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
1429 const stats = metricsCollector.getStats('hosting-service', timeWindow);
1430 return c.json({ stats, timeWindow });
1431});
1432
1433app.get('/__internal__/observability/cache', async (c) => {
1434 const { getCacheStats } = await import('./lib/cache');
1435 const stats = getCacheStats();
1436 return c.json({ cache: stats });
1437});
1438
1439export default app;