forked from
nekomimi.pet/wisp.place-monorepo
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;