forked from
nekomimi.pet/wisp.place-monorepo
Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
1import { Hono } from 'hono';
2import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
3import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils';
4import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
5import { existsSync, readFileSync } from 'fs';
6import { lookup } from 'mime-types';
7import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability';
8
9const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
10
11/**
12 * Validate site name (rkey) to prevent injection attacks
13 * Must match AT Protocol rkey format
14 */
15function isValidRkey(rkey: string): boolean {
16 if (!rkey || typeof rkey !== 'string') return false;
17 if (rkey.length < 1 || rkey.length > 512) return false;
18 if (rkey === '.' || rkey === '..') return false;
19 if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false;
20 const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/;
21 return validRkeyPattern.test(rkey);
22}
23
24// Helper to serve files from cache
25async function serveFromCache(did: string, rkey: string, filePath: string) {
26 // Default to index.html if path is empty or ends with /
27 let requestPath = filePath || 'index.html';
28 if (requestPath.endsWith('/')) {
29 requestPath += 'index.html';
30 }
31
32 const cachedFile = getCachedFilePath(did, rkey, requestPath);
33
34 if (existsSync(cachedFile)) {
35 const content = readFileSync(cachedFile);
36 const metaFile = `${cachedFile}.meta`;
37
38 console.log(`[DEBUG SERVE] ${requestPath}: file size=${content.length} bytes, metaFile exists=${existsSync(metaFile)}`);
39
40 // Check if file has compression metadata
41 if (existsSync(metaFile)) {
42 const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
43 console.log(`[DEBUG SERVE] ${requestPath}: meta=${JSON.stringify(meta)}`);
44
45 // Check actual content for gzip magic bytes
46 if (content.length >= 2) {
47 const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
48 console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic}`);
49 }
50
51 if (meta.encoding === 'gzip' && meta.mimeType) {
52 // Use shared function to determine if this should be served compressed
53 const shouldServeCompressed = shouldCompressMimeType(meta.mimeType);
54
55 if (!shouldServeCompressed) {
56 // This shouldn't happen if caching is working correctly, but handle it gracefully
57 console.log(`[DEBUG SERVE] ${requestPath}: decompressing file that shouldn't be compressed (${meta.mimeType})`);
58 const { gunzipSync } = await import('zlib');
59 const decompressed = gunzipSync(content);
60 console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
61 return new Response(decompressed, {
62 headers: {
63 'Content-Type': meta.mimeType,
64 },
65 });
66 }
67
68 // Serve gzipped content with proper headers (for HTML, CSS, JS, etc.)
69 console.log(`[DEBUG SERVE] ${requestPath}: serving as gzipped with Content-Encoding header`);
70 return new Response(content, {
71 headers: {
72 'Content-Type': meta.mimeType,
73 'Content-Encoding': 'gzip',
74 },
75 });
76 }
77 }
78
79 // Serve non-compressed files normally
80 const mimeType = lookup(cachedFile) || 'application/octet-stream';
81 return new Response(content, {
82 headers: {
83 'Content-Type': mimeType,
84 },
85 });
86 }
87
88 // Try index.html for directory-like paths
89 if (!requestPath.includes('.')) {
90 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
91 if (existsSync(indexFile)) {
92 const content = readFileSync(indexFile);
93 const metaFile = `${indexFile}.meta`;
94
95 // Check if file has compression metadata
96 if (existsSync(metaFile)) {
97 const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
98 if (meta.encoding === 'gzip' && meta.mimeType) {
99 return new Response(content, {
100 headers: {
101 'Content-Type': meta.mimeType,
102 'Content-Encoding': 'gzip',
103 },
104 });
105 }
106 }
107
108 return new Response(content, {
109 headers: {
110 'Content-Type': 'text/html; charset=utf-8',
111 },
112 });
113 }
114 }
115
116 return new Response('Not Found', { status: 404 });
117}
118
119// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes
120async function serveFromCacheWithRewrite(
121 did: string,
122 rkey: string,
123 filePath: string,
124 basePath: string
125) {
126 // Default to index.html if path is empty or ends with /
127 let requestPath = filePath || 'index.html';
128 if (requestPath.endsWith('/')) {
129 requestPath += 'index.html';
130 }
131
132 const cachedFile = getCachedFilePath(did, rkey, requestPath);
133
134 if (existsSync(cachedFile)) {
135 const metaFile = `${cachedFile}.meta`;
136 let mimeType = lookup(cachedFile) || 'application/octet-stream';
137 let isGzipped = false;
138
139 // Check if file has compression metadata
140 if (existsSync(metaFile)) {
141 const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
142 if (meta.encoding === 'gzip' && meta.mimeType) {
143 mimeType = meta.mimeType;
144 isGzipped = true;
145 }
146 }
147
148 // Check if this is HTML content that needs rewriting
149 // We decompress, rewrite paths, then recompress for efficient delivery
150 if (isHtmlContent(requestPath, mimeType)) {
151 let content: string;
152 if (isGzipped) {
153 const { gunzipSync } = await import('zlib');
154 const compressed = readFileSync(cachedFile);
155 content = gunzipSync(compressed).toString('utf-8');
156 } else {
157 content = readFileSync(cachedFile, 'utf-8');
158 }
159 const rewritten = rewriteHtmlPaths(content, basePath);
160
161 // Recompress the HTML for efficient delivery
162 const { gzipSync } = await import('zlib');
163 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
164
165 return new Response(recompressed, {
166 headers: {
167 'Content-Type': 'text/html; charset=utf-8',
168 'Content-Encoding': 'gzip',
169 },
170 });
171 }
172
173 // Non-HTML files: serve gzipped content as-is with proper headers
174 const content = readFileSync(cachedFile);
175 if (isGzipped) {
176 // Use shared function to determine if this should be served compressed
177 const shouldServeCompressed = shouldCompressMimeType(mimeType);
178
179 if (!shouldServeCompressed) {
180 // This shouldn't happen if caching is working correctly, but handle it gracefully
181 const { gunzipSync } = await import('zlib');
182 const decompressed = gunzipSync(content);
183 return new Response(decompressed, {
184 headers: {
185 'Content-Type': mimeType,
186 },
187 });
188 }
189
190 return new Response(content, {
191 headers: {
192 'Content-Type': mimeType,
193 'Content-Encoding': 'gzip',
194 },
195 });
196 }
197 return new Response(content, {
198 headers: {
199 'Content-Type': mimeType,
200 },
201 });
202 }
203
204 // Try index.html for directory-like paths
205 if (!requestPath.includes('.')) {
206 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
207 if (existsSync(indexFile)) {
208 const metaFile = `${indexFile}.meta`;
209 let isGzipped = false;
210
211 if (existsSync(metaFile)) {
212 const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
213 if (meta.encoding === 'gzip') {
214 isGzipped = true;
215 }
216 }
217
218 // HTML needs path rewriting, decompress, rewrite, then recompress
219 let content: string;
220 if (isGzipped) {
221 const { gunzipSync } = await import('zlib');
222 const compressed = readFileSync(indexFile);
223 content = gunzipSync(compressed).toString('utf-8');
224 } else {
225 content = readFileSync(indexFile, 'utf-8');
226 }
227 const rewritten = rewriteHtmlPaths(content, basePath);
228
229 // Recompress the HTML for efficient delivery
230 const { gzipSync } = await import('zlib');
231 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
232
233 return new Response(recompressed, {
234 headers: {
235 'Content-Type': 'text/html; charset=utf-8',
236 'Content-Encoding': 'gzip',
237 },
238 });
239 }
240 }
241
242 return new Response('Not Found', { status: 404 });
243}
244
245// Helper to ensure site is cached
246async function ensureSiteCached(did: string, rkey: string): Promise<boolean> {
247 if (isCached(did, rkey)) {
248 return true;
249 }
250
251 // Fetch and cache the site
252 const siteData = await fetchSiteRecord(did, rkey);
253 if (!siteData) {
254 logger.error('Site record not found', null, { did, rkey });
255 return false;
256 }
257
258 const pdsEndpoint = await getPdsForDid(did);
259 if (!pdsEndpoint) {
260 logger.error('PDS not found for DID', null, { did });
261 return false;
262 }
263
264 try {
265 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
266 logger.info('Site cached successfully', { did, rkey });
267 return true;
268 } catch (err) {
269 logger.error('Failed to cache site', err, { did, rkey });
270 return false;
271 }
272}
273
274const app = new Hono();
275
276// Add observability middleware
277app.use('*', observabilityMiddleware('hosting-service'));
278
279// Error handler
280app.onError(observabilityErrorHandler('hosting-service'));
281
282// Main site serving route
283app.get('/*', async (c) => {
284 const url = new URL(c.req.url);
285 const hostname = c.req.header('host') || '';
286 const rawPath = url.pathname.replace(/^\//, '');
287 const path = sanitizePath(rawPath);
288
289 // Check if this is sites.wisp.place subdomain
290 if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
291 // Sanitize the path FIRST to prevent path traversal
292 const sanitizedFullPath = sanitizePath(rawPath);
293
294 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
295 const pathParts = sanitizedFullPath.split('/');
296 if (pathParts.length < 2) {
297 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
298 }
299
300 const identifier = pathParts[0];
301 const site = pathParts[1];
302 const filePath = pathParts.slice(2).join('/');
303
304 // Additional validation: identifier must be a valid DID or handle format
305 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
306 return c.text('Invalid identifier', 400);
307 }
308
309 // Validate site parameter exists
310 if (!site) {
311 return c.text('Site name required', 400);
312 }
313
314 // Validate site name (rkey)
315 if (!isValidRkey(site)) {
316 return c.text('Invalid site name', 400);
317 }
318
319 // Resolve identifier to DID
320 const did = await resolveDid(identifier);
321 if (!did) {
322 return c.text('Invalid identifier', 400);
323 }
324
325 // Ensure site is cached
326 const cached = await ensureSiteCached(did, site);
327 if (!cached) {
328 return c.text('Site not found', 404);
329 }
330
331 // Serve with HTML path rewriting to handle absolute paths
332 const basePath = `/${identifier}/${site}/`;
333 return serveFromCacheWithRewrite(did, site, filePath, basePath);
334 }
335
336 // Check if this is a DNS hash subdomain
337 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
338 if (dnsMatch) {
339 const hash = dnsMatch[1];
340 const baseDomain = dnsMatch[2];
341
342 if (!hash) {
343 return c.text('Invalid DNS hash', 400);
344 }
345
346 if (baseDomain !== BASE_HOST) {
347 return c.text('Invalid base domain', 400);
348 }
349
350 const customDomain = await getCustomDomainByHash(hash);
351 if (!customDomain) {
352 return c.text('Custom domain not found or not verified', 404);
353 }
354
355 if (!customDomain.rkey) {
356 return c.text('Domain not mapped to a site', 404);
357 }
358
359 const rkey = customDomain.rkey;
360 if (!isValidRkey(rkey)) {
361 return c.text('Invalid site configuration', 500);
362 }
363
364 const cached = await ensureSiteCached(customDomain.did, rkey);
365 if (!cached) {
366 return c.text('Site not found', 404);
367 }
368
369 return serveFromCache(customDomain.did, rkey, path);
370 }
371
372 // Route 2: Registered subdomains - /*.wisp.place/*
373 if (hostname.endsWith(`.${BASE_HOST}`)) {
374 const domainInfo = await getWispDomain(hostname);
375 if (!domainInfo) {
376 return c.text('Subdomain not registered', 404);
377 }
378
379 if (!domainInfo.rkey) {
380 return c.text('Domain not mapped to a site', 404);
381 }
382
383 const rkey = domainInfo.rkey;
384 if (!isValidRkey(rkey)) {
385 return c.text('Invalid site configuration', 500);
386 }
387
388 const cached = await ensureSiteCached(domainInfo.did, rkey);
389 if (!cached) {
390 return c.text('Site not found', 404);
391 }
392
393 return serveFromCache(domainInfo.did, rkey, path);
394 }
395
396 // Route 1: Custom domains - /*
397 const customDomain = await getCustomDomain(hostname);
398 if (!customDomain) {
399 return c.text('Custom domain not found or not verified', 404);
400 }
401
402 if (!customDomain.rkey) {
403 return c.text('Domain not mapped to a site', 404);
404 }
405
406 const rkey = customDomain.rkey;
407 if (!isValidRkey(rkey)) {
408 return c.text('Invalid site configuration', 500);
409 }
410
411 const cached = await ensureSiteCached(customDomain.did, rkey);
412 if (!cached) {
413 return c.text('Site not found', 404);
414 }
415
416 return serveFromCache(customDomain.did, rkey, path);
417});
418
419// Internal observability endpoints (for admin panel)
420app.get('/__internal__/observability/logs', (c) => {
421 const query = c.req.query();
422 const filter: any = {};
423 if (query.level) filter.level = query.level;
424 if (query.service) filter.service = query.service;
425 if (query.search) filter.search = query.search;
426 if (query.eventType) filter.eventType = query.eventType;
427 if (query.limit) filter.limit = parseInt(query.limit as string);
428 return c.json({ logs: logCollector.getLogs(filter) });
429});
430
431app.get('/__internal__/observability/errors', (c) => {
432 const query = c.req.query();
433 const filter: any = {};
434 if (query.service) filter.service = query.service;
435 if (query.limit) filter.limit = parseInt(query.limit as string);
436 return c.json({ errors: errorTracker.getErrors(filter) });
437});
438
439app.get('/__internal__/observability/metrics', (c) => {
440 const query = c.req.query();
441 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
442 const stats = metricsCollector.getStats('hosting-service', timeWindow);
443 return c.json({ stats, timeWindow });
444});
445
446export default app;