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