Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
1import { Hono } from 'hono'; 2import { serveStatic } from 'hono/bun'; 3import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 4import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils'; 5import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 6import { existsSync } from 'fs'; 7 8const app = new Hono(); 9 10const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 11 12/** 13 * Validate site name (rkey) to prevent injection attacks 14 * Must match AT Protocol rkey format 15 */ 16function isValidRkey(rkey: string): boolean { 17 if (!rkey || typeof rkey !== 'string') return false; 18 if (rkey.length < 1 || rkey.length > 512) return false; 19 if (rkey === '.' || rkey === '..') return false; 20 if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false; 21 const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/; 22 return validRkeyPattern.test(rkey); 23} 24 25// Helper to serve files from cache 26async function serveFromCache(did: string, rkey: string, filePath: string) { 27 // Default to index.html if path is empty or ends with / 28 let requestPath = filePath || 'index.html'; 29 if (requestPath.endsWith('/')) { 30 requestPath += 'index.html'; 31 } 32 33 const cachedFile = getCachedFilePath(did, rkey, requestPath); 34 35 if (existsSync(cachedFile)) { 36 const file = Bun.file(cachedFile); 37 return new Response(file, { 38 headers: { 39 'Content-Type': file.type || 'application/octet-stream', 40 }, 41 }); 42 } 43 44 // Try index.html for directory-like paths 45 if (!requestPath.includes('.')) { 46 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 47 if (existsSync(indexFile)) { 48 const file = Bun.file(indexFile); 49 return new Response(file, { 50 headers: { 51 'Content-Type': 'text/html; charset=utf-8', 52 }, 53 }); 54 } 55 } 56 57 return new Response('Not Found', { status: 404 }); 58} 59 60// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes 61async function serveFromCacheWithRewrite( 62 did: string, 63 rkey: string, 64 filePath: string, 65 basePath: string 66) { 67 // Default to index.html if path is empty or ends with / 68 let requestPath = filePath || 'index.html'; 69 if (requestPath.endsWith('/')) { 70 requestPath += 'index.html'; 71 } 72 73 const cachedFile = getCachedFilePath(did, rkey, requestPath); 74 75 if (existsSync(cachedFile)) { 76 const file = Bun.file(cachedFile); 77 78 // Check if this is HTML content that needs rewriting 79 if (isHtmlContent(requestPath, file.type)) { 80 const content = await file.text(); 81 const rewritten = rewriteHtmlPaths(content, basePath); 82 return new Response(rewritten, { 83 headers: { 84 'Content-Type': 'text/html; charset=utf-8', 85 }, 86 }); 87 } 88 89 // Non-HTML files served with proper MIME type 90 return new Response(file, { 91 headers: { 92 'Content-Type': file.type || 'application/octet-stream', 93 }, 94 }); 95 } 96 97 // Try index.html for directory-like paths 98 if (!requestPath.includes('.')) { 99 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 100 if (existsSync(indexFile)) { 101 const file = Bun.file(indexFile); 102 const content = await file.text(); 103 const rewritten = rewriteHtmlPaths(content, basePath); 104 return new Response(rewritten, { 105 headers: { 106 'Content-Type': 'text/html; charset=utf-8', 107 }, 108 }); 109 } 110 } 111 112 return new Response('Not Found', { status: 404 }); 113} 114 115// Helper to ensure site is cached 116async function ensureSiteCached(did: string, rkey: string): Promise<boolean> { 117 if (isCached(did, rkey)) { 118 return true; 119 } 120 121 // Fetch and cache the site 122 const siteData = await fetchSiteRecord(did, rkey); 123 if (!siteData) { 124 console.error('Site record not found', did, rkey); 125 return false; 126 } 127 128 const pdsEndpoint = await getPdsForDid(did); 129 if (!pdsEndpoint) { 130 console.error('PDS not found for DID', did); 131 return false; 132 } 133 134 try { 135 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 136 return true; 137 } catch (err) { 138 console.error('Failed to cache site', did, rkey, err); 139 return false; 140 } 141} 142 143// Route 4: Direct file serving (no DB) - sites.wisp.place/:identifier/:site/* 144// This route is now handled in the catch-all route below 145 146// Route 3: DNS routing for custom domains - /hash.dns.wisp.place/* 147app.get('/*', async (c) => { 148 const hostname = c.req.header('host') || ''; 149 const rawPath = c.req.path.replace(/^\//, ''); 150 const path = sanitizePath(rawPath); 151 152 console.log('[Request]', { hostname, path }); 153 154 // Check if this is sites.wisp.place subdomain 155 if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) { 156 // Sanitize the path FIRST to prevent path traversal 157 const sanitizedFullPath = sanitizePath(rawPath); 158 159 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html 160 const pathParts = sanitizedFullPath.split('/'); 161 if (pathParts.length < 2) { 162 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 163 } 164 165 const identifier = pathParts[0]; 166 const site = pathParts[1]; 167 const filePath = pathParts.slice(2).join('/'); 168 169 console.log('[Sites] Serving', { identifier, site, filePath }); 170 171 // Additional validation: identifier must be a valid DID or handle format 172 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) { 173 return c.text('Invalid identifier', 400); 174 } 175 176 // Validate site name (rkey) 177 if (!isValidRkey(site)) { 178 return c.text('Invalid site name', 400); 179 } 180 181 // Resolve identifier to DID 182 const did = await resolveDid(identifier); 183 if (!did) { 184 return c.text('Invalid identifier', 400); 185 } 186 187 // Ensure site is cached 188 const cached = await ensureSiteCached(did, site); 189 if (!cached) { 190 return c.text('Site not found', 404); 191 } 192 193 // Serve with HTML path rewriting to handle absolute paths 194 const basePath = `/${identifier}/${site}/`; 195 return serveFromCacheWithRewrite(did, site, filePath, basePath); 196 } 197 198 // Check if this is a DNS hash subdomain 199 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 200 if (dnsMatch) { 201 const hash = dnsMatch[1]; 202 const baseDomain = dnsMatch[2]; 203 204 console.log('[DNS Hash] Looking up', { hash, baseDomain }); 205 206 if (baseDomain !== BASE_HOST) { 207 return c.text('Invalid base domain', 400); 208 } 209 210 const customDomain = await getCustomDomainByHash(hash); 211 if (!customDomain) { 212 return c.text('Custom domain not found or not verified', 404); 213 } 214 215 const rkey = customDomain.rkey || 'self'; 216 if (!isValidRkey(rkey)) { 217 return c.text('Invalid site configuration', 500); 218 } 219 220 const cached = await ensureSiteCached(customDomain.did, rkey); 221 if (!cached) { 222 return c.text('Site not found', 404); 223 } 224 225 return serveFromCache(customDomain.did, rkey, path); 226 } 227 228 // Route 2: Registered subdomains - /*.wisp.place/* 229 if (hostname.endsWith(`.${BASE_HOST}`)) { 230 const subdomain = hostname.replace(`.${BASE_HOST}`, ''); 231 232 console.log('[Subdomain] Looking up', { subdomain, fullDomain: hostname }); 233 234 const domainInfo = await getWispDomain(hostname); 235 if (!domainInfo) { 236 return c.text('Subdomain not registered', 404); 237 } 238 239 const rkey = domainInfo.rkey || 'self'; 240 if (!isValidRkey(rkey)) { 241 return c.text('Invalid site configuration', 500); 242 } 243 244 const cached = await ensureSiteCached(domainInfo.did, rkey); 245 if (!cached) { 246 return c.text('Site not found', 404); 247 } 248 249 return serveFromCache(domainInfo.did, rkey, path); 250 } 251 252 // Route 1: Custom domains - /* 253 console.log('[Custom Domain] Looking up', { hostname }); 254 255 const customDomain = await getCustomDomain(hostname); 256 if (!customDomain) { 257 return c.text('Custom domain not found or not verified', 404); 258 } 259 260 const rkey = customDomain.rkey || 'self'; 261 if (!isValidRkey(rkey)) { 262 return c.text('Invalid site configuration', 500); 263 } 264 265 const cached = await ensureSiteCached(customDomain.did, rkey); 266 if (!cached) { 267 return c.text('Site not found', 404); 268 } 269 270 return serveFromCache(customDomain.did, rkey, path); 271}); 272 273export default app;