diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index e681ec0773..547d6d2078 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1600,11 +1600,13 @@ export enum FileFolder { BuiltFrontComponent = 'BuiltFrontComponent', BuiltFunction = 'BuiltFunction', File = 'File', + FilesField = 'FilesField', PersonPicture = 'PersonPicture', ProfilePicture = 'ProfilePicture', ServerlessFunction = 'ServerlessFunction', ServerlessFunctionToDelete = 'ServerlessFunctionToDelete', Source = 'Source', + TemporaryFilesField = 'TemporaryFilesField', WorkspaceLogo = 'WorkspaceLogo' } @@ -2166,7 +2168,9 @@ export type Mutation = { updateWorkspaceFeatureFlag: Scalars['Boolean']; updateWorkspaceMemberRole: WorkspaceMember; uploadApplicationFile: File; + /** @deprecated Use uploadFilesFieldFile instead */ uploadFile: SignedFile; + uploadFilesFieldFile: File; uploadImage: SignedFile; uploadWorkspaceLogo: SignedFile; uploadWorkspaceMemberProfilePicture: SignedFile; @@ -3110,6 +3114,11 @@ export type MutationUploadFileArgs = { }; +export type MutationUploadFilesFieldFileArgs = { + file: Scalars['Upload']; +}; + + export type MutationUploadImageArgs = { file: Scalars['Upload']; fileFolder?: InputMaybe; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index a70013faaf..8999a29ba9 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1567,11 +1567,13 @@ export enum FileFolder { BuiltFrontComponent = 'BuiltFrontComponent', BuiltFunction = 'BuiltFunction', File = 'File', + FilesField = 'FilesField', PersonPicture = 'PersonPicture', ProfilePicture = 'ProfilePicture', ServerlessFunction = 'ServerlessFunction', ServerlessFunctionToDelete = 'ServerlessFunctionToDelete', Source = 'Source', + TemporaryFilesField = 'TemporaryFilesField', WorkspaceLogo = 'WorkspaceLogo' } @@ -2119,7 +2121,9 @@ export type Mutation = { updateWorkspaceFeatureFlag: Scalars['Boolean']; updateWorkspaceMemberRole: WorkspaceMember; uploadApplicationFile: File; + /** @deprecated Use uploadFilesFieldFile instead */ uploadFile: SignedFile; + uploadFilesFieldFile: File; uploadImage: SignedFile; uploadWorkspaceLogo: SignedFile; uploadWorkspaceMemberProfilePicture: SignedFile; @@ -2992,6 +2996,11 @@ export type MutationUploadFileArgs = { }; +export type MutationUploadFilesFieldFileArgs = { + file: Scalars['Upload']; +}; + + export type MutationUploadImageArgs = { file: Scalars['Upload']; fileFolder?: InputMaybe; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts index 039d547b3a..8e168af0bc 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts @@ -1,9 +1,11 @@ import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { Readable } from 'stream'; import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; describe('FileStorageService', () => { let service: FileStorageService; @@ -13,6 +15,10 @@ describe('FileStorageService', () => { getCurrentDriver: jest.fn(), }; + const mockFileRepository = { + save: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -21,6 +27,10 @@ describe('FileStorageService', () => { provide: FileStorageDriverFactory, useValue: mockFileStorageDriverFactory, }, + { + provide: getRepositoryToken(FileEntity), + useValue: mockFileRepository, + }, ], }).compile(); @@ -48,6 +58,9 @@ describe('FileStorageService', () => { copy: jest.fn(), download: jest.fn(), checkFileExists: jest.fn(), + checkFolderExists: jest.fn(), + writeFolder: jest.fn(), + readFolder: jest.fn(), }; mockFileStorageDriverFactory.getCurrentDriver.mockReturnValue(mockDriver); @@ -67,7 +80,11 @@ describe('FileStorageService', () => { await service.write(writeParams); expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); - expect(mockDriver.write).toHaveBeenCalledWith(writeParams); + expect(mockDriver.write).toHaveBeenCalledWith({ + filePath: 'documents/test.txt', + sourceFile: writeParams.file, + mimeType: 'text/plain', + }); }); it('should handle write errors', async () => { @@ -86,7 +103,6 @@ describe('FileStorageService', () => { 'Write failed', ); expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); - expect(mockDriver.write).toHaveBeenCalledWith(writeParams); }); }); @@ -104,7 +120,9 @@ describe('FileStorageService', () => { const result = await service.read(readParams); expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); - expect(mockDriver.read).toHaveBeenCalledWith(readParams); + expect(mockDriver.read).toHaveBeenCalledWith({ + filePath: 'documents/test.txt', + }); expect(result).toBe(mockStream); }); @@ -120,7 +138,6 @@ describe('FileStorageService', () => { await expect(service.read(readParams)).rejects.toThrow('Read failed'); expect(fileStorageDriverFactory.getCurrentDriver).toHaveBeenCalled(); - expect(mockDriver.read).toHaveBeenCalledWith(readParams); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts index 2d5ca3a3e8..4e6be7b9f7 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts @@ -4,12 +4,11 @@ import { type Sources } from 'twenty-shared/types'; export interface StorageDriver { delete(params: { folderPath: string; filename?: string }): Promise; - read(params: { folderPath: string; filename: string }): Promise; + read(params: { filePath: string }): Promise; readFolder(folderPath: string): Promise; write(params: { - file: Buffer | Uint8Array | string; - name: string; - folder: string; + filePath: string; + sourceFile: Buffer | Uint8Array | string; mimeType: string | undefined; }): Promise; writeFolder(sources: Sources, folderPath: string): Promise; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts index 81e257bfbe..11e945069b 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts @@ -28,21 +28,16 @@ export class LocalDriver implements StorageDriver { } async write(params: { - file: Buffer | Uint8Array | string; - name: string; - folder: string; + filePath: string; + sourceFile: Buffer | Uint8Array | string; mimeType: string | undefined; }): Promise { - const filePath = join( - `${this.options.storagePath}/`, - params.folder, - params.name, - ); + const filePath = `${this.options.storagePath}/${params.filePath}`; const folderPath = dirname(filePath); await this.createFolder(folderPath); - await fs.writeFile(filePath, params.file); + await fs.writeFile(filePath, params.sourceFile); } async writeFolder(sources: Sources, folderPath: string) { @@ -52,10 +47,9 @@ export class LocalDriver implements StorageDriver { continue; } await this.write({ - file: sources[key], - name: key, + filePath: join(folderPath, key), + sourceFile: sources[key], mimeType: undefined, - folder: folderPath, }); } } @@ -73,15 +67,8 @@ export class LocalDriver implements StorageDriver { await fs.rm(filePath, { recursive: true }); } - async read(params: { - folderPath: string; - filename: string; - }): Promise { - const joinedPath = join( - `${this.options.storagePath}/`, - params.folderPath, - params.filename, - ); + async read(params: { filePath: string }): Promise { + const joinedPath = join(`${this.options.storagePath}/`, params.filePath); let filePath: string; try { diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts index 149aabaf52..0ebc0e78c3 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts @@ -20,8 +20,8 @@ import { S3, type S3ClientConfig, } from '@aws-sdk/client-s3'; -import { isDefined } from 'twenty-shared/utils'; import { isObject } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface'; import { @@ -61,14 +61,13 @@ export class S3Driver implements StorageDriver { } async write(params: { - file: Buffer | Uint8Array | string; - name: string; - folder: string; + filePath: string; + sourceFile: Buffer | Uint8Array | string; mimeType: string | undefined; }): Promise { const command = new PutObjectCommand({ - Key: `${params.folder}/${params.name}`, - Body: params.file, + Key: params.filePath, + Body: params.sourceFile, ContentType: params.mimeType, Bucket: this.bucketName, }); @@ -83,10 +82,9 @@ export class S3Driver implements StorageDriver { continue; } await this.write({ - file: sources[key], - name: key, + filePath: `${folderPath}/${key}`, + sourceFile: sources[key], mimeType: undefined, - folder: folderPath, }); } } @@ -169,12 +167,9 @@ export class S3Driver implements StorageDriver { } } - async read(params: { - folderPath: string; - filename: string; - }): Promise { + async read(params: { filePath: string }): Promise { const command = new GetObjectCommand({ - Key: `${params.folderPath}/${params.filename}`, + Key: params.filePath, Bucket: this.bucketName, }); @@ -222,7 +217,7 @@ export class S3Driver implements StorageDriver { const { fromFolderPath, filename } = folderAndFilePaths; const fileContent = await readFileContent( - await this.read({ folderPath: fromFolderPath, filename }), + await this.read({ filePath: `${fromFolderPath}/${filename}` }), ); const formattedObjectKey = object.Key.replace( @@ -441,8 +436,7 @@ export class S3Driver implements StorageDriver { await mkdir(dir, { recursive: true }); const fileStream = await this.read({ - folderPath: params.from.folderPath, - filename: params.from.filename, + filePath: `${params.from.folderPath}/${params.from.filename}`, }); const toPath = join( diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts index 6f22c3c817..bfd3317315 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts @@ -1,7 +1,9 @@ import { type DynamicModule, Global } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; @Global() @@ -9,7 +11,7 @@ export class FileStorageModule { static forRoot(): DynamicModule { return { module: FileStorageModule, - imports: [TwentyConfigModule], + imports: [TwentyConfigModule, TypeOrmModule.forFeature([FileEntity])], providers: [FileStorageDriverFactory, FileStorageService], exports: [FileStorageService], }; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts index cf555ab845..2fa0f6fb2c 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts @@ -1,28 +1,111 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { type Readable } from 'stream'; -import { Sources } from 'twenty-shared/types'; - -import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface'; +import { FileFolder, Sources } from 'twenty-shared/types'; +import { Repository } from 'typeorm'; import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; @Injectable() -export class FileStorageService implements StorageDriver { +//TODO: Implement storage driver interface when removing v1 +//export class FileStorageService implements StorageDriver { +export class FileStorageService { constructor( private readonly fileStorageDriverFactory: FileStorageDriverFactory, + @InjectRepository(FileEntity) + private readonly fileRepository: Repository, ) {} + /** + * @deprecated Use write_v2 instead + */ write(params: { file: string | Buffer | Uint8Array; name: string; folder: string; mimeType: string | undefined; }): Promise { + const { file, name, folder, mimeType } = params; + const driver = this.fileStorageDriverFactory.getCurrentDriver(); - return driver.write(params); + return driver.write({ + filePath: `${folder}/${name}`, + sourceFile: file, + mimeType, + }); + } + + async write_v2({ + sourceFile, + destinationPath, + mimeType, + fileFolder, + applicationId, + workspaceId, + fileId, + }: { + sourceFile: string | Buffer | Uint8Array; + destinationPath: string; + mimeType: string | undefined; + fileFolder: FileFolder; + applicationId: string; + workspaceId: string; + fileId?: string; + }): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + const driverParams = { + filePath: `${workspaceId}/${applicationId}/${fileFolder}/${destinationPath}`, + mimeType, + sourceFile, + }; + + await driver.write(driverParams); + + const fileEntity = await this.fileRepository.save({ + path: `${fileFolder}/${destinationPath}`, + workspaceId, + applicationId, + id: fileId, + size: + typeof sourceFile === 'string' + ? Buffer.byteLength(sourceFile) + : sourceFile.length, + }); + + return fileEntity; + } + + /** + * @deprecated Use read_v2 instead + */ + read(params: { folderPath: string; filename: string }): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const { folderPath, filename } = params; + + return driver.read({ filePath: `${folderPath}/${filename}` }); + } + + read_v2({ + destinationPath, + fileFolder, + applicationId, + workspaceId, + }: { + destinationPath: string; + fileFolder: FileFolder; + applicationId: string; + workspaceId: string; + }): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + const folderPath = `${workspaceId}/${applicationId}/${fileFolder}/${destinationPath}`; + + return driver.read({ filePath: folderPath }); } writeFolder(sources: Sources, folderPath: string): Promise { @@ -31,12 +114,6 @@ export class FileStorageService implements StorageDriver { return driver.writeFolder(sources, folderPath); } - read(params: { folderPath: string; filename: string }): Promise { - const driver = this.fileStorageDriverFactory.getCurrentDriver(); - - return driver.read(params); - } - readFolder(folderPath: string): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); diff --git a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts index 000ad6766f..293cbf0ff2 100644 --- a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts @@ -1,5 +1,3 @@ -import { ObjectType } from '@nestjs/graphql'; - import { Column, CreateDateColumn, @@ -17,7 +15,6 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity'; @Entity('file') -@ObjectType('File') @Index('IDX_FILE_WORKSPACE_ID', ['workspaceId']) export class FileEntity extends WorkspaceRelatedEntity { @PrimaryGeneratedColumn('uuid') diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver.ts index 8b42afde8b..cc91fc4807 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver.ts @@ -7,6 +7,7 @@ import { FileFolder } from 'twenty-shared/types'; import type { FileUpload } from 'graphql-upload/processRequest.mjs'; +import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto'; import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; @@ -24,7 +25,9 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer'; export class FileUploadResolver { constructor(private readonly fileUploadService: FileUploadService) {} - @Mutation(() => SignedFileDTO) + @Mutation(() => SignedFileDTO, { + deprecationReason: 'Use uploadFilesFieldFile instead', + }) @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) async uploadFile( @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, @@ -51,6 +54,28 @@ export class FileUploadResolver { return files[0]; } + @Mutation(() => FileDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) + async uploadFilesFieldFile( + @AuthWorkspace() + { id: workspaceId, workspaceCustomApplicationId }: WorkspaceEntity, + @Args({ name: 'file', type: () => GraphQLUpload }) + { createReadStream, filename, mimetype }: FileUpload, + ): Promise { + const stream = createReadStream(); + const buffer = await streamToBuffer(stream); + + const fileEntity = await this.fileUploadService.uploadFilesFieldFile({ + file: buffer, + filename, + declaredMimeType: mimetype, + workspaceId, + applicationId: workspaceCustomApplicationId, + }); + + return fileEntity; + } + @Mutation(() => SignedFileDTO) @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) async uploadImage( diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts index a166389bd4..572221d057 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts @@ -4,13 +4,15 @@ import { Injectable } from '@nestjs/common'; import DOMPurify from 'dompurify'; import FileType from 'file-type'; import sharp from 'sharp'; +import { FileFolder } from 'twenty-shared/types'; import { v4 } from 'uuid'; -import { type FileFolder } from 'twenty-shared/types'; import { settings } from 'src/engine/constants/settings'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils'; +import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils'; import { getCropSize, getImageBufferFromUrl } from 'src/utils/image'; export type SignedFile = { path: string; token: string }; @@ -68,6 +70,9 @@ export class FileUploadService { return file; } + /** + * @deprecated Use uploadWorkspaceRecordFile if uploading workspace records-scoped files. Or create your dedicated upload file service. + */ async uploadFile({ file, filename, @@ -206,4 +211,39 @@ export class FileUploadService { private getWorkspaceFolderName(workspaceId: string, fileFolder: FileFolder) { return `workspace-${workspaceId}/${fileFolder}`; } + + async uploadFilesFieldFile({ + file, + filename, + declaredMimeType, + workspaceId, + applicationId, + }: { + file: Buffer; + filename: string; + declaredMimeType: string | undefined; + workspaceId: string; + applicationId: string; + }): Promise { + const { mimeType, ext } = await extractFileInfo({ + file, + declaredMimeType, + filename, + }); + + const sanitizedFile = this._sanitizeFile({ file, ext, mimeType }); + + const fileId = v4(); + const name = `${fileId}${ext ? `.${ext}` : ''}`; + + return await this.fileStorage.write_v2({ + sourceFile: sanitizedFile, + destinationPath: name, + mimeType, + fileFolder: FileFolder.FilesField, + applicationId, + workspaceId, + fileId, + }); + } } diff --git a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts index 29df1768af..ae97b4e7e8 100644 --- a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts @@ -1,7 +1,7 @@ import { registerEnumType } from '@nestjs/graphql'; -import { type KebabCase } from 'type-fest'; import { FileFolder } from 'twenty-shared/types'; +import { type KebabCase } from 'type-fest'; registerEnumType(FileFolder, { name: 'FileFolder', @@ -48,6 +48,12 @@ export const fileFolderConfigs: Record = { [FileFolder.Source]: { ignoreExpirationToken: false, }, + [FileFolder.FilesField]: { + ignoreExpirationToken: false, + }, + [FileFolder.TemporaryFilesField]: { + ignoreExpirationToken: false, + }, }; export type AllowedFolders = KebabCase; diff --git a/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/extract-file-info.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/extract-file-info.utils.spec.ts new file mode 100644 index 0000000000..ae2708b202 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/extract-file-info.utils.spec.ts @@ -0,0 +1,108 @@ +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(), +})); + +describe('extractFileInfo', () => { + const mockBuffer = Buffer.from('test content'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should use detected file type when available', async () => { + (FileType.fromBuffer as jest.Mock).mockResolvedValue({ + mime: 'image/png', + ext: 'png', + }); + + const result = await extractFileInfo({ + file: mockBuffer, + declaredMimeType: 'text/plain', + filename: 'test.txt', + }); + + expect(result).toEqual({ + mimeType: 'image/png', + ext: 'png', + }); + }); + + it('should fall back to declared values when file type is not detected', async () => { + (FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined); + + 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', + }); + + expect(result).toEqual({ + mimeType: 'application/pdf', + ext: 'pdf', + }); + }); + + it('should handle both mime type and extension being undefined', async () => { + (FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined); + + const result = await extractFileInfo({ + file: mockBuffer, + declaredMimeType: undefined, + filename: 'file-without-extension', + }); + + expect(result).toEqual({ + mimeType: undefined, + ext: '', + }); + }); + + it('should handle filenames with multiple dots', async () => { + (FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined); + + const result = await extractFileInfo({ + file: mockBuffer, + declaredMimeType: 'application/gzip', + filename: 'archive.tar.gz', + }); + + expect(result).toEqual({ + mimeType: 'application/gzip', + ext: 'gz', + }); + }); + + it('should call FileType.fromBuffer with the provided buffer', async () => { + (FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined); + + await extractFileInfo({ + file: mockBuffer, + declaredMimeType: 'image/png', + filename: 'image.png', + }); + + expect(FileType.fromBuffer).toHaveBeenCalledWith(mockBuffer); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file/utils/extract-file-info.utils.ts b/packages/twenty-server/src/engine/core-modules/file/utils/extract-file-info.utils.ts new file mode 100644 index 0000000000..297b79e722 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/utils/extract-file-info.utils.ts @@ -0,0 +1,26 @@ +import FileType from 'file-type'; + +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 mimeType = detectedFileType?.mime ?? declaredMimeType; + + const ext = detectedFileType?.ext ?? declaredExt; + + return { + mimeType, + ext, + }; +}; diff --git a/packages/twenty-server/test/integration/graphql/suites/file-upload/upload-files-field-file.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/file-upload/upload-files-field-file.integration-spec.ts new file mode 100644 index 0000000000..4e0c0f9333 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/file-upload/upload-files-field-file.integration-spec.ts @@ -0,0 +1,77 @@ +import gql from 'graphql-tag'; +import { makeGraphqlAPIRequestWithFileUpload } from 'test/integration/graphql/utils/make-graphql-api-request-with-file-upload.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { FileFolder } from 'twenty-shared/types'; + +const uploadFilesFieldFileMutation = gql` + mutation uploadFilesFieldFile($file: Upload!) { + uploadFilesFieldFile(file: $file) { + id + path + size + createdAt + } + } +`; + +const deleteFileMutation = gql` + mutation DeleteFile($fileId: UUID!) { + deleteFile(fileId: $fileId) { + id + } + } +`; + +describe('uploadFilesFieldFile', () => { + let uploadedFileId: string | null = null; + + beforeAll(() => { + jest.useRealTimers(); + }); + + afterAll(async () => { + if (uploadedFileId) { + await makeGraphqlAPIRequest({ + query: deleteFileMutation, + variables: { fileId: uploadedFileId }, + }); + } + jest.useFakeTimers(); + }); + + it('should upload a file and return file metadata', async () => { + const testFileContent = 'Hello, this is a test file content'; + const testFileName = 'test-file.txt'; + const testMimeType = 'text/plain'; + + const response = await makeGraphqlAPIRequestWithFileUpload( + { + query: uploadFilesFieldFileMutation, + variables: { file: null }, + }, + { + field: 'file', + buffer: Buffer.from(testFileContent), + filename: testFileName, + contentType: testMimeType, + }, + ); + + expect(response.status).toBe(200); + expect(response.body.errors).toBeUndefined(); + expect(response.body.data).toBeDefined(); + + const fileResult = response.body.data.uploadFilesFieldFile; + + expect(fileResult).toBeDefined(); + expect(fileResult.id).toBeDefined(); + expect(typeof fileResult.id).toBe('string'); + expect(fileResult.path).toBeDefined(); + expect(typeof fileResult.path).toBe('string'); + expect(fileResult.path).toContain(FileFolder.FilesField); + expect(fileResult.size).toBe(testFileContent.length); + expect(fileResult.createdAt).toBeDefined(); + + uploadedFileId = fileResult.id; + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request-with-file-upload.util.ts b/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request-with-file-upload.util.ts new file mode 100644 index 0000000000..7b87298d08 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/make-graphql-api-request-with-file-upload.util.ts @@ -0,0 +1,46 @@ +import { type ASTNode, print } from 'graphql'; +import request from 'supertest'; +import { isDefined } from 'twenty-shared/utils'; + +type FileUploadOperation = { + query: ASTNode; + variables?: Record; +}; + +type FileAttachment = { + field: string; + buffer: Buffer; + filename: string; + contentType: string; +}; + +export const makeGraphqlAPIRequestWithFileUpload = ( + graphqlOperation: FileUploadOperation, + fileAttachment: FileAttachment, + token: string | undefined = APPLE_JANE_ADMIN_ACCESS_TOKEN, +) => { + const client = request(`http://localhost:${APP_PORT}`); + + const clientInstance = client.post('/graphql'); + + if (isDefined(token)) { + clientInstance.set('Authorization', `Bearer ${token}`); + } + + return clientInstance + .field( + 'operations', + JSON.stringify({ + query: print(graphqlOperation.query), + variables: graphqlOperation.variables || {}, + }), + ) + .field( + 'map', + JSON.stringify({ '0': [`variables.${fileAttachment.field}`] }), + ) + .attach('0', fileAttachment.buffer, { + filename: fileAttachment.filename, + contentType: fileAttachment.contentType, + }); +}; diff --git a/packages/twenty-server/test/integration/utils/create-app.ts b/packages/twenty-server/test/integration/utils/create-app.ts index 02218915da..4b8cebb331 100644 --- a/packages/twenty-server/test/integration/utils/create-app.ts +++ b/packages/twenty-server/test/integration/utils/create-app.ts @@ -6,8 +6,12 @@ import { type TestingModuleBuilder, } from '@nestjs/testing'; +import bytes from 'bytes'; +import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; + import { AppModule } from 'src/app.module'; import { CommandModule } from 'src/command/command.module'; +import { settings } from 'src/engine/constants/settings'; import { StripeSDKMockService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service'; import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; import { CAPTCHA_DRIVER } from 'src/engine/core-modules/captcha/constants/captcha-driver.constants'; @@ -75,6 +79,22 @@ export const createApp = async ( cors: true, }); + app.use( + '/graphql', + graphqlUploadExpress({ + maxFieldSize: bytes(settings.storage.maxFileSize), + maxFiles: 10, + }), + ); + + app.use( + '/metadata', + graphqlUploadExpress({ + maxFieldSize: bytes(settings.storage.maxFileSize), + maxFiles: 10, + }), + ); + if (config.appInitHook) { await config.appInitHook(app); } diff --git a/packages/twenty-shared/src/types/FileFolder.ts b/packages/twenty-shared/src/types/FileFolder.ts index 82742ac26e..ad46ceca97 100644 --- a/packages/twenty-shared/src/types/FileFolder.ts +++ b/packages/twenty-shared/src/types/FileFolder.ts @@ -11,4 +11,6 @@ export enum FileFolder { BuiltFrontComponent = 'built-front-component', Asset = 'asset', Source = 'source', + TemporaryFilesField = 'temporary-files-field', + FilesField = 'files-field', }