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