forked from
nekomimi.pet/wisp.place-monorepo
Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
1import { Elysia } from 'elysia';
2import { node } from '@elysiajs/node'
3import { opentelemetry } from '@elysiajs/opentelemetry';
4import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
5import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils';
6import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
7import { existsSync, readFileSync } from 'fs';
8import { lookup } from 'mime-types';
9import { logger, observabilityMiddleware, logCollector, errorTracker, metricsCollector } from './lib/observability';
10
11const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
12
13/**
14 * Validate site name (rkey) to prevent injection attacks
15 * Must match AT Protocol rkey format
16 */
17function isValidRkey(rkey: string): boolean {
18 if (!rkey || typeof rkey !== 'string') return false;
19 if (rkey.length < 1 || rkey.length > 512) return false;
20 if (rkey === '.' || rkey === '..') return false;
21 if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false;
22 const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/;
23 return validRkeyPattern.test(rkey);
24}
25
26// Helper to serve files from cache
27async function serveFromCache(did: string, rkey: string, filePath: string) {
28 // Default to index.html if path is empty or ends with /
29 let requestPath = filePath || 'index.html';
30 if (requestPath.endsWith('/')) {
31 requestPath += 'index.html';
32 }
33
34 const cachedFile = getCachedFilePath(did, rkey, requestPath);
35
36 if (existsSync(cachedFile)) {
37 const content = readFileSync(cachedFile);
38 const metaFile = `${cachedFile}.meta`;
39
40 // Check if file has compression metadata
41 if (existsSync(metaFile)) {
42 const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
43 if (meta.encoding === 'gzip' && meta.mimeType) {
44 // Serve gzipped content with proper headers
45 return new Response(content, {
46 headers: {
47 'Content-Type': meta.mimeType,
48 'Content-Encoding': 'gzip',
49 },
50 });
51 }
52 }
53
54 // Serve non-compressed files normally
55 const mimeType = lookup(cachedFile) || 'application/octet-stream';
56 return new Response(content, {
57 headers: {
58 'Content-Type': mimeType,
59 },
60 });
61 }
62
63 // Try index.html for directory-like paths
64 if (!requestPath.includes('.')) {
65 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
66 if (existsSync(indexFile)) {
67 const content = readFileSync(indexFile);
68 const metaFile = `${indexFile}.meta`;
69
70 // Check if file has compression metadata
71 if (existsSync(metaFile)) {
72 const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
73 if (meta.encoding === 'gzip' && meta.mimeType) {
74 return new Response(content, {
75 headers: {
76 'Content-Type': meta.mimeType,
77 'Content-Encoding': 'gzip',
78 },
79 });
80 }
81 }
82
83 return new Response(content, {
84 headers: {
85 'Content-Type': 'text/html; charset=utf-8',
86 },
87 });
88 }
89 }
90
91 return new Response('Not Found', { status: 404 });
92}
93
94// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes
95async function serveFromCacheWithRewrite(
96 did: string,
97 rkey: string,
98 filePath: string,
99 basePath: string
100) {
101 // Default to index.html if path is empty or ends with /
102 let requestPath = filePath || 'index.html';
103 if (requestPath.endsWith('/')) {
104 requestPath += 'index.html';
105 }
106
107 const cachedFile = getCachedFilePath(did, rkey, requestPath);
108
109 if (existsSync(cachedFile)) {
110 const metaFile = `${cachedFile}.meta`;
111 let mimeType = lookup(cachedFile) || 'application/octet-stream';
112 let isGzipped = false;
113
114 // Check if file has compression metadata
115 if (existsSync(metaFile)) {
116 const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
117 if (meta.encoding === 'gzip' && meta.mimeType) {
118 mimeType = meta.mimeType;
119 isGzipped = true;
120 }
121 }
122
123 // Check if this is HTML content that needs rewriting
124 // Note: For gzipped HTML with path rewriting, we need to decompress, rewrite, and serve uncompressed
125 // This is a trade-off for the sites.wisp.place domain which needs path rewriting
126 if (isHtmlContent(requestPath, mimeType)) {
127 let content: string;
128 if (isGzipped) {
129 const { gunzipSync } = await import('zlib');
130 const compressed = readFileSync(cachedFile);
131 content = gunzipSync(compressed).toString('utf-8');
132 } else {
133 content = readFileSync(cachedFile, 'utf-8');
134 }
135 const rewritten = rewriteHtmlPaths(content, basePath);
136 return new Response(rewritten, {
137 headers: {
138 'Content-Type': 'text/html; charset=utf-8',
139 },
140 });
141 }
142
143 // Non-HTML files: serve gzipped content as-is with proper headers
144 const content = readFileSync(cachedFile);
145 if (isGzipped) {
146 return new Response(content, {
147 headers: {
148 'Content-Type': mimeType,
149 'Content-Encoding': 'gzip',
150 },
151 });
152 }
153 return new Response(content, {
154 headers: {
155 'Content-Type': mimeType,
156 },
157 });
158 }
159
160 // Try index.html for directory-like paths
161 if (!requestPath.includes('.')) {
162 const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
163 if (existsSync(indexFile)) {
164 const metaFile = `${indexFile}.meta`;
165 let isGzipped = false;
166
167 if (existsSync(metaFile)) {
168 const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
169 if (meta.encoding === 'gzip') {
170 isGzipped = true;
171 }
172 }
173
174 // HTML needs path rewriting, so decompress if needed
175 let content: string;
176 if (isGzipped) {
177 const { gunzipSync } = await import('zlib');
178 const compressed = readFileSync(indexFile);
179 content = gunzipSync(compressed).toString('utf-8');
180 } else {
181 content = readFileSync(indexFile, 'utf-8');
182 }
183 const rewritten = rewriteHtmlPaths(content, basePath);
184 return new Response(rewritten, {
185 headers: {
186 'Content-Type': 'text/html; charset=utf-8',
187 },
188 });
189 }
190 }
191
192 return new Response('Not Found', { status: 404 });
193}
194
195// Helper to ensure site is cached
196async function ensureSiteCached(did: string, rkey: string): Promise<boolean> {
197 if (isCached(did, rkey)) {
198 return true;
199 }
200
201 // Fetch and cache the site
202 const siteData = await fetchSiteRecord(did, rkey);
203 if (!siteData) {
204 logger.error('Site record not found', null, { did, rkey });
205 return false;
206 }
207
208 const pdsEndpoint = await getPdsForDid(did);
209 if (!pdsEndpoint) {
210 logger.error('PDS not found for DID', null, { did });
211 return false;
212 }
213
214 try {
215 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
216 logger.info('Site cached successfully', { did, rkey });
217 return true;
218 } catch (err) {
219 logger.error('Failed to cache site', err, { did, rkey });
220 return false;
221 }
222}
223
224const app = new Elysia({ adapter: node() })
225 .use(opentelemetry())
226 .onBeforeHandle(observabilityMiddleware('hosting-service').beforeHandle)
227 .onAfterHandle(observabilityMiddleware('hosting-service').afterHandle)
228 .onError(observabilityMiddleware('hosting-service').onError)
229 .get('/*', async ({ request, set }) => {
230 const url = new URL(request.url);
231 const hostname = request.headers.get('host') || '';
232 const rawPath = url.pathname.replace(/^\//, '');
233 const path = sanitizePath(rawPath);
234
235 // Check if this is sites.wisp.place subdomain
236 if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
237 // Sanitize the path FIRST to prevent path traversal
238 const sanitizedFullPath = sanitizePath(rawPath);
239
240 // Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
241 const pathParts = sanitizedFullPath.split('/');
242 if (pathParts.length < 2) {
243 set.status = 400;
244 return 'Invalid path format. Expected: /identifier/sitename/path';
245 }
246
247 const identifier = pathParts[0];
248 const site = pathParts[1];
249 const filePath = pathParts.slice(2).join('/');
250
251 // Additional validation: identifier must be a valid DID or handle format
252 if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
253 set.status = 400;
254 return 'Invalid identifier';
255 }
256
257 // Validate site name (rkey)
258 if (!isValidRkey(site)) {
259 set.status = 400;
260 return 'Invalid site name';
261 }
262
263 // Resolve identifier to DID
264 const did = await resolveDid(identifier);
265 if (!did) {
266 set.status = 400;
267 return 'Invalid identifier';
268 }
269
270 // Ensure site is cached
271 const cached = await ensureSiteCached(did, site);
272 if (!cached) {
273 set.status = 404;
274 return 'Site not found';
275 }
276
277 // Serve with HTML path rewriting to handle absolute paths
278 const basePath = `/${identifier}/${site}/`;
279 return serveFromCacheWithRewrite(did, site, filePath, basePath);
280 }
281
282 // Check if this is a DNS hash subdomain
283 const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
284 if (dnsMatch) {
285 const hash = dnsMatch[1];
286 const baseDomain = dnsMatch[2];
287
288 if (baseDomain !== BASE_HOST) {
289 set.status = 400;
290 return 'Invalid base domain';
291 }
292
293 const customDomain = await getCustomDomainByHash(hash);
294 if (!customDomain) {
295 set.status = 404;
296 return 'Custom domain not found or not verified';
297 }
298
299 if (!customDomain.rkey) {
300 set.status = 404;
301 return 'Domain not mapped to a site';
302 }
303
304 const rkey = customDomain.rkey;
305 if (!isValidRkey(rkey)) {
306 set.status = 500;
307 return 'Invalid site configuration';
308 }
309
310 const cached = await ensureSiteCached(customDomain.did, rkey);
311 if (!cached) {
312 set.status = 404;
313 return 'Site not found';
314 }
315
316 return serveFromCache(customDomain.did, rkey, path);
317 }
318
319 // Route 2: Registered subdomains - /*.wisp.place/*
320 if (hostname.endsWith(`.${BASE_HOST}`)) {
321 const subdomain = hostname.replace(`.${BASE_HOST}`, '');
322
323 const domainInfo = await getWispDomain(hostname);
324 if (!domainInfo) {
325 set.status = 404;
326 return 'Subdomain not registered';
327 }
328
329 if (!domainInfo.rkey) {
330 set.status = 404;
331 return 'Domain not mapped to a site';
332 }
333
334 const rkey = domainInfo.rkey;
335 if (!isValidRkey(rkey)) {
336 set.status = 500;
337 return 'Invalid site configuration';
338 }
339
340 const cached = await ensureSiteCached(domainInfo.did, rkey);
341 if (!cached) {
342 set.status = 404;
343 return 'Site not found';
344 }
345
346 return serveFromCache(domainInfo.did, rkey, path);
347 }
348
349 // Route 1: Custom domains - /*
350 const customDomain = await getCustomDomain(hostname);
351 if (!customDomain) {
352 set.status = 404;
353 return 'Custom domain not found or not verified';
354 }
355
356 if (!customDomain.rkey) {
357 set.status = 404;
358 return 'Domain not mapped to a site';
359 }
360
361 const rkey = customDomain.rkey;
362 if (!isValidRkey(rkey)) {
363 set.status = 500;
364 return 'Invalid site configuration';
365 }
366
367 const cached = await ensureSiteCached(customDomain.did, rkey);
368 if (!cached) {
369 set.status = 404;
370 return 'Site not found';
371 }
372
373 return serveFromCache(customDomain.did, rkey, path);
374 })
375 // Internal observability endpoints (for admin panel)
376 .get('/__internal__/observability/logs', ({ query }) => {
377 const filter: any = {};
378 if (query.level) filter.level = query.level;
379 if (query.service) filter.service = query.service;
380 if (query.search) filter.search = query.search;
381 if (query.eventType) filter.eventType = query.eventType;
382 if (query.limit) filter.limit = parseInt(query.limit as string);
383 return { logs: logCollector.getLogs(filter) };
384 })
385 .get('/__internal__/observability/errors', ({ query }) => {
386 const filter: any = {};
387 if (query.service) filter.service = query.service;
388 if (query.limit) filter.limit = parseInt(query.limit as string);
389 return { errors: errorTracker.getErrors(filter) };
390 })
391 .get('/__internal__/observability/metrics', ({ query }) => {
392 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
393 const stats = metricsCollector.getStats('hosting-service', timeWindow);
394 return { stats, timeWindow };
395 });
396
397export default app;