Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

add docs, fix regression in hosting service where it would treat folders as files

Changed files
+163 -87
hosting-service
src
public
editor
tabs
+141 -85
hosting-service/src/server.ts
··· 13 13 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 14 14 15 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 + /** 16 22 * Validate site name (rkey) to prevent injection attacks 17 23 * Must match AT Protocol rkey format 18 24 */ ··· 85 91 // If not forced, check if the requested file exists before redirecting 86 92 if (!rule.force) { 87 93 // Build the expected file path 88 - let checkPath = filePath || 'index.html'; 94 + let checkPath = filePath || INDEX_FILES[0]; 89 95 if (checkPath.endsWith('/')) { 90 - checkPath += 'index.html'; 96 + checkPath += INDEX_FILES[0]; 91 97 } 92 98 93 99 const cachedFile = getCachedFilePath(did, rkey, checkPath); ··· 133 139 134 140 // Internal function to serve a file (used by both normal serving and rewrites) 135 141 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'; 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 138 146 if (requestPath.endsWith('/')) { 139 - requestPath += 'index.html'; 147 + requestPath += INDEX_FILES[0]; 140 148 } 141 149 142 150 const cacheKey = getCacheKey(did, rkey, requestPath); 143 151 const cachedFile = getCachedFilePath(did, rkey, requestPath); 144 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 + 145 174 // Check in-memory cache first 146 175 let content = fileCache.get(cacheKey); 147 176 let meta = metadataCache.get(cacheKey); ··· 201 230 return new Response(content, { headers }); 202 231 } 203 232 204 - // Try index.html for directory-like paths 233 + // Try index files for directory-like paths 205 234 if (!requestPath.includes('.')) { 206 - const indexPath = `${requestPath}/index.html`; 207 - const indexCacheKey = getCacheKey(did, rkey, indexPath); 208 - const indexFile = getCachedFilePath(did, rkey, indexPath); 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); 209 239 210 - let indexContent = fileCache.get(indexCacheKey); 211 - let indexMeta = metadataCache.get(indexCacheKey); 240 + let indexContent = fileCache.get(indexCacheKey); 241 + let indexMeta = metadataCache.get(indexCacheKey); 212 242 213 - if (!indexContent && await fileExists(indexFile)) { 214 - indexContent = await readFile(indexFile); 215 - fileCache.set(indexCacheKey, indexContent, indexContent.length); 243 + if (!indexContent && await fileExists(indexFile)) { 244 + indexContent = await readFile(indexFile); 245 + fileCache.set(indexCacheKey, indexContent, indexContent.length); 216 246 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); 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 + } 222 253 } 223 - } 224 254 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 - }; 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 + }; 230 260 231 - if (indexMeta && indexMeta.encoding === 'gzip') { 232 - headers['Content-Encoding'] = 'gzip'; 233 - } 261 + if (indexMeta && indexMeta.encoding === 'gzip') { 262 + headers['Content-Encoding'] = 'gzip'; 263 + } 234 264 235 - return new Response(indexContent, { headers }); 265 + return new Response(indexContent, { headers }); 266 + } 236 267 } 237 268 } 238 269 ··· 276 307 // If not forced, check if the requested file exists before redirecting 277 308 if (!rule.force) { 278 309 // Build the expected file path 279 - let checkPath = filePath || 'index.html'; 310 + let checkPath = filePath || INDEX_FILES[0]; 280 311 if (checkPath.endsWith('/')) { 281 - checkPath += 'index.html'; 312 + checkPath += INDEX_FILES[0]; 282 313 } 283 314 284 315 const cachedFile = getCachedFilePath(did, rkey, checkPath); ··· 329 360 330 361 // Internal function to serve a file with rewriting 331 362 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'; 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 334 367 if (requestPath.endsWith('/')) { 335 - requestPath += 'index.html'; 368 + requestPath += INDEX_FILES[0]; 336 369 } 337 370 338 371 const cacheKey = getCacheKey(did, rkey, requestPath); 339 372 const cachedFile = getCachedFilePath(did, rkey, requestPath); 340 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 + 341 395 // Check for rewritten HTML in cache first (if it's HTML) 342 396 const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream'; 343 397 if (isHtmlContent(requestPath, mimeTypeGuess)) { ··· 435 489 return new Response(content, { headers }); 436 490 } 437 491 438 - // Try index.html for directory-like paths 492 + // Try index files for directory-like paths 439 493 if (!requestPath.includes('.')) { 440 - const indexPath = `${requestPath}/index.html`; 441 - const indexCacheKey = getCacheKey(did, rkey, indexPath); 442 - const indexFile = getCachedFilePath(did, rkey, indexPath); 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); 443 498 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 - } 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 + } 456 511 457 - let indexContent = fileCache.get(indexCacheKey); 458 - let indexMeta = metadataCache.get(indexCacheKey); 512 + let indexContent = fileCache.get(indexCacheKey); 513 + let indexMeta = metadataCache.get(indexCacheKey); 459 514 460 - if (!indexContent && await fileExists(indexFile)) { 461 - indexContent = await readFile(indexFile); 462 - fileCache.set(indexCacheKey, indexContent, indexContent.length); 515 + if (!indexContent && await fileExists(indexFile)) { 516 + indexContent = await readFile(indexFile); 517 + fileCache.set(indexCacheKey, indexContent, indexContent.length); 463 518 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); 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 + } 469 525 } 470 - } 471 526 472 - if (indexContent) { 473 - const isGzipped = indexMeta?.encoding === 'gzip'; 527 + if (indexContent) { 528 + const isGzipped = indexMeta?.encoding === 'gzip'; 474 529 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'); 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 + } 482 541 } else { 483 - console.warn(`Index file marked as gzipped but lacks magic bytes, serving as-is`); 484 542 htmlContent = indexContent.toString('utf-8'); 485 543 } 486 - } else { 487 - htmlContent = indexContent.toString('utf-8'); 488 - } 489 - const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath); 544 + const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath); 490 545 491 - const { gzipSync } = await import('zlib'); 492 - const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 546 + const { gzipSync } = await import('zlib'); 547 + const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 493 548 494 - rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 549 + rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 495 550 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 - }); 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 + } 503 559 } 504 560 } 505 561
+9
public/editor/tabs/CLITab.tsx
··· 296 296 <h3 className="text-sm font-semibold">Learn More</h3> 297 297 <div className="grid gap-2"> 298 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 299 308 href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli" 300 309 target="_blank" 301 310 rel="noopener noreferrer"
+13 -2
public/index.tsx
··· 372 372 <Button 373 373 size="sm" 374 374 className="bg-accent text-accent-foreground hover:bg-accent/90" 375 - onClick={() => setShowForm(true)} 375 + asChild 376 376 > 377 - Get Started 377 + <a href="https://docs.wisp.place" target="_blank" rel="noopener noreferrer"> 378 + Read the Docs 379 + </a> 378 380 </Button> 379 381 </div> 380 382 </div> ··· 728 730 className="text-accent hover:text-accent/80 transition-colors font-medium" 729 731 > 730 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 731 742 </a> 732 743 </p> 733 744 </div>