Skip to content

Commit 5e34a07

Browse files
swenzel-arcChristian Schurr
authored andcommitted
fix(storage-azure): fix azure storage mime type handling for streaming uploads (payloadcms#15949)
The fix itself is quite simple, the code was just missing the content type header when uploading via streams. I think the actual code review is more about the testing. I'm not sure if I did everything right. I've duplicated the current azure storage tests and made sure to run them with `useTempFiles: true` which causes all uploads to be streaming uploads. I've also added the test for the mime type handling to the original test suite just to be sure that it is also tested for non-streaming uploads. closes payloadcms#15948
1 parent ccc20eb commit 5e34a07

4 files changed

Lines changed: 211 additions & 0 deletions

File tree

packages/storage-azure/src/handleUpload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const getHandleUpload = ({ getStorageClient, prefix = '' }: Args): Handle
3535

3636
await blockBlobClient.uploadStream(fileBufferOrStream, 4 * 1024 * 1024, 4, {
3737
abortSignal: AbortController.timeout(30 * 60 * 1000),
38+
blobHTTPHeaders: { blobContentType: file.mimeType },
3839
})
3940

4041
return data

test/storage-azure/int.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ContainerClient } from '@azure/storage-blob'
22
import type { CollectionSlug, Payload } from 'payload'
33

44
import { BlobServiceClient } from '@azure/storage-blob'
5+
import { readFile } from 'node:fs/promises'
56
import path from 'path'
67
import { fileURLToPath } from 'url'
78
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
@@ -42,6 +43,20 @@ describe('@payloadcms/storage-azure', () => {
4243
await clearContainer()
4344
})
4445

