From bd7ee41ef54539926e816cb7dc157695c8f63b00 Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Mon, 23 Feb 2026 20:57:33 -0500 Subject: [PATCH] Fix directory index routing --- apps/hosting-service/src/lib/file-serving.ts | 6 +- .../src/tiers/DiskStorageTier.ts | 69 ++++++++++++++----- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/apps/hosting-service/src/lib/file-serving.ts b/apps/hosting-service/src/lib/file-serving.ts index 3a8c1a7..edb63f0 100644 --- a/apps/hosting-service/src/lib/file-serving.ts +++ b/apps/hosting-service/src/lib/file-serving.ts @@ -383,6 +383,7 @@ export async function serveFileInternal( }; const indexFiles = getIndexFiles(settings); + const isDirectoryPathRequest = filePath.endsWith('/') && filePath.length > 0; // Normalize the request path (keep empty for root, remove trailing slash for others) let requestPath = filePath || ''; @@ -393,7 +394,7 @@ export async function serveFileInternal( // For directory-like paths (empty or no file extension in basename), try index files if (!requestPath || !hasFileExtension(requestPath)) { // For non-empty extensionless paths, try as a direct file first (e.g. binary downloads) - if (requestPath) { + if (requestPath && !isDirectoryPathRequest) { const directResult = await span(trace, `storage:${requestPath}`, () => getFileWithMetadata(did, rkey, requestPath)); if (directResult) { return buildResponseFromStorageResult(directResult, requestPath, settings, requestHeaders); @@ -694,6 +695,7 @@ export async function serveFileInternalWithRewrite( }; const indexFiles = getIndexFiles(settings); + const isDirectoryPathRequest = filePath.endsWith('/') && filePath.length > 0; const buildResponse = (fileResult: FileForRequestResult): Response => { const meta = fileResult.result.metadata.customMetadata as { encoding?: string; mimeType?: string } | undefined; const mimeType = meta?.mimeType || lookup(fileResult.filePath) || 'application/octet-stream'; @@ -716,7 +718,7 @@ export async function serveFileInternalWithRewrite( // For directory-like paths (empty or no file extension in basename), try index files if (!requestPath || !hasFileExtension(requestPath)) { // For non-empty extensionless paths, try as a direct file first (e.g. binary downloads) - if (requestPath) { + if (requestPath && !isDirectoryPathRequest) { const directResult = await span(trace, `storage:${requestPath}`, () => getFileForRequest(did, rkey, requestPath, true)); if (directResult) { return buildResponse(directResult); diff --git a/packages/@wispplace/tiered-storage/src/tiers/DiskStorageTier.ts b/packages/@wispplace/tiered-storage/src/tiers/DiskStorageTier.ts index 1e7c084..739e843 100644 --- a/packages/@wispplace/tiered-storage/src/tiers/DiskStorageTier.ts +++ b/packages/@wispplace/tiered-storage/src/tiers/DiskStorageTier.ts @@ -11,6 +11,12 @@ import type { } from '../types/index.js'; import { encodeKey, decodeKey } from '../utils/path-encoding.js'; +function getErrnoCode(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const maybeCode = (error as { code?: unknown }).code; + return typeof maybeCode === 'string' ? maybeCode : undefined; +} + /** * Eviction policy for disk tier when size limit is reached. */ @@ -180,8 +186,8 @@ export class DiskStorageTier implements StorageTier { const data = await readFile(filePath); return new Uint8Array(data); } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') { + const code = getErrnoCode(error); + if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR') { return null; } throw error; @@ -222,8 +228,8 @@ export class DiskStorageTier implements StorageTier { return { data: new Uint8Array(dataBuffer), metadata }; } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') { + const code = getErrnoCode(error); + if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR') { return null; } if (error instanceof SyntaxError) { @@ -262,13 +268,19 @@ export class DiskStorageTier implements StorageTier { metadata.ttl = new Date(metadata.ttl); } + // Guard against directories being treated as files (causes EISDIR at read time). + const dataStat = await stat(filePath); + if (!dataStat.isFile()) { + return null; + } + // Create stream - will throw if file doesn't exist const stream = createReadStream(filePath); return { stream, metadata }; } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') { + const code = getErrnoCode(error); + if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR') { return null; } if (error instanceof SyntaxError) { @@ -311,17 +323,13 @@ export class DiskStorageTier implements StorageTier { await this.evictIfNeeded(metadata.size); } - // Write metadata first (atomic via temp file) - const tempMetaPath = `${metaPath}.tmp`; - await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2)); + // Write metadata first atomically so readers never observe partial JSON. + await this.writeMetadataAtomically(metaPath, metadata); // Stream data to file const writeStream = createWriteStream(filePath); await pipeline(stream, writeStream); - // Commit metadata - await rename(tempMetaPath, metaPath); - this.metadataIndex.set(key, { size: metadata.size, createdAt: metadata.createdAt, @@ -348,10 +356,8 @@ export class DiskStorageTier implements StorageTier { await this.evictIfNeeded(data.byteLength); } - const tempMetaPath = `${metaPath}.tmp`; - await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2)); + await this.writeMetadataAtomically(metaPath, metadata); await writeFile(filePath, data); - await rename(tempMetaPath, metaPath); this.metadataIndex.set(key, { size: data.byteLength, @@ -379,7 +385,16 @@ export class DiskStorageTier implements StorageTier { async exists(key: string): Promise { const filePath = this.getFilePath(key); - return existsSync(filePath); + try { + const fileStat = await stat(filePath); + return fileStat.isFile(); + } catch (error) { + const code = getErrnoCode(error); + if (code === 'ENOENT' || code === 'ENOTDIR') { + return false; + } + throw error; + } } async *listKeys(prefix?: string): AsyncIterableIterator { @@ -449,8 +464,8 @@ export class DiskStorageTier implements StorageTier { return metadata; } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') { + const code = getErrnoCode(error); + if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR') { return null; } if (error instanceof SyntaxError) { @@ -469,7 +484,7 @@ export class DiskStorageTier implements StorageTier { await mkdir(dir, { recursive: true }); } - await writeFile(metaPath, JSON.stringify(metadata, null, 2)); + await this.writeMetadataAtomically(metaPath, metadata); } async getStats(): Promise { @@ -543,6 +558,22 @@ export class DiskStorageTier implements StorageTier { } } + /** + * Atomically write metadata to avoid readers seeing partial JSON. + */ + private async writeMetadataAtomically(metaPath: string, metadata: StorageMetadata): Promise { + const tempMetaPath = `${metaPath}.tmp-${process.pid}-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + try { + await writeFile(tempMetaPath, JSON.stringify(metadata, null, 2)); + await rename(tempMetaPath, metaPath); + } catch (error) { + await unlink(tempMetaPath).catch(() => {}); + throw error; + } + } + /** * Get the filesystem path for a key's data file. */ -- 2.54.0