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 -1
View File
@@ -10,7 +10,9 @@ const jestConfig = {
rootDir: './',
testEnvironment: 'node',
setupFilesAfterEnv: ['./setupTests.ts'],
transformIgnorePatterns: ['/node_modules/'],
transformIgnorePatterns: [
'/node_modules/(?!(file-type|@file-type|strtok3|token-types|@borewit|@tokenizer|uint8array-extras|read-next-line)/)',
],
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': [
@@ -42,6 +44,7 @@ const jestConfig = {
moduleNameMapper: {
'^src/(.*)': '<rootDir>/src/$1',
'^test/(.*)': '<rootDir>/test/$1',
'^file-type$': '<rootDir>/node_modules/file-type/index.js',
},
moduleFileExtensions: ['js', 'json', 'ts'],
modulePathIgnorePatterns: ['<rootDir>/dist'],
+2 -1
View File
@@ -39,6 +39,7 @@
"@envelop/on-resolve": "4.1.0",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@faker-js/faker": "9.8.0",
"@file-type/pdf": "^0.2.0",
"@graphql-tools/schema": "10.0.4",
"@graphql-tools/utils": "9.2.1",
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch",
@@ -106,7 +107,7 @@
"dotenv": "16.4.5",
"express": "4.22.1",
"express-session": "^1.18.2",
"file-type": "16.5.4",
"file-type": "^21.3.1",
"fuse.js": "^7.1.0",
"gaxios": "5.1.3",
"google-auth-library": "8.9.0",
@@ -1,7 +1,8 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import FileType from 'file-type';
import { FileTypeParser } from 'file-type';
import { detectPdf } from '@file-type/pdf';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FileFolder } from 'twenty-shared/types';
@@ -204,7 +205,8 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
const httpClient = this.secureHttpClientService.getHttpClient();
const buffer = await getImageBufferFromUrl(logoUrl, 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/')) {
this.logger.warn(
@@ -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.`,
+39 -1
View File
@@ -5873,6 +5873,17 @@ __metadata:
languageName: node
linkType: hard
"@file-type/pdf@npm:^0.2.0":
version: 0.2.0
resolution: "@file-type/pdf@npm:0.2.0"
dependencies:
"@borewit/text-codec": "npm:^0.2.1"
read-next-line: "npm:^0.5.0"
sax: "npm:^1.4.1"
checksum: 10c0/9f2f446a04ca2fff4f5a02aeb6065d72f4fc95b6bce5efc6a8ec77530c5b36192f9aa718b65e00398921cf5a0d505cda5e9208d3ac81aad63f7d60a535212a89
languageName: node
linkType: hard
"@floating-ui/core@npm:^1.6.0":
version: 1.6.7
resolution: "@floating-ui/core@npm:1.6.7"
@@ -36527,6 +36538,18 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:^21.3.1":
version: 21.3.1
resolution: "file-type@npm:21.3.1"
dependencies:
"@tokenizer/inflate": "npm:^0.4.1"
strtok3: "npm:^10.3.4"
token-types: "npm:^6.1.1"
uint8array-extras: "npm:^1.4.0"
checksum: 10c0/66a8eda781c803c6fc372464abba88cee564cfe30f029716f06909da1564bc51d0a4e8d34654b881dc751cdb0513338b3c0747e06a608cee0dd5c5499b018d47
languageName: node
linkType: hard
"filelist@npm:^1.0.4":
version: 1.0.4
resolution: "filelist@npm:1.0.4"
@@ -51234,6 +51257,13 @@ __metadata:
languageName: node
linkType: hard
"read-next-line@npm:^0.5.0":
version: 0.5.0
resolution: "read-next-line@npm:0.5.0"
checksum: 10c0/a658dd647a655767a81d237474dac2cce6f15fff14e8963a41980bafb30c11298e27d6210a06031ecef62a28edf703cc2e3fce8d28ec411bb309ea71403c6992
languageName: node
linkType: hard
"read-only-stream@npm:^2.0.0":
version: 2.0.0
resolution: "read-only-stream@npm:2.0.0"
@@ -52880,6 +52910,13 @@ __metadata:
languageName: node
linkType: hard
"sax@npm:^1.4.1":
version: 1.5.0
resolution: "sax@npm:1.5.0"
checksum: 10c0/bc3b60a7bfecd40b18256596e96b32df2488339ae1e00a77f842b568f0831228a16c3bd357ec500241ec0b9dc7a475a1286427795c4a8c50bb8e8878f3435dd8
languageName: node
linkType: hard
"saxes@npm:^6.0.0":
version: 6.0.0
resolution: "saxes@npm:6.0.0"
@@ -56594,6 +56631,7 @@ __metadata:
"@envelop/on-resolve": "npm:4.1.0"
"@esbuild-plugins/node-modules-polyfill": "npm:^0.2.2"
"@faker-js/faker": "npm:^9.8.0"
"@file-type/pdf": "npm:^0.2.0"
"@graphql-tools/schema": "npm:10.0.4"
"@graphql-tools/utils": "npm:9.2.1"
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch"
@@ -56700,7 +56738,7 @@ __metadata:
dotenv: "npm:16.4.5"
express: "npm:4.22.1"
express-session: "npm:^1.18.2"
file-type: "npm:16.5.4"
file-type: "npm:^21.3.1"
fuse.js: "npm:^7.1.0"
gaxios: "npm:5.1.3"
google-auth-library: "npm:8.9.0"