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, 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, requestPath); 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 indexPath = `${requestPath}/index.html`; 228 const rewritten = rewriteHtmlPaths(content, basePath, indexPath); 229 230 // Recompress the HTML for efficient delivery 231 const { gzipSync } = await import('zlib'); 232 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 233 234 return new Response(recompressed, { 235 headers: { 236 'Content-Type': 'text/html; charset=utf-8', 237 'Content-Encoding': 'gzip', 238 }, 239 }); 240 } 241 } 242 243 return new Response('Not Found', { status: 404 }); 244} 245 246// Helper to ensure site is cached 247async function ensureSiteCached(did: string, rkey: string): Promise<boolean> { 248 if (isCached(did, rkey)) { 249 return true; 250 } 251 252 // Fetch and cache the site 253 const siteData = await fetchSiteRecord(did, rkey); 254 if (!siteData) { 255 logger.error('Site record not found', null, { did, rkey }); 256 return false; 257 } 258 259 const pdsEndpoint = await getPdsForDid(did); 260 if (!pdsEndpoint) { 261 logger.error('PDS not found for DID', null, { did }); 262 return false; 263 } 264 265 try { 266 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 267 logger.info('Site cached successfully', { did, rkey }); 268 return true; 269 } catch (err) { 270 logger.error('Failed to cache site', err, { did, rkey }); 271 return false; 272 } 273} 274 275const app = new Hono(); 276 277// Add observability middleware 278app.use('*', observabilityMiddleware('hosting-service')); 279 280// Error handler 281app.onError(observabilityErrorHandler('hosting-service')); 282 283// Main site serving route 284app.get('/*', async (c) => { 285 const url = new URL(c.req.url); 286 const hostname = c.req.header('host') || ''; 287 const rawPath = url.pathname.replace(/^\//, ''); 288 const path = sanitizePath(rawPath); 289 290 // Check if this is sites.wisp.place subdomain 291 if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 292 // Sanitize the path FIRST to prevent path traversal 293 const sanitizedFullPath = sanitizePath(rawPath); 294 295 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 296 const pathParts = sanitizedFullPath.split('/'); 297 if (pathParts.length < 2) { 298 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 299 } 300 301 const identifier = pathParts[0]; 302 const site = pathParts[1]; 303 const filePath = pathParts.slice(2).join('/'); 304 305 // Additional validation: identifier must be a valid DID or handle format 306 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 307 return c.text('Invalid identifier', 400); 308 } 309 310 // Validate site parameter exists 311 if (!site) { 312 return c.text('Site name required', 400); 313 } 314 315 // Validate site name (rkey) 316 if (!isValidRkey(site)) { 317 return c.text('Invalid site name', 400); 318 } 319 320 // Resolve identifier to DID 321 const did = await resolveDid(identifier); 322 if (!did) { 323 return c.text('Invalid identifier', 400); 324 } 325 326 // Ensure site is cached 327 const cached = await ensureSiteCached(did, site); 328 if (!cached) { 329 return c.text('Site not found', 404); 330 } 331 332 // Serve with HTML path rewriting to handle absolute paths 333 const basePath = `/${identifier}/${site}/`; 334 return serveFromCacheWithRewrite(did, site, filePath, basePath); 335 } 336 337 // Check if this is a DNS hash subdomain 338 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 339 if (dnsMatch) { 340 const hash = dnsMatch[1]; 341 const baseDomain = dnsMatch[2]; 342 343 if (!hash) { 344 return c.text('Invalid DNS hash', 400); 345 } 346 347 if (baseDomain !== BASE_HOST) { 348 return c.text('Invalid base domain', 400); 349 } 350 351 const customDomain = await getCustomDomainByHash(hash); 352 if (!customDomain) { 353 return c.text('Custom domain not found or not verified', 404); 354 } 355 356 if (!customDomain.rkey) { 357 return c.text('Domain not mapped to a site', 404); 358 } 359 360 const rkey = customDomain.rkey; 361 if (!isValidRkey(rkey)) { 362 return c.text('Invalid site configuration', 500); 363 } 364 365 const cached = await ensureSiteCached(customDomain.did, rkey); 366 if (!cached) { 367 return c.text('Site not found', 404); 368 } 369 370 return serveFromCache(customDomain.did, rkey, path); 371 } 372 373 // Route 2: Registered subdomains - /*.wisp.place/* 374 if (hostname.endsWith(`.${BASE_HOST}`)) { 375 const domainInfo = await getWispDomain(hostname); 376 if (!domainInfo) { 377 return c.text('Subdomain not registered', 404); 378 } 379 380 if (!domainInfo.rkey) { 381 return c.text('Domain not mapped to a site', 404); 382 } 383 384 const rkey = domainInfo.rkey; 385 if (!isValidRkey(rkey)) { 386 return c.text('Invalid site configuration', 500); 387 } 388 389 const cached = await ensureSiteCached(domainInfo.did, rkey); 390 if (!cached) { 391 return c.text('Site not found', 404); 392 } 393 394 return serveFromCache(domainInfo.did, rkey, path); 395 } 396 397 // Route 1: Custom domains - /* 398 const customDomain = await getCustomDomain(hostname); 399 if (!customDomain) { 400 return c.text('Custom domain not found or not verified', 404); 401 } 402 403 if (!customDomain.rkey) { 404 return c.text('Domain not mapped to a site', 404); 405 } 406 407 const rkey = customDomain.rkey; 408 if (!isValidRkey(rkey)) { 409 return c.text('Invalid site configuration', 500); 410 } 411 412 const cached = await ensureSiteCached(customDomain.did, rkey); 413 if (!cached) { 414 return c.text('Site not found', 404); 415 } 416 417 return serveFromCache(customDomain.did, rkey, path); 418}); 419 420// Internal observability endpoints (for admin panel) 421app.get('/__internal__/observability/logs', (c) => { 422 const query = c.req.query(); 423 const filter: any = {}; 424 if (query.level) filter.level = query.level; 425 if (query.service) filter.service = query.service; 426 if (query.search) filter.search = query.search; 427 if (query.eventType) filter.eventType = query.eventType; 428 if (query.limit) filter.limit = parseInt(query.limit as string); 429 return c.json({ logs: logCollector.getLogs(filter) }); 430}); 431 432app.get('/__internal__/observability/errors', (c) => { 433 const query = c.req.query(); 434 const filter: any = {}; 435 if (query.service) filter.service = query.service; 436 if (query.limit) filter.limit = parseInt(query.limit as string); 437 return c.json({ errors: errorTracker.getErrors(filter) }); 438}); 439 440app.get('/__internal__/observability/metrics', (c) => { 441 const query = c.req.query(); 442 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 443 const stats = metricsCollector.getStats('hosting-service', timeWindow); 444 return c.json({ stats, timeWindow }); 445}); 446 447export default app;