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;