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