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 record = await fetchSiteRecord(did, rkey); 123 if (!record) { 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, record, pdsEndpoint); 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 // Extract identifier and site from path: /did:plc:123abc/sitename/file.html 157 const pathParts = rawPath.split('/'); 158 if (pathParts.length < 2) { 159 return c.text('Invalid path format. Expected: /identifier/sitename/path', 400); 160 } 161 162 const identifier = pathParts[0]; 163 const site = pathParts[1]; 164 const filePath = sanitizePath(pathParts.slice(2).join('/')); 165 166 console.log('[Sites] Serving', { identifier, site, filePath }); 167 168 // Validate site name (rkey) 169 if (!isValidRkey(site)) { 170 return c.text('Invalid site name', 400); 171 } 172 173 // Resolve identifier to DID 174 const did = await resolveDid(identifier); 175 if (!did) { 176 return c.text('Invalid identifier', 400); 177 } 178 179 // Ensure site is cached 180 const cached = await ensureSiteCached(did, site); 181 if (!cached) { 182 return c.text('Site not found', 404); 183 } 184 185 // Serve with HTML path rewriting to handle absolute paths 186 const basePath = `/${identifier}/${site}/`; 187 return serveFromCacheWithRewrite(did, site, filePath, basePath); 188 } 189 190 // Check if this is a DNS hash subdomain 191 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 192 if (dnsMatch) { 193 const hash = dnsMatch[1]; 194 const baseDomain = dnsMatch[2]; 195 196 console.log('[DNS Hash] Looking up', { hash, baseDomain }); 197 198 if (baseDomain !== BASE_HOST) { 199 return c.text('Invalid base domain', 400); 200 } 201 202 const customDomain = await getCustomDomainByHash(hash); 203 if (!customDomain) { 204 return c.text('Custom domain not found or not verified', 404); 205 } 206 207 const rkey = customDomain.rkey || 'self'; 208 if (!isValidRkey(rkey)) { 209 return c.text('Invalid site configuration', 500); 210 } 211 212 const cached = await ensureSiteCached(customDomain.did, rkey); 213 if (!cached) { 214 return c.text('Site not found', 404); 215 } 216 217 return serveFromCache(customDomain.did, rkey, path); 218 } 219 220 // Route 2: Registered subdomains - /*.wisp.place/* 221 if (hostname.endsWith(`.${BASE_HOST}`)) { 222 const subdomain = hostname.replace(`.${BASE_HOST}`, ''); 223 224 console.log('[Subdomain] Looking up', { subdomain, fullDomain: hostname }); 225 226 const domainInfo = await getWispDomain(hostname); 227 if (!domainInfo) { 228 return c.text('Subdomain not registered', 404); 229 } 230 231 const rkey = domainInfo.rkey || 'self'; 232 if (!isValidRkey(rkey)) { 233 return c.text('Invalid site configuration', 500); 234 } 235 236 const cached = await ensureSiteCached(domainInfo.did, rkey); 237 if (!cached) { 238 return c.text('Site not found', 404); 239 } 240 241 return serveFromCache(domainInfo.did, rkey, path); 242 } 243 244 // Route 1: Custom domains - /* 245 console.log('[Custom Domain] Looking up', { hostname }); 246 247 const customDomain = await getCustomDomain(hostname); 248 if (!customDomain) { 249 return c.text('Custom domain not found or not verified', 404); 250 } 251 252 const rkey = customDomain.rkey || 'self'; 253 if (!isValidRkey(rkey)) { 254 return c.text('Invalid site configuration', 500); 255 } 256 257 const cached = await ensureSiteCached(customDomain.did, rkey); 258 if (!cached) { 259 return c.text('Site not found', 404); 260 } 261 262 return serveFromCache(customDomain.did, rkey, path); 263}); 264 265export default app;