Fix PDF Upload edge case (#18533)
we were using an older version of `file-type` which has limited support for PDF as it's a complex spec Updated to latest version which includes support for plugins and added `@file-type/pdf` which has extensive spec compliant detection approach fixes TWENTY-SERVER-FAN
This commit is contained in:
+4
-2
@@ -4,7 +4,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { buffer as streamToBuffer } from 'node:stream/consumers';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import FileType from 'file-type';
|
||||
import { FileTypeParser } from 'file-type';
|
||||
import { detectPdf } from '@file-type/pdf';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Like, type QueryRunner, Repository } from 'typeorm';
|
||||
@@ -195,7 +196,8 @@ export class FileCorePictureService {
|
||||
|
||||
const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
|
||||
|
||||
const type = await FileType.fromBuffer(buffer);
|
||||
const parser = new FileTypeParser({ customDetectors: [detectPdf] });
|
||||
const type = await parser.fromBuffer(buffer);
|
||||
|
||||
if (!isDefined(type) || !type.mime.startsWith('image/')) {
|
||||
return undefined;
|
||||
|
||||
+92
-170
@@ -1,182 +1,104 @@
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { extractFileInfo } from '../extract-file-info.utils';
|
||||
|
||||
// Mock detectableMimeTypes to work around ESM/CommonJS interop issues in Jest
|
||||
jest.mock('file-type', () => {
|
||||
const actual = jest.requireActual('file-type');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
mimeTypes: actual.mimeTypes,
|
||||
};
|
||||
});
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
||||
0x48, 0x44, 0x52,
|
||||
]);
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8');
|
||||
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
|
||||
const zipBuffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
|
||||
describe('extractFileInfo', () => {
|
||||
// Real PNG file header (magic numbers)
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52,
|
||||
]);
|
||||
|
||||
// Real PDF file header
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8');
|
||||
|
||||
// Plain text buffer (no magic numbers)
|
||||
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
|
||||
|
||||
// Real ZIP file header (for testing docx, xlsx, etc.)
|
||||
const zipBuffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
|
||||
it('should detect PNG from buffer magic numbers', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: pngBuffer,
|
||||
it.each([
|
||||
{
|
||||
name: 'PNG',
|
||||
buffer: pngBuffer,
|
||||
filename: 'image.png',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/png',
|
||||
ext: 'png',
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect PDF from buffer magic numbers', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: pdfBuffer,
|
||||
mime: 'image/png',
|
||||
},
|
||||
{
|
||||
name: 'PDF',
|
||||
buffer: pdfBuffer,
|
||||
filename: 'document.pdf',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'application/pdf',
|
||||
ext: 'pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use extension-based lookup for text files', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: textBuffer,
|
||||
filename: 'document.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'text/plain',
|
||||
ext: 'txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle CSV files using extension', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: textBuffer,
|
||||
filename: 'data.csv',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'text/csv',
|
||||
ext: 'csv',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON files using extension', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: Buffer.from('{"key": "value"}'),
|
||||
filename: 'config.json',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'application/json',
|
||||
ext: 'json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return application/octet-stream for unknown extensions', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: textBuffer,
|
||||
filename: 'file.unknown',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'application/octet-stream',
|
||||
ext: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return application/octet-stream for files without extension', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: textBuffer,
|
||||
filename: 'file-without-extension',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'application/octet-stream',
|
||||
ext: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect ZIP files from buffer', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: zipBuffer,
|
||||
mime: 'application/pdf',
|
||||
},
|
||||
{
|
||||
name: 'ZIP',
|
||||
buffer: zipBuffer,
|
||||
filename: 'archive.zip',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'application/zip',
|
||||
ext: 'zip',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when PNG extension is used with non-PNG buffer', async () => {
|
||||
await expect(
|
||||
extractFileInfo({
|
||||
file: textBuffer,
|
||||
filename: 'fake-image.png',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"File content does not match its extension. The file has extension 'png' (expected mime type: image/png), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when PDF extension is used with non-PDF buffer', async () => {
|
||||
await expect(
|
||||
extractFileInfo({
|
||||
file: textBuffer,
|
||||
filename: 'fake-document.pdf',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"File content does not match its extension. The file has extension 'pdf' (expected mime type: application/pdf), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle markdown files using extension', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: Buffer.from('# Heading\n\nContent'),
|
||||
filename: 'README.md',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'text/markdown',
|
||||
ext: 'md',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle HTML files using extension', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: Buffer.from('<html><body>Test</body></html>'),
|
||||
filename: 'index.html',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'text/html',
|
||||
ext: 'html',
|
||||
});
|
||||
});
|
||||
|
||||
it('should prefer detected type over declared extension', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: pngBuffer,
|
||||
mime: 'application/zip',
|
||||
},
|
||||
{
|
||||
name: 'PNG (mismatched extension)',
|
||||
buffer: pngBuffer,
|
||||
filename: 'image.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/png',
|
||||
ext: 'png',
|
||||
});
|
||||
});
|
||||
mime: 'image/png',
|
||||
},
|
||||
])(
|
||||
'should detect $name from buffer magic numbers',
|
||||
async ({ buffer, filename, ext, mime }) => {
|
||||
const result = await extractFileInfo({ file: buffer, filename });
|
||||
|
||||
expect(result).toEqual({ mimeType: mime, ext });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: 'text', filename: 'document.txt', ext: 'txt', mime: 'text/plain' },
|
||||
{ name: 'CSV', filename: 'data.csv', ext: 'csv', mime: 'text/csv' },
|
||||
{
|
||||
name: 'JSON',
|
||||
filename: 'config.json',
|
||||
ext: 'json',
|
||||
mime: 'application/json',
|
||||
},
|
||||
{
|
||||
name: 'markdown',
|
||||
filename: 'README.md',
|
||||
ext: 'md',
|
||||
mime: 'text/markdown',
|
||||
},
|
||||
{ name: 'HTML', filename: 'index.html', ext: 'html', mime: 'text/html' },
|
||||
{
|
||||
name: 'unknown extension',
|
||||
filename: 'file.unknown',
|
||||
ext: 'unknown',
|
||||
mime: 'application/octet-stream',
|
||||
},
|
||||
{
|
||||
name: 'no extension',
|
||||
filename: 'file-without-extension',
|
||||
ext: '',
|
||||
mime: 'application/octet-stream',
|
||||
},
|
||||
])(
|
||||
'should fall back to extension for $name files',
|
||||
async ({ filename, ext, mime }) => {
|
||||
const result = await extractFileInfo({ file: textBuffer, filename });
|
||||
|
||||
expect(result).toEqual({ mimeType: mime, ext });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ ext: 'png', filename: 'fake-image.png', expectedMime: 'image/png' },
|
||||
{
|
||||
ext: 'pdf',
|
||||
filename: 'fake-document.pdf',
|
||||
expectedMime: 'application/pdf',
|
||||
},
|
||||
])(
|
||||
'should throw when $ext extension does not match buffer content',
|
||||
async ({ filename, ext, expectedMime }) => {
|
||||
await expect(
|
||||
extractFileInfo({ file: textBuffer, filename }),
|
||||
).rejects.toThrow(
|
||||
`File content does not match its extension. The file has extension '${ext}' (expected mime type: ${expectedMime}), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+8
-3
@@ -1,6 +1,6 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import FileType, { type MimeType } from 'file-type';
|
||||
import { FileTypeParser, supportedMimeTypes } from 'file-type';
|
||||
import { lookup } from 'mrmime';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
|
||||
import { detectPdf } from '@file-type/pdf';
|
||||
import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
|
||||
|
||||
export const extractFileInfo = async ({
|
||||
@@ -20,8 +21,12 @@ export const extractFileInfo = async ({
|
||||
}) => {
|
||||
const { ext: declaredExt } = buildFileInfo(filename);
|
||||
|
||||
const fileParser = new FileTypeParser({
|
||||
customDetectors: [detectPdf],
|
||||
});
|
||||
|
||||
const { ext: detectedExt, mime: detectedMime } =
|
||||
(await FileType.fromBuffer(file)) ?? {};
|
||||
(await fileParser.fromBuffer(file)) ?? {};
|
||||
|
||||
if (isDefined(detectedExt) && isDefined(detectedMime)) {
|
||||
return {
|
||||
@@ -39,7 +44,7 @@ export const extractFileInfo = async ({
|
||||
|
||||
if (
|
||||
mimeTypeFromExtension &&
|
||||
FileType.mimeTypes.has(mimeTypeFromExtension as MimeType)
|
||||
supportedMimeTypes.has(mimeTypeFromExtension)
|
||||
) {
|
||||
throw new FileStorageException(
|
||||
`File content does not match its extension. The file has extension '${ext}' (expected mime type: ${mimeTypeFromExtension}), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.`,
|
||||
|
||||
Reference in New Issue
Block a user