46+
it('preserves mime type when uploaded via rest endpoint', async () => {
47+
const fileBuffer = await readFile(`${dirname}/../uploads/image.png`)
48+
49+
const data = new FormData()
50+
data.append('file', new Blob([fileBuffer], { type: 'image/png' }), 'image2.png')
51+
const newMedia: { doc: { url: string } } = await (
52+
await restClient.POST('/media', {
53+
body: data,
54+
})
55+
).json()
56+
const response = await restClient.GET(newMedia.doc.url.replace(/^\/api/, '') as `/${string}`)
57+
expect(response.headers.get('content-type')).toEqual('image/png')
58+
})
59+
4560
it('can upload', async () => {
4661
const upload = await payload.create({
4762
collection: mediaSlug,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { azureStorage } from '@payloadcms/storage-azure'
2+
import dotenv from 'dotenv'
3+
import { fileURLToPath } from 'node:url'
4+
import path from 'path'
5+
6+
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
7+
import { devUser } from '../credentials.js'
8+
import { Media } from './collections/Media.js'
9+
import { MediaWithPrefix } from './collections/MediaWithPrefix.js'
10+
import { Users } from './collections/Users.js'
11+
import { mediaSlug, mediaWithPrefixSlug, prefix } from './shared.js'
12+
const filename = fileURLToPath(import.meta.url)
13+
const dirname = path.dirname(filename)
14+
15+
let uploadOptions
16+
17+
// Load config to work with emulated services
18+
dotenv.config({
19+
path: path.resolve(dirname, '../plugin-cloud-storage/.env.emulated'),
20+
})
21+
22+
export default buildConfigWithDefaults({
23+
admin: {
24+
importMap: {
25+
baseDir: path.resolve(dirname),
26+
},
27+
},
28+
collections: [Media, MediaWithPrefix, Users],
29+
onInit: async (payload) => {
30+
await payload.create({
31+
collection: 'users',
32+
data: {
33+
email: devUser.email,
34+
password: devUser.password,
35+
},
36+
})
37+
},
38+
plugins: [
39+
azureStorage({
40+
collections: {
41+
[mediaSlug]: true,
42+
[mediaWithPrefixSlug]: {
43+
prefix,
44+
},
45+
},
46+
allowContainerCreate: process.env.AZURE_STORAGE_ALLOW_CONTAINER_CREATE === 'true',
47+
baseURL: process.env.AZURE_STORAGE_ACCOUNT_BASEURL,
48+
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
49+
containerName: process.env.AZURE_STORAGE_CONTAINER_NAME,
50+
}),
51+
],
52+
upload: {
53+
useTempFiles: true,
54+
},
55+
typescript: {
56+
outputFile: path.resolve(dirname, 'payload-types.ts'),
57+
},
58+
})
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type { ContainerClient } from '@azure/storage-blob'
2+
import type { CollectionSlug, Payload } from 'payload'
3+
4+
import { BlobServiceClient } from '@azure/storage-blob'
5+
import { readFile } from 'node:fs/promises'
6+
import path from 'path'
7+
import { fileURLToPath } from 'url'
8+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
9+
10+
import type { NextRESTClient } from '../__helpers/shared/NextRESTClient.js'
11+
12+
import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js'
13+
import { mediaSlug, mediaWithPrefixSlug, prefix } from './shared.js'
14+
15+
const filename = fileURLToPath(import.meta.url)
16+
const dirname = path.dirname(filename)
17+
18+
let restClient: NextRESTClient
19+
let payload: Payload
20+
21+
describe('@payloadcms/storage-azure streamingUploads', () => {
22+
let TEST_CONTAINER: string
23+
let client: ContainerClient
24+
25+
beforeAll(async () => {
26+
;({ payload, restClient } = await initPayloadInt(
27+
dirname,
28+
undefined,
29+
undefined,
30+
path.resolve(dirname, 'streamingUploads.config.ts'),
31+
))
32+
TEST_CONTAINER = process.env.AZURE_STORAGE_CONTAINER_NAME!
33+
34+
const blobServiceClient = BlobServiceClient.fromConnectionString(
35+
process.env.AZURE_STORAGE_CONNECTION_STRING!,
36+
)
37+
client = blobServiceClient.getContainerClient(TEST_CONTAINER)
38+
39+
await client.createIfNotExists()
40+
await clearContainer()
41+
}, 90000)
42+
43+
afterAll(async () => {
44+
await payload.destroy()
45+
})
46+
47+
afterEach(async () => {
48+
await clearContainer()
49+
})
50+
51+
it('preserves mime type when uploaded via rest endpoint', async () => {
52+
const fileBuffer = await readFile(path.resolve(dirname, '../uploads/image.png'))
53+
54+
const data = new FormData()
55+
data.append('file', new Blob([fileBuffer], { type: 'image/png' }), 'image1.png')
56+
const newMedia: { doc: { url: string } } = await (
57+
await restClient.POST('/media', {
58+
body: data,
59+
})
60+
).json()
61+
const response = await restClient.GET(newMedia.doc.url.replace(/^\/api/, '') as `/${string}`)
62+
expect(response.headers.get('content-type')).toEqual('image/png')
63+
})
64+
65+
it('can upload', async () => {
66+
const upload = await payload.create({
67+
collection: mediaSlug,
68+
data: {},
69+
filePath: path.resolve(dirname, '../uploads/image.png'),
70+
})
71+
72+
expect(upload.id).toBeTruthy()
73+
await verifyUploads({ collectionSlug: mediaSlug, uploadId: upload.id })
74+
expect(upload.url).toEqual(`/api/${mediaSlug}/file/${String(upload.filename)}`)
75+
})
76+
77+
it('can upload with prefix', async () => {
78+
const upload = await payload.create({
79+
collection: mediaWithPrefixSlug,
80+
data: {},
81+
filePath: path.resolve(dirname, '../uploads/image.png'),
82+
})
83+
84+
expect(upload.id).toBeTruthy()
85+
await verifyUploads({
86+
collectionSlug: mediaWithPrefixSlug,
87+
uploadId: upload.id,
88+
prefix,
89+
})
90+
expect(upload.url).toEqual(`/api/${mediaWithPrefixSlug}/file/${String(upload.filename)}`)
91+
})
92+
93+
it('returns 404 for non-existing file', async () => {
94+
const response = await restClient.GET(`/${mediaSlug}/file/nonexistent.png`)
95+
expect(response.status).toBe(404)
96+
})
97+
98+
async function clearContainer() {
99+
for await (const blob of client.listBlobsFlat()) {
100+
await client.deleteBlob(blob.name)
101+
}
102+
}
103+
104+
async function verifyUploads({
105+
collectionSlug,
106+
uploadId,
107+
prefix = '',
108+
}: {
109+
collectionSlug: CollectionSlug
110+
prefix?: string
111+
uploadId: number | string
112+
}) {
113+
const uploadData = (await payload.findByID({
114+
collection: collectionSlug,
115+
id: uploadId,
116+
})) as unknown as { filename: string; sizes: Record<string, { filename: string }> }
117+
118+
const fileKeys = Object.keys(uploadData.sizes || {}).map((key) => {
119+
const rawFilename = uploadData.sizes[key].filename
120+
return prefix ? `${prefix}/${rawFilename}` : rawFilename
121+
})
122+
123+
fileKeys.push(`${prefix ? `${prefix}/` : ''}${uploadData.filename}`)
124+
125+
for (const key of fileKeys) {
126+
const blobClient = client.getBlobClient(key)
127+
try {
128+
const props = await blobClient.getProperties()
129+
expect(props).toBeDefined()
130+
expect(props.contentLength).toBeGreaterThan(0)
131+
} catch (error) {
132+
console.error('Error verifying uploads:', key, error)
133+
throw error
134+
}
135+
}
136+
}
137+
})

0 commit comments

Comments
 (0)