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