File - Migrate avatarUrl > avatarFile on person (data migration + logic) + Attachment data migration (#17752)
- Migration command
- Check IS_FILES_FIELD_MIGRATED:false
- Check or create avatarFile field
- Fetch all people with avatarUrl
- Move (Copy/move) file in storage
- Create core.file record
- Update person record
- bonus : attachment migration : fullPath > file (same logic)
- BE logic
- Add avatarFile field on person
- FE logic
- Adapt logic to upload on/display avatarFile data
The whole imageIdentifier logic will be done later
This commit is contained in:
@@ -114,6 +114,7 @@ export class FileStorageService {
|
||||
workspaceId,
|
||||
applicationId: application.id,
|
||||
id: fileId,
|
||||
mimeType,
|
||||
size:
|
||||
typeof sourceFile === 'string'
|
||||
? Buffer.byteLength(sourceFile)
|
||||
|
||||
+3
@@ -7,12 +7,15 @@ import { CustomException } from 'src/utils/custom-exception';
|
||||
export enum FileStorageExceptionCode {
|
||||
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
|
||||
ACCESS_DENIED = 'ACCESS_DENIED',
|
||||
INVALID_EXTENSION = 'INVALID_EXTENSION',
|
||||
}
|
||||
|
||||
const getFileStorageExceptionUserFriendlyMessage = (
|
||||
code: FileStorageExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case FileStorageExceptionCode.INVALID_EXTENSION:
|
||||
return msg`Wrong extension for file.`;
|
||||
case FileStorageExceptionCode.FILE_NOT_FOUND:
|
||||
return msg`File not found.`;
|
||||
case FileStorageExceptionCode.ACCESS_DENIED:
|
||||
|
||||
@@ -56,4 +56,11 @@ export class FileEntity extends WorkspaceRelatedEntity {
|
||||
|
||||
@Column({ nullable: true, type: 'jsonb' })
|
||||
settings: FileSettings | null;
|
||||
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: 'varchar',
|
||||
default: 'application/octet-stream',
|
||||
})
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
+2
-4
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
@@ -44,26 +45,23 @@ export class FilesFieldService {
|
||||
async uploadFile({
|
||||
file,
|
||||
filename,
|
||||
declaredMimeType,
|
||||
workspaceId,
|
||||
fieldMetadataId,
|
||||
}: {
|
||||
file: Buffer;
|
||||
filename: string;
|
||||
declaredMimeType: string | undefined;
|
||||
workspaceId: string;
|
||||
fieldMetadataId: string;
|
||||
}): Promise<FileEntity> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
file,
|
||||
declaredMimeType,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${ext ? `.${ext}` : ''}`;
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
const fieldMetadata = await this.fieldMetadataRepository.findOneOrFail({
|
||||
select: ['applicationId', 'universalIdentifier'],
|
||||
|
||||
+1
-2
@@ -30,7 +30,7 @@ export class FilesFieldResolver {
|
||||
@AuthWorkspace()
|
||||
{ id: workspaceId }: WorkspaceEntity,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, filename, mimetype }: FileUpload,
|
||||
{ createReadStream, filename }: FileUpload,
|
||||
@Args({
|
||||
name: 'fieldMetadataId',
|
||||
type: () => String,
|
||||
@@ -44,7 +44,6 @@ export class FilesFieldResolver {
|
||||
return await this.filesFieldService.uploadFile({
|
||||
file: buffer,
|
||||
filename,
|
||||
declaredMimeType: mimetype,
|
||||
workspaceId,
|
||||
fieldMetadataId,
|
||||
});
|
||||
|
||||
+136
-62
@@ -1,28 +1,35 @@
|
||||
import FileType from 'file-type';
|
||||
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
|
||||
jest.mock('file-type', () => ({
|
||||
fromBuffer: jest.fn(),
|
||||
}));
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
|
||||
describe('extractFileInfo', () => {
|
||||
const mockBuffer = Buffer.from('test content');
|
||||
// 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,
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
// Real PDF file header
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8');
|
||||
|
||||
it('should use detected file type when available', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue({
|
||||
mime: 'image/png',
|
||||
ext: 'png',
|
||||
});
|
||||
// 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: mockBuffer,
|
||||
declaredMimeType: 'text/plain',
|
||||
filename: 'test.txt',
|
||||
file: pngBuffer,
|
||||
filename: 'image.png',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -31,31 +38,10 @@ describe('extractFileInfo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to declared values when file type is not detected', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
it('should detect PDF from buffer magic numbers', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: mockBuffer,
|
||||
declaredMimeType: 'text/plain',
|
||||
filename: 'test.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'text/plain',
|
||||
ext: 'txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing declared mime type when file type is detected', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue({
|
||||
mime: 'application/pdf',
|
||||
ext: 'pdf',
|
||||
});
|
||||
|
||||
const result = await extractFileInfo({
|
||||
file: mockBuffer,
|
||||
declaredMimeType: undefined,
|
||||
filename: 'document',
|
||||
file: pdfBuffer,
|
||||
filename: 'document.pdf',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -64,45 +50,133 @@ describe('extractFileInfo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle both mime type and extension being undefined', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
it('should use extension-based lookup for text files', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: mockBuffer,
|
||||
declaredMimeType: undefined,
|
||||
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: undefined,
|
||||
mimeType: 'application/octet-stream',
|
||||
ext: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle filenames with multiple dots', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
it('should detect ZIP files from buffer', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: mockBuffer,
|
||||
declaredMimeType: 'application/gzip',
|
||||
filename: 'archive.tar.gz',
|
||||
file: zipBuffer,
|
||||
filename: 'archive.zip',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'application/gzip',
|
||||
ext: 'gz',
|
||||
mimeType: 'application/zip',
|
||||
ext: 'zip',
|
||||
});
|
||||
});
|
||||
|
||||
it('should call FileType.fromBuffer with the provided buffer', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
|
||||
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.",
|
||||
);
|
||||
});
|
||||
|
||||
await extractFileInfo({
|
||||
file: mockBuffer,
|
||||
declaredMimeType: 'image/png',
|
||||
filename: 'image.png',
|
||||
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(FileType.fromBuffer).toHaveBeenCalledWith(mockBuffer);
|
||||
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,
|
||||
filename: 'image.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/png',
|
||||
ext: 'png',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+40
-6
@@ -1,23 +1,57 @@
|
||||
import FileType from 'file-type';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import FileType, { type MimeType } from 'file-type';
|
||||
import { lookup } from 'mrmime';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
|
||||
import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
|
||||
|
||||
export const extractFileInfo = async ({
|
||||
file,
|
||||
declaredMimeType,
|
||||
filename,
|
||||
}: {
|
||||
file: Buffer;
|
||||
declaredMimeType: string | undefined;
|
||||
filename: string;
|
||||
}) => {
|
||||
const { ext: declaredExt } = buildFileInfo(filename);
|
||||
|
||||
const detectedFileType = await FileType.fromBuffer(file);
|
||||
const { ext: detectedExt, mime: detectedMime } =
|
||||
(await FileType.fromBuffer(file)) ?? {};
|
||||
|
||||
const mimeType = detectedFileType?.mime ?? declaredMimeType;
|
||||
if (isDefined(detectedExt) && isDefined(detectedMime)) {
|
||||
return {
|
||||
mimeType: detectedMime,
|
||||
ext: detectedExt,
|
||||
};
|
||||
}
|
||||
|
||||
const ext = detectedFileType?.ext ?? declaredExt;
|
||||
const ext = declaredExt;
|
||||
|
||||
let mimeType: string = 'application/octet-stream';
|
||||
|
||||
if (isNonEmptyString(ext)) {
|
||||
const mimeTypeFromExtension = lookup(ext);
|
||||
|
||||
if (
|
||||
mimeTypeFromExtension &&
|
||||
FileType.mimeTypes.has(mimeTypeFromExtension as MimeType)
|
||||
) {
|
||||
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.`,
|
||||
FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
{
|
||||
userFriendlyMessage: msg`The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
mimeType = mimeTypeFromExtension ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
return {
|
||||
mimeType,
|
||||
|
||||
+8
@@ -134,6 +134,14 @@ export class FilesFieldSync {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isModifyingFilesField = filesFields.some((filesField) =>
|
||||
isDefined(updatePayload[filesField.name as keyof typeof updatePayload]),
|
||||
);
|
||||
|
||||
if (!isModifyingFilesField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existingRecords.length !== 1) {
|
||||
throw new TwentyORMException(
|
||||
`Cannot update multiple records with files field at once`,
|
||||
|
||||
+478
-475
File diff suppressed because it is too large
Load Diff
+21
@@ -229,6 +229,7 @@ export const buildPersonStandardFlatFieldMetadatas = ({
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
//deprecated
|
||||
avatarUrl: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
@@ -246,6 +247,26 @@ export const buildPersonStandardFlatFieldMetadatas = ({
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
avatarFile: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'avatarFile',
|
||||
type: FieldMetadataType.FILES,
|
||||
label: 'Avatar File',
|
||||
description: "Contact's avatar file",
|
||||
icon: 'IconFileUpload',
|
||||
isSystem: true,
|
||||
isNullable: true,
|
||||
settings: {
|
||||
maxNumberOfValues: 1,
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
position: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
|
||||
Reference in New Issue
Block a user