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;