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 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;