Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/core/storage-js/src/StorageClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,25 @@ import { StorageVectorsClient } from './packages/StorageVectorsClient'

export interface StorageClientOptions {
useNewHostname?: boolean
/**
* Folder prepended to every object path, in every bucket. With `pathPrefix: 's07'`,
* `upload('a.png')` stores `s07/a.png`. Lets several clients share the same buckets
* without overwriting each other's files.
*
* Paths you pass in are relative to the prefix, and so are the paths and names the
* client returns, so they can be passed back as-is. `fullPath`, and the `path` returned
* by `copy`, keep the real `bucket/prefix/...` key.
*
* Bucket-level operations (`emptyBucket`, `deleteBucket`, `purgeBucketCache`) are not
* scoped to the prefix. The prefix is not an access boundary: use RLS policies on
* `storage.objects` to restrict access.
*/
pathPrefix?: string
}

export class StorageClient extends StorageBucketApi {
protected pathPrefix: string | undefined

/**
* Creates a client for Storage buckets, files, analytics, and vectors.
*
Expand Down Expand Up @@ -40,6 +56,7 @@ export class StorageClient extends StorageBucketApi {
opts?: StorageClientOptions
) {
super(url, headers, fetch, opts)
this.pathPrefix = opts?.pathPrefix
}

/**
Expand All @@ -56,7 +73,7 @@ export class StorageClient extends StorageBucketApi {
* ```
*/
from(id: string): StorageFileApi {
return new StorageFileApi(this.url, this.headers, id, this.fetch)
return new StorageFileApi(this.url, this.headers, id, this.fetch, this.pathPrefix)
}

/**
Expand Down
75 changes: 63 additions & 12 deletions packages/core/storage-js/src/packages/StorageFileApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,18 @@ type FileBody =

export default class StorageFileApi extends BaseApiClient<StorageError> {
protected bucketId: string | undefined
protected pathPrefix: string | undefined

constructor(
url: string,
headers: { [key: string]: string } = {},
bucketId?: string,
fetch?: Fetch
fetch?: Fetch,
pathPrefix?: string
) {
super(url, headers, fetch, 'storage')
this.bucketId = bucketId
this.pathPrefix = pathPrefix?.replace(/^\/+|\/+$/g, '') || undefined
}

/**
Expand Down Expand Up @@ -540,8 +543,8 @@ export default class StorageFileApi extends BaseApiClient<StorageError> {
`${this.url}/object/move`,
{
bucketId: this.bucketId,
sourceKey: fromPath,
destinationKey: toPath,
sourceKey: this._prefixPath(fromPath),
destinationKey: this._prefixPath(toPath),
destinationBucket: options?.destinationBucket,
sourceVersionId: options?.sourceVersionId,
},
Expand Down Expand Up @@ -620,8 +623,8 @@ export default class StorageFileApi extends BaseApiClient<StorageError> {
`${this.url}/object/copy`,
{
bucketId: this.bucketId,
sourceKey: fromPath,
destinationKey: toPath,
sourceKey: this._prefixPath(fromPath),
destinationKey: this._prefixPath(toPath),
destinationBucket: options?.destinationBucket,
sourceVersionId: options?.sourceVersionId,
copyMetadata:
Expand Down Expand Up @@ -817,7 +820,7 @@ export default class StorageFileApi extends BaseApiClient<StorageError> {
const data = await post(
this.fetch,
`${this.url}/object/sign/${this.bucketId}`,
{ expiresIn, paths },
{ expiresIn, paths: paths.map((path) => this._prefixPath(path)) },
{ headers: this.headers }
)

Expand All @@ -832,6 +835,7 @@ export default class StorageFileApi extends BaseApiClient<StorageError> {
return data.map(
(datum: { error: string | null; path: string | null; signedURL: string | null }) => ({
...datum,
path: this._unprefixPath(datum.path),
signedUrl: datum.signedURL
? encodeURI(`${this.url}${datum.signedURL}${queryString ? `&${queryString}` : ''}`)
: null,
Expand Down Expand Up @@ -986,7 +990,8 @@ export default class StorageFileApi extends BaseApiClient<StorageError> {
}
)

return recursiveToCamel(data) as Camelize<FileObjectV2>
const info = recursiveToCamel(data) as Camelize<FileObjectV2>
return this.pathPrefix ? this._unprefixEntry(info) : info
})
}

Expand Down Expand Up @@ -1189,12 +1194,20 @@ export default class StorageFileApi extends BaseApiClient<StorageError> {
}
> {
return this.handleOperation(async () => {
return await remove(
const prefixes = paths.map((entry) =>
typeof entry === 'string'
? this._prefixPath(entry)
: { ...entry, path: this._prefixPath(entry.path) }
)
const data: FileObject[] = await remove(
this.fetch,
`${this.url}/object/${this.bucketId}`,
{ prefixes: paths },
{ prefixes },
{ headers: this.headers }
)
return this.pathPrefix && Array.isArray(data)
? data.map((object) => this._unprefixEntry(object))
: data
})
}

Expand Down Expand Up @@ -1436,7 +1449,12 @@ export default class StorageFileApi extends BaseApiClient<StorageError> {
const sortBy = options?.sortBy
? { ...DEFAULT_SEARCH_OPTIONS.sortBy, ...options.sortBy }
: DEFAULT_SEARCH_OPTIONS.sortBy
const body = { ...DEFAULT_SEARCH_OPTIONS, ...options, sortBy, prefix: path || '' }
const body = {
...DEFAULT_SEARCH_OPTIONS,
...options,
sortBy,
prefix: this._prefixPath(path || ''),
}
return await post(
this.fetch,
`${this.url}/object/list/${this.bucketId}`,
Expand Down Expand Up @@ -1509,13 +1527,24 @@ export default class StorageFileApi extends BaseApiClient<StorageError> {
> {
return this.handleOperation(async () => {
const body = { ...options }
return await post(
if (this.pathPrefix) {
body.prefix = this._prefixPath(options?.prefix ?? '')
}
const data: SearchV2Result = await post(
this.fetch,
`${this.url}/object/list-v2/${this.bucketId}`,
body,
{ headers: this.headers },
parameters
)
if (!this.pathPrefix) {
return data
}
return {
...data,
folders: data.folders?.map((folder) => this._unprefixEntry(folder)),
objects: data.objects?.map((object) => this._unprefixEntry(object)),
}
})
}

Expand Down Expand Up @@ -1563,7 +1592,29 @@ export default class StorageFileApi extends BaseApiClient<StorageError> {
}

private _getFinalPath(path: string) {
return `${this.bucketId}/${path.replace(/^\/+/, '')}`
return `${this.bucketId}/${this._prefixPath(path).replace(/^\/+/, '')}`
}

/** Prepends `pathPrefix` to an object path. Returns the path unchanged when there is no prefix. */
private _prefixPath(path: string) {
return this.pathPrefix ? `${this.pathPrefix}/${path.replace(/^\/+/, '')}` : path
}

/** Strips `pathPrefix` from an object path returned by the server. */
private _unprefixPath<T extends string | null | undefined>(path: T): T {
const prefix = `${this.pathPrefix}/`
if (!this.pathPrefix || typeof path !== 'string' || !path.startsWith(prefix)) {
return path
}
return path.slice(prefix.length) as T
}

/** Strips `pathPrefix` from the `name` and `key` of an object or folder returned by the server. */
private _unprefixEntry<T extends { name?: string; key?: string }>(entry: T): T {
const result: { name?: string; key?: string } = { ...entry }
if (typeof entry.name === 'string') result.name = this._unprefixPath(entry.name)
if (typeof entry.key === 'string') result.key = this._unprefixPath(entry.key)
return result as T
}

private _removeEmptyFolders(path: string) {
Expand Down
172 changes: 172 additions & 0 deletions packages/core/storage-js/test/storageFileApiPathPrefix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { StorageClient } from '../src/index'

describe('pathPrefix', () => {
let fetchMock: jest.MockedFunction<typeof fetch>

function respondWith(body: unknown) {
fetchMock.mockResolvedValue(
new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } })
)
}

function files(pathPrefix?: string) {
return new StorageClient('http://localhost/storage/v1', {}, fetchMock, {
...(pathPrefix !== undefined && { pathPrefix }),
}).from('bucket')
}

function request() {
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]
return new Request(url, init)
}

