+141
-85
hosting-service/src/server.ts
+141
-85
hosting-service/src/server.ts
···
13
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
14
15
/**
16
* Validate site name (rkey) to prevent injection attacks
17
* Must match AT Protocol rkey format
18
*/
···
85
// If not forced, check if the requested file exists before redirecting
86
if (!rule.force) {
87
// Build the expected file path
88
-
let checkPath = filePath || 'index.html';
89
if (checkPath.endsWith('/')) {
90
-
checkPath += 'index.html';
91
}
92
93
const cachedFile = getCachedFilePath(did, rkey, checkPath);
···
133
134
// Internal function to serve a file (used by both normal serving and rewrites)
135
async function serveFileInternal(did: string, rkey: string, filePath: string) {
136
-
// Default to index.html if path is empty or ends with /
137
-
let requestPath = filePath || 'index.html';
138
if (requestPath.endsWith('/')) {
139
-
requestPath += 'index.html';
140
}
141
142
const cacheKey = getCacheKey(did, rkey, requestPath);
143
const cachedFile = getCachedFilePath(did, rkey, requestPath);
144
145
// Check in-memory cache first
146
let content = fileCache.get(cacheKey);
147
let meta = metadataCache.get(cacheKey);
···
201
return new Response(content, { headers });
202
}
203
204
-
// Try index.html for directory-like paths
205
if (!requestPath.includes('.')) {
206
-
const indexPath = `${requestPath}/index.html`;
207
-
const indexCacheKey = getCacheKey(did, rkey, indexPath);
208
-
const indexFile = getCachedFilePath(did, rkey, indexPath);
209
210
-
let indexContent = fileCache.get(indexCacheKey);
211
-
let indexMeta = metadataCache.get(indexCacheKey);
212
213
-
if (!indexContent && await fileExists(indexFile)) {
214
-
indexContent = await readFile(indexFile);
215
-
fileCache.set(indexCacheKey, indexContent, indexContent.length);
216
217
-
const indexMetaFile = `${indexFile}.meta`;
218
-
if (await fileExists(indexMetaFile)) {
219
-
const metaJson = await readFile(indexMetaFile, 'utf-8');
220
-
indexMeta = JSON.parse(metaJson);
221
-
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
222
}
223
-
}
224
225
-
if (indexContent) {
226
-
const headers: Record<string, string> = {
227
-
'Content-Type': 'text/html; charset=utf-8',
228
-
'Cache-Control': 'public, max-age=300',
229
-
};
230
231
-
if (indexMeta && indexMeta.encoding === 'gzip') {
232
-
headers['Content-Encoding'] = 'gzip';
233
-
}
234
235
-
return new Response(indexContent, { headers });
236
}
237
}
238
···
276
// If not forced, check if the requested file exists before redirecting
277
if (!rule.force) {
278
// Build the expected file path
279
-
let checkPath = filePath || 'index.html';
280
if (checkPath.endsWith('/')) {
281
-
checkPath += 'index.html';
282
}
283
284
const cachedFile = getCachedFilePath(did, rkey, checkPath);
···
329
330
// Internal function to serve a file with rewriting
331
async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) {
332
-
// Default to index.html if path is empty or ends with /
333
-
let requestPath = filePath || 'index.html';
334
if (requestPath.endsWith('/')) {
335
-
requestPath += 'index.html';
336
}
337
338
const cacheKey = getCacheKey(did, rkey, requestPath);
339
const cachedFile = getCachedFilePath(did, rkey, requestPath);
340
341
// Check for rewritten HTML in cache first (if it's HTML)
342
const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream';
343
if (isHtmlContent(requestPath, mimeTypeGuess)) {
···
435
return new Response(content, { headers });
436
}
437
438
-
// Try index.html for directory-like paths
439
if (!requestPath.includes('.')) {
440
-
const indexPath = `${requestPath}/index.html`;
441
-
const indexCacheKey = getCacheKey(did, rkey, indexPath);
442
-
const indexFile = getCachedFilePath(did, rkey, indexPath);
443
444
-
// Check for rewritten index.html in cache
445
-
const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`);
446
-
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
447
-
if (rewrittenContent) {
448
-
return new Response(rewrittenContent, {
449
-
headers: {
450
-
'Content-Type': 'text/html; charset=utf-8',
451
-
'Content-Encoding': 'gzip',
452
-
'Cache-Control': 'public, max-age=300',
453
-
},
454
-
});
455
-
}
456
457
-
let indexContent = fileCache.get(indexCacheKey);
458
-
let indexMeta = metadataCache.get(indexCacheKey);
459
460
-
if (!indexContent && await fileExists(indexFile)) {
461
-
indexContent = await readFile(indexFile);
462
-
fileCache.set(indexCacheKey, indexContent, indexContent.length);
463
464
-
const indexMetaFile = `${indexFile}.meta`;
465
-
if (await fileExists(indexMetaFile)) {
466
-
const metaJson = await readFile(indexMetaFile, 'utf-8');
467
-
indexMeta = JSON.parse(metaJson);
468
-
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
469
}
470
-
}
471
472
-
if (indexContent) {
473
-
const isGzipped = indexMeta?.encoding === 'gzip';
474
475
-
let htmlContent: string;
476
-
if (isGzipped) {
477
-
// Verify content is actually gzipped
478
-
const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b;
479
-
if (hasGzipMagic) {
480
-
const { gunzipSync } = await import('zlib');
481
-
htmlContent = gunzipSync(indexContent).toString('utf-8');
482
} else {
483
-
console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`);
484
htmlContent = indexContent.toString('utf-8');
485
}
486
-
} else {
487
-
htmlContent = indexContent.toString('utf-8');
488
-
}
489
-
const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath);
490
491
-
const { gzipSync } = await import('zlib');
492
-
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
493
494
-
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
495
496
-
return new Response(recompressed, {
497
-
headers: {
498
-
'Content-Type': 'text/html; charset=utf-8',
499
-
'Content-Encoding': 'gzip',
500
-
'Cache-Control': 'public, max-age=300',
501
-
},
502
-
});
503
}
504
}
505
···
13
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
14
15
/**
16
+
* Configurable index file names to check for directory requests
17
+
* Will be checked in order until one is found
18
+
*/
19
+
const INDEX_FILES = ['index.html', 'index.htm'];
20
+
21
+
/**
22
* Validate site name (rkey) to prevent injection attacks
23
* Must match AT Protocol rkey format
24
*/
···
91
// If not forced, check if the requested file exists before redirecting
92
if (!rule.force) {
93
// Build the expected file path
94
+
let checkPath = filePath || INDEX_FILES[0];
95
if (checkPath.endsWith('/')) {
96
+
checkPath += INDEX_FILES[0];
97
}
98
99
const cachedFile = getCachedFilePath(did, rkey, checkPath);
···
139
140
// Internal function to serve a file (used by both normal serving and rewrites)
141
async function serveFileInternal(did: string, rkey: string, filePath: string) {
142
+
// Default to first index file if path is empty
143
+
let requestPath = filePath || INDEX_FILES[0];
144
+
145
+
// If path ends with /, append first index file
146
if (requestPath.endsWith('/')) {
147
+
requestPath += INDEX_FILES[0];
148
}
149
150
const cacheKey = getCacheKey(did, rkey, requestPath);
151
const cachedFile = getCachedFilePath(did, rkey, requestPath);
152
153
+
// Check if the cached file path is a directory
154
+
if (await fileExists(cachedFile)) {
155
+
const { stat } = await import('fs/promises');
156
+
try {
157
+
const stats = await stat(cachedFile);
158
+
if (stats.isDirectory()) {
159
+
// It's a directory, try each index file in order
160
+
for (const indexFile of INDEX_FILES) {
161
+
const indexPath = `${requestPath}/${indexFile}`;
162
+
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
163
+
if (await fileExists(indexFilePath)) {
164
+
return serveFileInternal(did, rkey, indexPath);
165
+
}
166
+
}
167
+
// No index file found, fall through to 404
168
+
}
169
+
} catch (err) {
170
+
// If stat fails, continue with normal flow
171
+
}
172
+
}
173
+
174
// Check in-memory cache first
175
let content = fileCache.get(cacheKey);
176
let meta = metadataCache.get(cacheKey);
···
230
return new Response(content, { headers });
231
}
232
233
+
// Try index files for directory-like paths
234
if (!requestPath.includes('.')) {
235
+
for (const indexFileName of INDEX_FILES) {
236
+
const indexPath = `${requestPath}/${indexFileName}`;
237
+
const indexCacheKey = getCacheKey(did, rkey, indexPath);
238
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
239
240
+
let indexContent = fileCache.get(indexCacheKey);
241
+
let indexMeta = metadataCache.get(indexCacheKey);
242
243
+
if (!indexContent && await fileExists(indexFile)) {
244
+
indexContent = await readFile(indexFile);
245
+
fileCache.set(indexCacheKey, indexContent, indexContent.length);
246
247
+
const indexMetaFile = `${indexFile}.meta`;
248
+
if (await fileExists(indexMetaFile)) {
249
+
const metaJson = await readFile(indexMetaFile, 'utf-8');
250
+
indexMeta = JSON.parse(metaJson);
251
+
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
252
+
}
253
}
254
255
+
if (indexContent) {
256
+
const headers: Record<string, string> = {
257
+
'Content-Type': 'text/html; charset=utf-8',
258
+
'Cache-Control': 'public, max-age=300',
259
+
};
260
261
+
if (indexMeta && indexMeta.encoding === 'gzip') {
262
+
headers['Content-Encoding'] = 'gzip';
263
+
}
264
265
+
return new Response(indexContent, { headers });
266
+
}
267
}
268
}
269
···
307
// If not forced, check if the requested file exists before redirecting
308
if (!rule.force) {
309
// Build the expected file path
310
+
let checkPath = filePath || INDEX_FILES[0];
311
if (checkPath.endsWith('/')) {
312
+
checkPath += INDEX_FILES[0];
313
}
314
315
const cachedFile = getCachedFilePath(did, rkey, checkPath);
···
360
361
// Internal function to serve a file with rewriting
362
async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) {
363
+
// Default to first index file if path is empty
364
+
let requestPath = filePath || INDEX_FILES[0];
365
+
366
+
// If path ends with /, append first index file
367
if (requestPath.endsWith('/')) {
368
+
requestPath += INDEX_FILES[0];
369
}
370
371
const cacheKey = getCacheKey(did, rkey, requestPath);
372
const cachedFile = getCachedFilePath(did, rkey, requestPath);
373
374
+
// Check if the cached file path is a directory
375
+
if (await fileExists(cachedFile)) {
376
+
const { stat } = await import('fs/promises');
377
+
try {
378
+
const stats = await stat(cachedFile);
379
+
if (stats.isDirectory()) {
380
+
// It's a directory, try each index file in order
381
+
for (const indexFile of INDEX_FILES) {
382
+
const indexPath = `${requestPath}/${indexFile}`;
383
+
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
384
+
if (await fileExists(indexFilePath)) {
385
+
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath);
386
+
}
387
+
}
388
+
// No index file found, fall through to 404
389
+
}
390
+
} catch (err) {
391
+
// If stat fails, continue with normal flow
392
+
}
393
+
}
394
+
395
// Check for rewritten HTML in cache first (if it's HTML)
396
const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream';
397
if (isHtmlContent(requestPath, mimeTypeGuess)) {
···
489
return new Response(content, { headers });
490
}
491
492
+
// Try index files for directory-like paths
493
if (!requestPath.includes('.')) {
494
+
for (const indexFileName of INDEX_FILES) {
495
+
const indexPath = `${requestPath}/${indexFileName}`;
496
+
const indexCacheKey = getCacheKey(did, rkey, indexPath);
497
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
498
499
+
// Check for rewritten index file in cache
500
+
const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`);
501
+
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
502
+
if (rewrittenContent) {
503
+
return new Response(rewrittenContent, {
504
+
headers: {
505
+
'Content-Type': 'text/html; charset=utf-8',
506
+
'Content-Encoding': 'gzip',
507
+
'Cache-Control': 'public, max-age=300',
508
+
},
509
+
});
510
+
}
511
512
+
let indexContent = fileCache.get(indexCacheKey);
513
+
let indexMeta = metadataCache.get(indexCacheKey);
514
515
+
if (!indexContent && await fileExists(indexFile)) {
516
+
indexContent = await readFile(indexFile);
517
+
fileCache.set(indexCacheKey, indexContent, indexContent.length);
518
519
+
const indexMetaFile = `${indexFile}.meta`;
520
+
if (await fileExists(indexMetaFile)) {
521
+
const metaJson = await readFile(indexMetaFile, 'utf-8');
522
+
indexMeta = JSON.parse(metaJson);
523
+
metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length);
524
+
}
525
}
526
527
+
if (indexContent) {
528
+
const isGzipped = indexMeta?.encoding === 'gzip';
529
530
+
let htmlContent: string;
531
+
if (isGzipped) {
532
+
// Verify content is actually gzipped
533
+
const hasGzipMagic = indexContent.length >= 2 && indexContent[0] === 0x1f && indexContent[1] === 0x8b;
534
+
if (hasGzipMagic) {
535
+
const { gunzipSync } = await import('zlib');
536
+
htmlContent = gunzipSync(indexContent).toString('utf-8');
537
+
} else {
538
+
console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`);
539
+
htmlContent = indexContent.toString('utf-8');
540
+
}
541
} else {
542
htmlContent = indexContent.toString('utf-8');
543
}
544
+
const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath);
545
546
+
const { gzipSync } = await import('zlib');
547
+
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
548
549
+
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
550
551
+
return new Response(recompressed, {
552
+
headers: {
553
+
'Content-Type': 'text/html; charset=utf-8',
554
+
'Content-Encoding': 'gzip',
555
+
'Cache-Control': 'public, max-age=300',
556
+
},
557
+
});
558
+
}
559
}
560
}
561
+9
public/editor/tabs/CLITab.tsx
+9
public/editor/tabs/CLITab.tsx
···
296
<h3 className="text-sm font-semibold">Learn More</h3>
297
<div className="grid gap-2">
298
<a
299
+
href="https://docs.wisp.place/cli"
300
+
target="_blank"
301
+
rel="noopener noreferrer"
302
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
303
+
>
304
+
<span className="text-sm">CLI Documentation</span>
305
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
306
+
</a>
307
+
<a
308
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
309
target="_blank"
310
rel="noopener noreferrer"
+13
-2
public/index.tsx
+13
-2
public/index.tsx
···
372
<Button
373
size="sm"
374
className="bg-accent text-accent-foreground hover:bg-accent/90"
375
-
onClick={() => setShowForm(true)}
376
>
377
-
Get Started
378
</Button>
379
</div>
380
</div>
···
728
className="text-accent hover:text-accent/80 transition-colors font-medium"
729
>
730
Acceptable Use Policy
731
</a>
732
</p>
733
</div>
···
372
<Button
373
size="sm"
374
className="bg-accent text-accent-foreground hover:bg-accent/90"
375
+
asChild
376
>
377
+
<a href="https://docs.wisp.place" target="_blank" rel="noopener noreferrer">
378
+
Read the Docs
379
+
</a>
380
</Button>
381
</div>
382
</div>
···
730
className="text-accent hover:text-accent/80 transition-colors font-medium"
731
>
732
Acceptable Use Policy
733
+
</a>
734
+
{' • '}
735
+
<a
736
+
href="https://docs.wisp.place"
737
+
target="_blank"
738
+
rel="noopener noreferrer"
739
+
className="text-accent hover:text-accent/80 transition-colors font-medium"
740
+
>
741
+
Documentation
742
</a>
743
</p>
744
</div>