Skip to content
Open
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
42 changes: 38 additions & 4 deletions packages/core/storage-js/src/packages/StorageFileApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,26 +265,60 @@ export default class StorageFileApi extends BaseApiClient<StorageError> {

return this.handleOperation(async () => {
let body
const options = { ...DEFAULT_FILE_OPTIONS, ...fileOptions }
const headers: Record<string, string> = {
const options = {
...DEFAULT_FILE_OPTIONS,
...fileOptions,
}
let headers: Record<string, string> = {
...this.headers,
...{ 'x-upsert': String(options.upsert as boolean) },
}

const metadata = options.metadata

if (typeof Blob !== 'undefined' && fileBody instanceof Blob) {
body = new FormData()
body.append('cacheControl', options.cacheControl as string)
if (metadata) {
body.append('metadata', this.encodeMetadata(metadata))
}
body.append('', fileBody)
} else if (typeof FormData !== 'undefined' && fileBody instanceof FormData) {
body = fileBody
body.append('cacheControl', options.cacheControl as string)
if (!body.has('cacheControl')) {
body.append('cacheControl', options.cacheControl as string)
}
if (metadata && !body.has('metadata')) {
body.append('metadata', this.encodeMetadata(metadata))
}
} else {
body = fileBody
headers['cache-control'] = `max-age=${options.cacheControl}`
headers['content-type'] = options.contentType as string

if (metadata) {
headers['x-metadata'] = this.toBase64(this.encodeMetadata(metadata))
}

// Node.js streams require duplex option for fetch in Node 20+
// Check for both web ReadableStream and Node.js streams
const isStream =
(typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) ||
(body && typeof body === 'object' && 'pipe' in body && typeof body.pipe === 'function')

if (isStream && !options.duplex) {
options.duplex = 'half'
}
}

if (fileOptions?.headers) {
headers = { ...headers, ...fileOptions.headers }
}
Comment on lines +314 to 316
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't directly relevant to this PR since it's coming from existing code but combined with #2211 (comment) this can be a footgun because casing can be different


const data = await put(this.fetch, url.toString(), body as object, { headers })
const data = await put(this.fetch, url.toString(), body as object, {
headers,
...(options?.duplex ? { duplex: options.duplex } : {}),
})

return { path: cleanPath, fullPath: data.Key }
})
Expand Down
46 changes: 46 additions & 0 deletions packages/core/storage-js/test/storageFileApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,52 @@ describe('StorageFileApi Edge Cases', () => {
expect(body.get('cacheControl')).toBe('7200')
})

test('uploadToSignedUrl with Blob includes metadata in FormData', async () => {
const testBlob = new Blob(['test content'], { type: 'text/plain' })
const metadata = { customKey: 'customValue', author: 'test' }

await storage
.from('test-bucket')
.uploadToSignedUrl('test-path', 'test-token', testBlob, { metadata })

expect(mockPut).toHaveBeenCalled()
const [, , body] = mockPut.mock.calls[0] as [null, null, FormData]
expect(body.constructor.name).toBe('FormData')
expect(body.get('metadata')).toBe(JSON.stringify(metadata))
})

test('uploadToSignedUrl with FormData includes metadata', async () => {
const testFormData = new FormData()
testFormData.append('file', 'test content')
const metadata = { customKey: 'customValue' }

await storage
.from('test-bucket')
.uploadToSignedUrl('test-path', 'test-token', testFormData, { metadata })

expect(mockPut).toHaveBeenCalled()
const [, , body] = mockPut.mock.calls[0] as [null, null, FormData]
expect(body).toBe(testFormData)
expect(body.get('metadata')).toBe(JSON.stringify(metadata))
})

test('uploadToSignedUrl with raw body includes metadata in headers', async () => {
const testBody = 'raw string content'
const metadata = { customKey: 'customValue' }

await storage
.from('test-bucket')
.uploadToSignedUrl('test-path', 'test-token', testBody, { metadata })

expect(mockPut).toHaveBeenCalled()
const [, , , { headers }] = mockPut.mock.calls[0]
const expectedBase64 =
typeof Buffer !== 'undefined'
? Buffer.from(JSON.stringify(metadata)).toString('base64')
: btoa(JSON.stringify(metadata))
expect(headers['x-metadata']).toBe(expectedBase64)
})

test('upload with metadata', async () => {
const testBlob = new Blob(['test content'], { type: 'text/plain' })
const metadata = { customKey: 'customValue', author: 'test' }
Expand Down
Loading