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:
neo773
2026-03-12 16:04:24 +05:30
committed by GitHub
parent 38664249cf
commit b21fb4aa6f
7 changed files with 153 additions and 180 deletions
@@ -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;
@@ -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.`,
);
},
);
});
@@ -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.`,