beforeEach(() => {
fetchMock = jest.fn()
respondWith({})
})

test('upload stores the object under the prefix and returns the relative path', async () => {
respondWith({ Id: 'id', Key: 'bucket/s07/folder/a.txt' })
const { data, error } = await files('s07').upload('/folder/a.txt', 'hello')

expect(error).toBeNull()
expect(request().url).toBe('http://localhost/storage/v1/object/bucket/s07/folder/a.txt')
expect(data).toEqual({ id: 'id', path: 'folder/a.txt', fullPath: 'bucket/s07/folder/a.txt' })
})

test('surrounding slashes in the prefix are ignored', async () => {
respondWith({ Id: 'id', Key: 'bucket/s07/a.txt' })
await files('/s07/').upload('a.txt', 'hello')
expect(request().url).toBe('http://localhost/storage/v1/object/bucket/s07/a.txt')
})

test('an empty prefix behaves like no prefix', async () => {
respondWith({ Id: 'id', Key: 'bucket/a.txt' })
await files('').upload('a.txt', 'hello')
expect(request().url).toBe('http://localhost/storage/v1/object/bucket/a.txt')
})

test('getPublicUrl includes the prefix', () => {
const { data } = files('s07').getPublicUrl('a.png')
expect(data.publicUrl).toBe('http://localhost/storage/v1/object/public/bucket/s07/a.png')
})

test('createSignedUrl signs the prefixed path', async () => {
respondWith({ signedURL: '/object/sign/bucket/s07/a.png?token=t' })
await files('s07').createSignedUrl('a.png', 60)
expect(request().url).toBe('http://localhost/storage/v1/object/sign/bucket/s07/a.png')
})

