Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
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 } 39 40 // Try index.html for directory-like paths 41 if (!requestPath.includes('.')) { 42 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 43 if (existsSync(indexFile)) { 44 const file = Bun.file(indexFile); 45 return new Response(file); 46 } 47 } 48 49 return new Response('Not Found', { status: 404 }); 50} 51 52// Helper to serve files from cache with HTML path rewriting for /s/ routes 53async function serveFromCacheWithRewrite( 54 did: string, 55 rkey: string, 56 filePath: string, 57 basePath: string 58) { 59 // Default to index.html if path is empty or ends with / 60 let requestPath = filePath || 'index.html'; 61 if (requestPath.endsWith('/')) { 62 requestPath += 'index.html'; 63 } 64 65 const cachedFile = getCachedFilePath(did, rkey, requestPath); 66 67 if (existsSync(cachedFile)) { 68 const file = Bun.file(cachedFile); 69 70 // Check if this is HTML content that needs rewriting 71 if (isHtmlContent(requestPath, file.type)) { 72 const content = await file.text(); 73 const rewritten = rewriteHtmlPaths(content, basePath); 74 return new Response(rewritten, { 75 headers: { 76 'Content-Type': 'text/html; charset=utf-8', 77 }, 78 }); 79 } 80 81 // Non-HTML files served as-is 82 return new Response(file); 83 } 84 85 // Try index.html for directory-like paths 86 if (!requestPath.includes('.')) { 87 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 88 if (existsSync(indexFile)) { 89 const file = Bun.file(indexFile); 90 const content = await file.text(); 91 const rewritten = rewriteHtmlPaths(content, basePath); 92 return new Response(rewritten, { 93 headers: { 94 'Content-Type': 'text/html; charset=utf-8', 95 }, 96 }); 97 } 98 } 99 100 return new Response('Not Found', { status: 404 }); 101} 102 103// Helper to ensure site is cached 104async function ensureSiteCached(did: string, rkey: string): Promise<boolean> { 105 if (isCached(did, rkey)) { 106 return true; 107 } 108 109 // Fetch and cache the site 110 const record = await fetchSiteRecord(did, rkey); 111 if (!record) { 112 console.error('Site record not found', did, rkey); 113 return false; 114 } 115 116 const pdsEndpoint = await getPdsForDid(did); 117 if (!pdsEndpoint) { 118 console.error('PDS not found for DID', did); 119 return false; 120 } 121 122 try { 123 await downloadAndCacheSite(did, rkey, record, pdsEndpoint); 124 return true; 125 } catch (err) { 126 console.error('Failed to cache site', did, rkey, err); 127 return false; 128 } 129} 130 131// Route 4: Direct file serving (no DB) - /s.wisp.place/:identifier/:site/* 132app.get('/s/:identifier/:site/*', async (c) => { 133 const identifier = c.req.param('identifier'); 134 const site = c.req.param('site'); 135 const rawPath = c.req.path.replace(`/s/${identifier}/${site}/`, ''); 136 const filePath = sanitizePath(rawPath); 137 138 console.log('[Direct] Serving', { identifier, site, filePath }); 139 140 // Validate site name (rkey) 141 if (!isValidRkey(site)) { 142 return c.text('Invalid site name', 400); 143 } 144 145 // Resolve identifier to DID 146 const did = await resolveDid(identifier); 147 if (!did) { 148 return c.text('Invalid identifier', 400); 149 } 150 151 // Ensure site is cached 152 const cached = await ensureSiteCached(did, site); 153 if (!cached) { 154 return c.text('Site not found', 404); 155 } 156 157 // Serve with HTML path rewriting to handle absolute paths 158 const basePath = `/s/${identifier}/${site}/`; 159 return serveFromCacheWithRewrite(did, site, filePath, basePath); 160}); 161 162// Route 3: DNS routing for custom domains - /hash.dns.wisp.place/* 163app.get('/*', async (c) => { 164 const hostname = c.req.header('host') || ''; 165 const rawPath = c.req.path.replace(/^\//, ''); 166 const path = sanitizePath(rawPath); 167 168 console.log('[Request]', { hostname, path }); 169 170 // Check if this is a DNS hash subdomain 171 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/); 172 if (dnsMatch) { 173 const hash = dnsMatch[1]; 174 const baseDomain = dnsMatch[2]; 175 176 console.log('[DNS Hash] Looking up', { hash, baseDomain }); 177 178 if (baseDomain !== BASE_HOST) { 179 return c.text('Invalid base domain', 400); 180 } 181 182 const customDomain = await getCustomDomainByHash(hash); 183 if (!customDomain) { 184 return c.text('Custom domain not found or not verified', 404); 185 } 186 187 const rkey = customDomain.rkey || 'self'; 188 if (!isValidRkey(rkey)) { 189 return c.text('Invalid site configuration', 500); 190 } 191 192 const cached = await ensureSiteCached(customDomain.did, rkey); 193 if (!cached) { 194 return c.text('Site not found', 404); 195 } 196 197 return serveFromCache(customDomain.did, rkey, path); 198 } 199 200 // Route 2: Registered subdomains - /*.wisp.place/* 201 if (hostname.endsWith(`.${BASE_HOST}`)) { 202 const subdomain = hostname.replace(`.${BASE_HOST}`, ''); 203 204 console.log('[Subdomain] Looking up', { subdomain, fullDomain: hostname }); 205 206 const domainInfo = await getWispDomain(hostname); 207 if (!domainInfo) { 208 return c.text('Subdomain not registered', 404); 209 } 210 211 const rkey = domainInfo.rkey || 'self'; 212 if (!isValidRkey(rkey)) { 213 return c.text('Invalid site configuration', 500); 214 } 215 216 const cached = await ensureSiteCached(domainInfo.did, rkey); 217 if (!cached) { 218 return c.text('Site not found', 404); 219 } 220 221 return serveFromCache(domainInfo.did, rkey, path); 222 } 223 224 // Route 1: Custom domains - /* 225 console.log('[Custom Domain] Looking up', { hostname }); 226 227 const customDomain = await getCustomDomain(hostname); 228 if (!customDomain) { 229 return c.text('Custom domain not found or not verified', 404); 230 } 231 232 const rkey = customDomain.rkey || 'self'; 233 if (!isValidRkey(rkey)) { 234 return c.text('Invalid site configuration', 500); 235 } 236 237 const cached = await ensureSiteCached(customDomain.did, rkey); 238 if (!cached) { 239 return c.text('Site not found', 404); 240 } 241 242 return serveFromCache(customDomain.did, rkey, path); 243}); 244 245export default app;