fix: correctly detect glb & gltf mimetypes during upload (#12623)

### What?

The browser was incorrectly setting the mimetype for `.glb` and `.gltf`
files to `application/octet-stream` when uploading when they should be
receiving proper types consistent with `glb` and `gltf`.

This patch adds logic to infer the correct `MIME` type for `.glb` files
(`model/gltf-binary`) & `gltf` files (`model/gltf+json`) based on file
extension during multipart processing, ensuring consistent MIME type
detection regardless of browser behavior.

Fixes #12620
This commit is contained in:
Patrik
2025-06-02 14:26:26 -04:00
committed by GitHub
parent 08a6f88a4b
commit 05eeddba7c
6 changed files with 140 additions and 29 deletions

View File

@@ -66,6 +66,11 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
const { encoding, filename: name, mimeType: mime } = info const { encoding, filename: name, mimeType: mime } = info
const filename = parseFileName(options, name) const filename = parseFileName(options, name)
const inferredMimeType =
(filename && filename.endsWith('.glb') && 'model/gltf-binary') ||
(filename && filename.endsWith('.gltf') && 'model/gltf+json') ||
mime
// Define methods and handlers for upload process. // Define methods and handlers for upload process.
const { cleanup, complete, dataHandler, getFilePath, getFileSize, getHash, getWritePromise } = const { cleanup, complete, dataHandler, getFilePath, getFileSize, getHash, getWritePromise } =
options.useTempFiles options.useTempFiles
@@ -137,7 +142,7 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
buffer: complete(), buffer: complete(),
encoding, encoding,
hash: getHash(), hash: getHash(),
mimetype: mime, mimetype: inferredMimeType,
size, size,
tempFilePath: getFilePath(), tempFilePath: getFilePath(),
truncated: Boolean('truncated' in file && file.truncated) || false, truncated: Boolean('truncated' in file && file.truncated) || false,

View File

@@ -1,4 +1,6 @@
import type { CollectionSlug } from 'payload' /* eslint-disable no-restricted-exports */
import type { CollectionSlug, File } from 'payload'
import path from 'path' import path from 'path'
import { getFileByPath } from 'payload' import { getFileByPath } from 'payload'
@@ -28,6 +30,7 @@ import {
reduceSlug, reduceSlug,
relationPreviewSlug, relationPreviewSlug,
relationSlug, relationSlug,
threeDimensionalSlug,
unstoredMediaSlug, unstoredMediaSlug,
versionSlug, versionSlug,
withoutEnlargeSlug, withoutEnlargeSlug,
@@ -797,6 +800,14 @@ export default buildConfigWithDefaults({
}, },
], ],
}, },
{
slug: threeDimensionalSlug,
fields: [],
upload: {
crop: false,
focalPoint: false,
},
},
], ],
onInit: async (payload) => { onInit: async (payload) => {
const uploadsDir = path.resolve(dirname, './media') const uploadsDir = path.resolve(dirname, './media')
@@ -893,39 +904,39 @@ export default buildConfigWithDefaults({
// Create admin thumbnail media // Create admin thumbnail media
await payload.create({ await payload.create({
collection: AdminThumbnailSize.slug, collection: AdminThumbnailSize.slug as CollectionSlug,
data: {}, data: {},
file: { file: {
...audioFile, ...audioFile,
name: 'audio-thumbnail.mp3', // Override to avoid conflicts name: 'audio-thumbnail.mp3', // Override to avoid conflicts
}, } as File,
}) })
await payload.create({ await payload.create({
collection: AdminThumbnailSize.slug, collection: AdminThumbnailSize.slug as CollectionSlug,
data: {}, data: {},
file: { file: {
...imageFile, ...imageFile,
name: `thumb-${imageFile.name}`, name: `thumb-${imageFile?.name}`,
}, } as File,
}) })
await payload.create({ await payload.create({
collection: AdminThumbnailFunction.slug, collection: AdminThumbnailFunction.slug as CollectionSlug,
data: {}, data: {},
file: { file: {
...imageFile, ...imageFile,
name: `function-image-${imageFile.name}`, name: `function-image-${imageFile?.name}`,
}, } as File,
}) })
await payload.create({ await payload.create({
collection: AdminThumbnailWithSearchQueries.slug, collection: AdminThumbnailWithSearchQueries.slug as CollectionSlug,
data: {}, data: {},
file: { file: {
...imageFile, ...imageFile,
name: `searchQueries-image-${imageFile.name}`, name: `searchQueries-image-${imageFile?.name}`,
}, } as File,
}) })
// Create media with and without relation preview // Create media with and without relation preview
@@ -940,8 +951,8 @@ export default buildConfigWithDefaults({
data: {}, data: {},
file: { file: {
...imageFile, ...imageFile,
name: `withoutCacheTags-image-${imageFile.name}`, name: `withoutCacheTags-image-${imageFile?.name}`,
}, } as File,
}) })
const { id: uploadedImageWithoutPreview } = await payload.create({ const { id: uploadedImageWithoutPreview } = await payload.create({

BIN
test/uploads/duck.glb Normal file

Binary file not shown.

View File

@@ -36,6 +36,7 @@ import {
mediaWithoutCacheTagsSlug, mediaWithoutCacheTagsSlug,
relationPreviewSlug, relationPreviewSlug,
relationSlug, relationSlug,
threeDimensionalSlug,
withMetadataSlug, withMetadataSlug,
withOnlyJPEGMetadataSlug, withOnlyJPEGMetadataSlug,
withoutEnlargeSlug, withoutEnlargeSlug,
@@ -71,6 +72,7 @@ let customUploadFieldURL: AdminUrlUtil
let hideFileInputOnCreateURL: AdminUrlUtil let hideFileInputOnCreateURL: AdminUrlUtil
let bestFitURL: AdminUrlUtil let bestFitURL: AdminUrlUtil
let withoutEnlargementResizeOptionsURL: AdminUrlUtil let withoutEnlargementResizeOptionsURL: AdminUrlUtil
let threeDimensionalURL: AdminUrlUtil
let consoleErrorsFromPage: string[] = [] let consoleErrorsFromPage: string[] = []
let collectErrorsFromPage: () => boolean let collectErrorsFromPage: () => boolean
let stopCollectingErrorsFromPage: () => boolean let stopCollectingErrorsFromPage: () => boolean
@@ -107,6 +109,7 @@ describe('Uploads', () => {
hideFileInputOnCreateURL = new AdminUrlUtil(serverURL, hideFileInputOnCreateSlug) hideFileInputOnCreateURL = new AdminUrlUtil(serverURL, hideFileInputOnCreateSlug)
bestFitURL = new AdminUrlUtil(serverURL, 'best-fit') bestFitURL = new AdminUrlUtil(serverURL, 'best-fit')
withoutEnlargementResizeOptionsURL = new AdminUrlUtil(serverURL, withoutEnlargeSlug) withoutEnlargementResizeOptionsURL = new AdminUrlUtil(serverURL, withoutEnlargeSlug)
threeDimensionalURL = new AdminUrlUtil(serverURL, threeDimensionalSlug)
const context = await browser.newContext() const context = await browser.newContext()
page = await context.newPage() page = await context.newPage()
@@ -176,7 +179,7 @@ describe('Uploads', () => {
}) })
).docs[0] ).docs[0]
await page.goto(relationURL.edit(relationDoc.id)) await page.goto(relationURL.edit(relationDoc!.id))
const filename = page.locator('.upload-relationship-details__filename a').nth(0) const filename = page.locator('.upload-relationship-details__filename a').nth(0)
await expect(filename).toContainText('image.png') await expect(filename).toContainText('image.png')
@@ -245,6 +248,19 @@ describe('Uploads', () => {
await expect(fileMetaSizeType).toHaveText(/image\/png/) await expect(fileMetaSizeType).toHaveText(/image\/png/)
}) })
test('should show proper mimetype for glb file', async () => {
await page.goto(threeDimensionalURL.create)
await page.setInputFiles('input[type="file"]', path.resolve(dirname, './duck.glb'))
const filename = page.locator('.file-field__filename')
await expect(filename).toHaveValue('duck.glb')
await saveDocAndAssert(page)
const fileMetaSizeType = page.locator('.file-meta__size-type')
await expect(fileMetaSizeType).toHaveText(/model\/gltf-binary/)
})
test('should create animated file upload', async () => { test('should create animated file upload', async () => {
await page.goto(animatedTypeMediaURL.create) await page.goto(animatedTypeMediaURL.create)
@@ -298,7 +314,7 @@ describe('Uploads', () => {
}) })
).docs[0] ).docs[0]
await page.goto(mediaURL.edit(pngDoc.id)) await page.goto(mediaURL.edit(pngDoc!.id))
await page.locator('.file-field__previewSizes').click() await page.locator('.file-field__previewSizes').click()
@@ -432,7 +448,7 @@ describe('Uploads', () => {
}) })
).docs[0] ).docs[0]
await page.goto(audioURL.edit(audioDoc.id)) await page.goto(audioURL.edit(audioDoc!.id))
// remove the selection and open the list drawer // remove the selection and open the list drawer
await wait(500) // flake workaround await wait(500) // flake workaround
@@ -478,7 +494,7 @@ describe('Uploads', () => {
}) })
).docs[0] ).docs[0]
await page.goto(audioURL.edit(audioDoc.id)) await page.goto(audioURL.edit(audioDoc!.id))
// remove the selection and open the list drawer // remove the selection and open the list drawer
await wait(500) // flake workaround await wait(500) // flake workaround
@@ -539,7 +555,7 @@ describe('Uploads', () => {
}) })
).docs[0] ).docs[0]
await page.goto(mediaWithoutCacheTagsSlugURL.edit(imageDoc.id)) await page.goto(mediaWithoutCacheTagsSlugURL.edit(imageDoc!.id))
const genericUploadImage = page.locator('.file-details .thumbnail img') const genericUploadImage = page.locator('.file-details .thumbnail img')
@@ -569,7 +585,7 @@ describe('Uploads', () => {
}) })
).docs[0] ).docs[0]
await page.goto(adminThumbnailFunctionURL.edit(imageDoc.id)) await page.goto(adminThumbnailFunctionURL.edit(imageDoc!.id))
const genericUploadImage = page.locator('.file-details .thumbnail img') const genericUploadImage = page.locator('.file-details .thumbnail img')
@@ -605,7 +621,7 @@ describe('Uploads', () => {
const imageID = page.url().split('/').pop() const imageID = page.url().split('/').pop()
const { doc: uploadedImage } = await client.findByID({ const { doc: uploadedImage } = await client.findByID({
id: imageID, id: imageID as number | string,
slug: mediaSlug, slug: mediaSlug,
auth: true, auth: true,
}) })
@@ -630,7 +646,7 @@ describe('Uploads', () => {
const mediaID = page.url().split('/').pop() const mediaID = page.url().split('/').pop()
const { doc: mediaDoc } = await client.findByID({ const { doc: mediaDoc } = await client.findByID({
id: mediaID, id: mediaID as number | string,
slug: withMetadataSlug, slug: withMetadataSlug,
auth: true, auth: true,
}) })
@@ -657,7 +673,7 @@ describe('Uploads', () => {
const mediaID = page.url().split('/').pop() const mediaID = page.url().split('/').pop()
const { doc: mediaDoc } = await client.findByID({ const { doc: mediaDoc } = await client.findByID({
id: mediaID, id: mediaID as number | string,
slug: withoutMetadataSlug, slug: withoutMetadataSlug,
auth: true, auth: true,
}) })
@@ -684,7 +700,7 @@ describe('Uploads', () => {
const jpegMediaID = page.url().split('/').pop() const jpegMediaID = page.url().split('/').pop()
const { doc: jpegMediaDoc } = await client.findByID({ const { doc: jpegMediaDoc } = await client.findByID({
id: jpegMediaID, id: jpegMediaID as number | string,
slug: withOnlyJPEGMetadataSlug, slug: withOnlyJPEGMetadataSlug,
auth: true, auth: true,
}) })
@@ -710,7 +726,7 @@ describe('Uploads', () => {
const webpMediaID = page.url().split('/').pop() const webpMediaID = page.url().split('/').pop()
const { doc: webpMediaDoc } = await client.findByID({ const { doc: webpMediaDoc } = await client.findByID({
id: webpMediaID, id: webpMediaID as number | string,
slug: withOnlyJPEGMetadataSlug, slug: withOnlyJPEGMetadataSlug,
auth: true, auth: true,
}) })
@@ -1210,13 +1226,13 @@ describe('Uploads', () => {
const redSquareMediaID = page.url().split('/').pop() // get the ID of the doc const redSquareMediaID = page.url().split('/').pop() // get the ID of the doc
const { doc: greenDoc } = await client.findByID({ const { doc: greenDoc } = await client.findByID({
id: greenSquareMediaID, id: greenSquareMediaID as number | string,
slug: mediaSlug, slug: mediaSlug,
auth: true, auth: true,
}) })
const { doc: redDoc } = await client.findByID({ const { doc: redDoc } = await client.findByID({
id: redSquareMediaID, id: redSquareMediaID as number | string,
slug: mediaSlug, slug: mediaSlug,
auth: true, auth: true,
}) })
@@ -1254,7 +1270,7 @@ describe('Uploads', () => {
const redSquareMediaID = page.url().split('/').pop() // get the ID of the doc const redSquareMediaID = page.url().split('/').pop() // get the ID of the doc
const { doc: redDoc } = await client.findByID({ const { doc: redDoc } = await client.findByID({
id: redSquareMediaID, id: redSquareMediaID as number | string,
slug: focalOnlySlug, slug: focalOnlySlug,
auth: true, auth: true,
}) })

View File

@@ -82,6 +82,7 @@ export interface Config {
media: Media; media: Media;
'animated-type-media': AnimatedTypeMedia; 'animated-type-media': AnimatedTypeMedia;
enlarge: Enlarge; enlarge: Enlarge;
'without-enlarge': WithoutEnlarge;
reduce: Reduce; reduce: Reduce;
'media-trim': MediaTrim; 'media-trim': MediaTrim;
'custom-file-name-media': CustomFileNameMedia; 'custom-file-name-media': CustomFileNameMedia;
@@ -103,6 +104,7 @@ export interface Config {
'hide-file-input-on-create': HideFileInputOnCreate; 'hide-file-input-on-create': HideFileInputOnCreate;
'best-fit': BestFit; 'best-fit': BestFit;
'list-view-preview': ListViewPreview; 'list-view-preview': ListViewPreview;
'three-dimensional': ThreeDimensional;
users: User; users: User;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
@@ -125,6 +127,7 @@ export interface Config {
media: MediaSelect<false> | MediaSelect<true>; media: MediaSelect<false> | MediaSelect<true>;
'animated-type-media': AnimatedTypeMediaSelect<false> | AnimatedTypeMediaSelect<true>; 'animated-type-media': AnimatedTypeMediaSelect<false> | AnimatedTypeMediaSelect<true>;
enlarge: EnlargeSelect<false> | EnlargeSelect<true>; enlarge: EnlargeSelect<false> | EnlargeSelect<true>;
'without-enlarge': WithoutEnlargeSelect<false> | WithoutEnlargeSelect<true>;
reduce: ReduceSelect<false> | ReduceSelect<true>; reduce: ReduceSelect<false> | ReduceSelect<true>;
'media-trim': MediaTrimSelect<false> | MediaTrimSelect<true>; 'media-trim': MediaTrimSelect<false> | MediaTrimSelect<true>;
'custom-file-name-media': CustomFileNameMediaSelect<false> | CustomFileNameMediaSelect<true>; 'custom-file-name-media': CustomFileNameMediaSelect<false> | CustomFileNameMediaSelect<true>;
@@ -146,6 +149,7 @@ export interface Config {
'hide-file-input-on-create': HideFileInputOnCreateSelect<false> | HideFileInputOnCreateSelect<true>; 'hide-file-input-on-create': HideFileInputOnCreateSelect<false> | HideFileInputOnCreateSelect<true>;
'best-fit': BestFitSelect<false> | BestFitSelect<true>; 'best-fit': BestFitSelect<false> | BestFitSelect<true>;
'list-view-preview': ListViewPreviewSelect<false> | ListViewPreviewSelect<true>; 'list-view-preview': ListViewPreviewSelect<false> | ListViewPreviewSelect<true>;
'three-dimensional': ThreeDimensionalSelect<false> | ThreeDimensionalSelect<true>;
users: UsersSelect<false> | UsersSelect<true>; users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -848,6 +852,24 @@ export interface Enlarge {
}; };
}; };
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "without-enlarge".
*/
export interface WithoutEnlarge {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reduce". * via the `definition` "reduce".
@@ -1289,6 +1311,22 @@ export interface ListViewPreview {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "three-dimensional".
*/
export interface ThreeDimensional {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users". * via the `definition` "users".
@@ -1373,6 +1411,10 @@ export interface PayloadLockedDocument {
relationTo: 'enlarge'; relationTo: 'enlarge';
value: string | Enlarge; value: string | Enlarge;
} | null) } | null)
| ({
relationTo: 'without-enlarge';
value: string | WithoutEnlarge;
} | null)
| ({ | ({
relationTo: 'reduce'; relationTo: 'reduce';
value: string | Reduce; value: string | Reduce;
@@ -1457,6 +1499,10 @@ export interface PayloadLockedDocument {
relationTo: 'list-view-preview'; relationTo: 'list-view-preview';
value: string | ListViewPreview; value: string | ListViewPreview;
} | null) } | null)
| ({
relationTo: 'three-dimensional';
value: string | ThreeDimensional;
} | null)
| ({ | ({
relationTo: 'users'; relationTo: 'users';
value: string | User; value: string | User;
@@ -2219,6 +2265,23 @@ export interface EnlargeSelect<T extends boolean = true> {
}; };
}; };
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "without-enlarge_select".
*/
export interface WithoutEnlargeSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "reduce_select". * via the `definition` "reduce_select".
@@ -2692,6 +2755,21 @@ export interface ListViewPreviewSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "three-dimensional_select".
*/
export interface ThreeDimensionalSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select". * via the `definition` "users_select".

View File

@@ -25,3 +25,4 @@ export const withOnlyJPEGMetadataSlug = 'with-only-jpeg-meta-data'
export const customFileNameMediaSlug = 'custom-file-name-media' export const customFileNameMediaSlug = 'custom-file-name-media'
export const listViewPreviewSlug = 'list-view-preview' export const listViewPreviewSlug = 'list-view-preview'
export const threeDimensionalSlug = 'three-dimensional'