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

Configure Feed

Select the types of activity you want to include in your feed.

fix folders ending with .extension being treated as files then 404ing

+133 -18
+117
apps/hosting-service/src/lib/file-serving.integration.test.ts
··· 1 + import { beforeEach, describe, expect, mock, test } from 'bun:test' 2 + 3 + // Fake storage shared across tests; reset in beforeEach 4 + type FakeEntry = { data: Uint8Array; mimeType?: string; encoding?: string; checksum?: string } 5 + const storageData = new Map<string, FakeEntry>() 6 + 7 + const fakeStorage = { 8 + async get(key: string) { 9 + const entry = storageData.get(key) 10 + return entry?.data ?? null 11 + }, 12 + async getWithMetadata(key: string) { 13 + const entry = storageData.get(key) 14 + if (!entry) return null 15 + return { 16 + data: entry.data, 17 + metadata: { 18 + key, 19 + size: entry.data.length, 20 + createdAt: new Date(), 21 + lastAccessed: new Date(), 22 + accessCount: 0, 23 + checksum: entry.checksum ?? 'test-checksum', 24 + customMetadata: { mimeType: entry.mimeType, encoding: entry.encoding }, 25 + }, 26 + source: 'cold' as const, 27 + } 28 + }, 29 + async *listKeys(_prefix?: string): AsyncGenerator<string> {}, 30 + } 31 + 32 + const noopTier = { 33 + async get() { 34 + return null 35 + }, 36 + async getWithMetadata() { 37 + return null 38 + }, 39 + async set() {}, 40 + async delete() {}, 41 + async deleteMany() {}, 42 + async exists() { 43 + return false 44 + }, 45 + async *listKeys() {}, 46 + async getMetadata() { 47 + return null 48 + }, 49 + async setMetadata() {}, 50 + async getStats() { 51 + return { entries: 0, sizeBytes: 0 } 52 + }, 53 + async clear() {}, 54 + } 55 + 56 + mock.module('./storage', () => ({ 57 + storage: fakeStorage, 58 + hotTier: noopTier, 59 + warmTier: undefined, 60 + getStorageConfig: () => ({}), 61 + })) 62 + mock.module('./db', () => ({ 63 + getSiteCache: async () => null, 64 + getSiteSettingsCache: async () => null, 65 + CACHE_ONLY: true, 66 + })) 67 + mock.module('./utils', () => ({ 68 + getCachedSettings: async () => null, 69 + })) 70 + mock.module('./on-demand-cache', () => ({ 71 + fetchAndCacheSite: async () => false, 72 + })) 73 + 74 + const { serveFileInternal } = await import('./file-serving') 75 + 76 + const DID = 'did:plc:test' 77 + const RKEY = 'hydrant-docs' 78 + 79 + function storeFile(path: string, body: string, mimeType = 'text/html') { 80 + storageData.set(`${DID}/${RKEY}/${path}`, { 81 + data: new TextEncoder().encode(body), 82 + mimeType, 83 + }) 84 + } 85 + 86 + describe('serveFileInternal directory-index fallback for extensioned paths', () => { 87 + beforeEach(() => { 88 + storageData.clear() 89 + }) 90 + 91 + test('serves index.html when requested path with .md extension is actually a directory', async () => { 92 + // Astro emits concepts/relay.md/ as a directory containing index.html 93 + storeFile('concepts/relay.md/index.html', '<html>relay docs</html>') 94 + 95 + const response = await serveFileInternal(DID, RKEY, 'concepts/relay.md') 96 + 97 + expect(response.status).toBe(200) 98 + expect(response.headers.get('Content-Type')).toContain('text/html') 99 + expect(await response.text()).toBe('<html>relay docs</html>') 100 + }) 101 + 102 + test('still serves a direct file when both file and directory-style entry would match', async () => { 103 + // If the file exists directly, it wins over the directory-index fallback 104 + storeFile('concepts/relay.md', 'raw markdown', 'text/markdown') 105 + 106 + const response = await serveFileInternal(DID, RKEY, 'concepts/relay.md') 107 + 108 + expect(response.status).toBe(200) 109 + expect(await response.text()).toBe('raw markdown') 110 + }) 111 + 112 + test('returns 404 when neither the file nor an index under it exists', async () => { 113 + const response = await serveFileInternal(DID, RKEY, 'concepts/missing.md') 114 + 115 + expect(response.status).toBe(404) 116 + }) 117 + })
+16 -18
apps/hosting-service/src/lib/file-serving.ts
··· 520 520 } 521 521 await markExpectedMiss(fileRequestPath) 522 522 523 - // Try index files for directory-like paths 524 - if (!hasFileExtension(fileRequestPath)) { 525 - for (const indexFileName of indexFiles) { 526 - const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName 527 - const indexResult = await span(trace, `storage:${indexPath}`, () => getFileWithMetadata(did, rkey, indexPath)) 528 - if (indexResult) { 529 - return buildResponseFromStorageResult(indexResult, indexPath, settings, requestHeaders) 530 - } 531 - await markExpectedMiss(indexPath) 523 + // Try index files for directory-like paths (even with extensions, 524 + // e.g. Astro emits `relay.md/index.html` for .md routes) 525 + for (const indexFileName of indexFiles) { 526 + const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName 527 + const indexResult = await span(trace, `storage:${indexPath}`, () => getFileWithMetadata(did, rkey, indexPath)) 528 + if (indexResult) { 529 + return buildResponseFromStorageResult(indexResult, indexPath, settings, requestHeaders) 532 530 } 531 + await markExpectedMiss(indexPath) 533 532 } 534 533 535 534 // Try clean URLs: /about -> /about.html ··· 869 868 } 870 869 await markExpectedMiss(fileRequestPath) 871 870 872 - // Try index files for directory-like paths 873 - if (!hasFileExtension(fileRequestPath)) { 874 - for (const indexFileName of indexFiles) { 875 - const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName 876 - const indexResult = await span(trace, `storage:${indexPath}`, () => getFileForRequest(did, rkey, indexPath, true)) 877 - if (indexResult) { 878 - return await buildResponse(indexResult) 879 - } 880 - await markExpectedMiss(indexPath) 871 + // Try index files for directory-like paths (even with extensions, 872 + // e.g. Astro emits `relay.md/index.html` for .md routes) 873 + for (const indexFileName of indexFiles) { 874 + const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName 875 + const indexResult = await span(trace, `storage:${indexPath}`, () => getFileForRequest(did, rkey, indexPath, true)) 876 + if (indexResult) { 877 + return await buildResponse(indexResult) 881 878 } 879 + await markExpectedMiss(indexPath) 882 880 } 883 881 884 882 // Try clean URLs: /about -> /about.html