Skip to content

Commit e777e9d

Browse files
authored
S3 browse demo (#238)
* working s3 browser demo * parse breadcrumbs for file view also * remove pointless throw * fix lint * undo lint fix * fix build * fix lint and test
1 parent 8172713 commit e777e9d

File tree

2 files changed

+125
-5
lines changed

2 files changed

+125
-5
lines changed

src/components/File/File.test.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,16 @@ describe('File Component', () => {
4444
const source = getHttpSource(url)
4545
assert(source?.kind === 'file')
4646

47-
const { getByText } = await act(() => render(<File source={source} />))
47+
const { getAllByRole } = await act(() => render(
48+
<ConfigProvider value={config}>
49+
<File source={source} />
50+
</ConfigProvider>
51+
))
4852

49-
expect(getByText(url)).toBeDefined()
53+
const links = getAllByRole('link')
54+
expect(links[0]?.getAttribute('href')).toBe('/')
55+
expect(links[1]?.getAttribute('href')).toBe('/files?key=https://example.com/')
56+
expect(links[2]?.getAttribute('href')).toBe('/files?key=https://example.com/test.txt')
5057
})
5158

5259
it('renders correct breadcrumbs for nested folders', async () => {

src/lib/sources/httpSource.ts

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,127 @@
1-
import { FileSource } from './types.js'
1+
import { DirSource, FileMetadata, FileSource, SourcePart } from './types.js'
22
import { getFileName } from './utils.js'
33

4-
export function getHttpSource(sourceId: string, options?: {requestInit?: RequestInit}): FileSource | undefined {
4+
function s3list(bucket: string, prefix: string) {
5+
const url = `https://${bucket}.s3.amazonaws.com/?list-type=2&prefix=${prefix}&delimiter=/`
6+
return fetch(url)
7+
.then(res => {
8+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
9+
return res.text()
10+
})
11+
.then(text => {
12+
const results = []
13+
14+
// Parse regular objects (files and explicit directories)
15+
const contentsRegex = /<Contents>(.*?)<\/Contents>/gs
16+
const contentsMatches = text.match(contentsRegex) ?? []
17+
18+
for (const match of contentsMatches) {
19+
const keyMatch = /<Key>(.*?)<\/Key>/.exec(match)
20+
const lastModifiedMatch = /<LastModified>(.*?)<\/LastModified>/.exec(match)
21+
const sizeMatch = /<Size>(.*?)<\/Size>/.exec(match)
22+
const eTagMatch = /<ETag>&quot;(.*?)&quot;<\/ETag>/.exec(match) ?? /<ETag>"(.*?)"<\/ETag>/.exec(match)
23+
24+
if (!keyMatch || !lastModifiedMatch) continue
25+
26+
const key = keyMatch[1]
27+
const lastModified = lastModifiedMatch[1]
28+
const size = sizeMatch ? parseInt(sizeMatch[1] ?? '', 10) : undefined
29+
const eTag = eTagMatch ? eTagMatch[1] : undefined
30+
31+
results.push({ key, lastModified, size, eTag })
32+
}
33+
34+
// Parse CommonPrefixes (virtual directories)
35+
const prefixRegex = /<CommonPrefixes>(.*?)<\/CommonPrefixes>/gs
36+
const prefixMatches = text.match(prefixRegex) ?? []
37+
38+
for (const match of prefixMatches) {
39+
const prefixMatch = /<Prefix>(.*?)<\/Prefix>/.exec(match)
40+
if (!prefixMatch) continue
41+
42+
const key = prefixMatch[1]
43+
results.push({
44+
key,
45+
lastModified: new Date().toISOString(), // No lastModified for CommonPrefixes
46+
size: 0,
47+
isCommonPrefix: true,
48+
})
49+
}
50+
51+
return results
52+
})
53+
}
54+
55+
function getSourceParts(sourceId: string): SourcePart[] {
56+
const [protocol, rest] = sourceId.split('://', 2)
57+
const parts = rest
58+
? [`${protocol}://${rest.split('/', 1)[0]}`, ...rest.split('/').slice(1)]
59+
: sourceId.split('/')
60+
const sourceParts = [
61+
...parts.map((part, depth) => {
62+
const slashSuffix = depth === parts.length - 1 ? '' : '/'
63+
return {
64+
text: part + slashSuffix,
65+
sourceId: parts.slice(0, depth + 1).join('/') + slashSuffix,
66+
}
67+
}),
68+
]
69+
if (sourceParts[sourceParts.length - 1]?.text === '') {
70+
sourceParts.pop()
71+
}
72+
return sourceParts
73+
}
74+
75+
export function getHttpSource(sourceId: string, options?: {requestInit?: RequestInit}): FileSource | DirSource | undefined {
576
if (!URL.canParse(sourceId)) {
677
return undefined
778
}
79+
80+
const sourceParts = getSourceParts(sourceId)
81+
82+
if (sourceId.endsWith('/')) {
83+
const url = new URL(sourceId)
84+
const bucket = url.hostname.split('.')[0]
85+
const prefix = url.pathname.slice(1)
86+
87+
if (!bucket) {
88+
return undefined
89+
}
90+
91+
return {
92+
kind: 'directory',
93+
sourceId,
94+
sourceParts,
95+
prefix,
96+
listFiles: () => s3list(bucket, prefix).then(items =>
97+
items
98+
.filter(item => item.key !== undefined)
99+
.map(item => {
100+
if (!item.key) {
101+
throw new Error('Key is undefined')
102+
}
103+
const isDirectory = item.key.endsWith('/')
104+
const itemSourceId = `https://${bucket}.s3.amazonaws.com/${item.key}`
105+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
106+
let name = item.key.split('/').pop() || item.key
107+
if (name && isDirectory) {
108+
name = name.replace(prefix, '')
109+
}
110+
return {
111+
name,
112+
lastModified: item.lastModified,
113+
sourceId: itemSourceId,
114+
kind: isDirectory ? 'directory' : 'file',
115+
} as FileMetadata
116+
})
117+
),
118+
} as DirSource
119+
}
120+
8121
return {
9122
kind: 'file',
10123
sourceId,
11-
sourceParts: [{ text: sourceId, sourceId }],
124+
sourceParts,
12125
fileName: getFileName(sourceId),
13126
resolveUrl: sourceId,
14127
requestInit: options?.requestInit,

0 commit comments

Comments
 (0)