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;