Files v2 - Add new workspace field file upload resolver (#17325)

This commit is contained in:
Etienne
2026-01-22 14:15:52 +01:00
committed by GitHub
parent 96bdae4582
commit 0459f25dec
18 changed files with 505 additions and 64 deletions
@@ -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')
@@ -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<FileDTO> {
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(
@@ -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<FileEntity> {
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,
});
}
}
@@ -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, FileFolderConfig> = {
[FileFolder.Source]: {
ignoreExpirationToken: false,
},
[FileFolder.FilesField]: {
ignoreExpirationToken: false,
},
[FileFolder.TemporaryFilesField]: {
ignoreExpirationToken: false,
},
};
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
@@ -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);
});
});
@@ -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,
};
};