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;