test('createSignedUploadUrl and uploadToSignedUrl use the prefixed path', async () => {
respondWith({ url: '/object/upload/sign/bucket/s07/a.png?token=t' })
const signed = await files('s07').createSignedUploadUrl('a.png')
expect(request().url).toBe('http://localhost/storage/v1/object/upload/sign/bucket/s07/a.png')
expect(signed.data?.path).toBe('a.png')

fetchMock.mockClear()
respondWith({ Key: 'bucket/s07/a.png' })
const uploaded = await files('s07').uploadToSignedUrl('a.png', 't', 'hello')
expect(request().url).toBe(
'http://localhost/storage/v1/object/upload/sign/bucket/s07/a.png?token=t'
)
expect(uploaded.data).toEqual({ path: 'a.png', fullPath: 'bucket/s07/a.png' })
})

test('download and info read the prefixed path and info returns the relative name', async () => {
respondWith({ name: 's07/a.png', bucket_id: 'bucket' })
const { data } = await files('s07').info('a.png')
expect(request().url).toBe('http://localhost/storage/v1/object/info/bucket/s07/a.png')
expect(data).toMatchObject({ name: 'a.png', bucketId: 'bucket' })

fetchMock.mockClear()
fetchMock.mockResolvedValue(new Response('file'))
await files('s07').download('a.png')
expect(request().url).toBe('http://localhost/storage/v1/object/bucket/s07/a.png')
})

test.each(['copy', 'move'] as const)('%s prefixes source and destination', async (operation) => {
respondWith({ Key: 'bucket/s07/b.txt', message: 'ok' })
await files('s07')[operation]('a.txt', 'b.txt')
expect(await request().json()).toEqual({
bucketId: 'bucket',
sourceKey: 's07/a.txt',
destinationKey: 's07/b.txt',
})
})

test('createSignedUrls prefixes the request paths and strips them from the response', async () => {
respondWith([
{ path: 's07/a.png', signedURL: '/object/sign/bucket/s07/a.png?token=t', error: null },
{ path: 's07/b.png', signedURL: null, error: 'Either the object does not exist' },
])
const { data } = await files('s07').createSignedUrls(['a.png', 'b.png'], 60)

expect(await request().json()).toEqual({ expiresIn: 60, paths: ['s07/a.png', 's07/b.png'] })
expect(data?.map((d) => d.path)).toEqual(['a.png', 'b.png'])
expect(data?.[0].signedUrl).toBe(
'http://localhost/storage/v1/object/sign/bucket/s07/a.png?token=t'
)
})

test('remove prefixes every entry and strips names from the response', async () => {
respondWith([{ name: 's07/a.png' }, { name: 's07/b.png' }])
const { data } = await files('s07').remove(['a.png', { path: 'b.png', versionId: 'v1' }])

expect(await request().json()).toEqual({
prefixes: ['s07/a.png', { path: 's07/b.png', versionId: 'v1' }],
})
expect(data?.map((d) => d.name)).toEqual(['a.png', 'b.png'])
})

test.each([
[undefined, 's07/'],
['', 's07/'],
['folder', 's07/folder'],
])('list(%j) searches under %j', async (path, prefix) => {
respondWith([])
await files('s07').list(path)
expect((await request().json()).prefix).toBe(prefix)
})

test('listV2 scopes the prefix and strips it from keys and names', async () => {
respondWith({
hasNext: false,
folders: [{ name: 's07/folder/', key: 's07/folder/' }],
objects: [{ name: 's07/a.png', key: 's07/a.png', id: 'id' }],
})
const { data } = await files('s07').listV2({ with_delimiter: true })

expect(await request().json()).toEqual({ with_delimiter: true, prefix: 's07/' })
expect(data).toEqual({
hasNext: false,
folders: [{ name: 'folder/', key: 'folder/' }],
objects: [{ name: 'a.png', key: 'a.png', id: 'id' }],
})
})

test('listV2 appends a caller prefix to the path prefix', async () => {
respondWith({ hasNext: false, folders: [], objects: [] })
await files('s07').listV2({ prefix: 'folder/' })
expect((await request().json()).prefix).toBe('s07/folder/')
})

test('purgeCache purges the prefixed path', async () => {
respondWith({ message: 'ok' })
await files('s07').purgeCache('a.png')
expect(request().url).toBe('http://localhost/storage/v1/cdn/bucket/s07/a.png')
})

test('without a prefix, requests and responses are unchanged', async () => {
respondWith([{ name: 's07/a.png' }])
const { data } = await files().remove(['s07/a.png'])
expect(await request().json()).toEqual({ prefixes: ['s07/a.png'] })
expect(data?.[0].name).toBe('s07/a.png')

fetchMock.mockClear()
respondWith({ hasNext: false, folders: [], objects: [{ name: 's07/a.png' }] })
const listed = await files().listV2()
expect(await request().json()).toEqual({})
expect(listed.data?.objects[0].name).toBe('s07/a.png')
})
})
Loading
Loading