Files v2 - Add new workspace field file upload resolver (#17325)
This commit is contained in:
@@ -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')
|
||||
|
||||
+26
-1
@@ -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(
|
||||
|
||||
+41
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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>;
|
||||
|
||||
+108
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user