Files v2 - Add new workspace field file upload resolver (#17325)
This commit is contained in:
@@ -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<FileFolder>;
|
||||
|
||||
@@ -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<FileFolder>;
|
||||
|
||||
+21
-4
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+3
-4
@@ -4,12 +4,11 @@ import { type Sources } from 'twenty-shared/types';
|
||||
|
||||
export interface StorageDriver {
|
||||
delete(params: { folderPath: string; filename?: string }): Promise<void>;
|
||||
read(params: { folderPath: string; filename: string }): Promise<Readable>;
|
||||
read(params: { filePath: string }): Promise<Readable>;
|
||||
readFolder(folderPath: string): Promise<Sources>;
|
||||
write(params: {
|
||||
file: Buffer | Uint8Array | string;
|
||||
name: string;
|
||||
folder: string;
|
||||
filePath: string;
|
||||
sourceFile: Buffer | Uint8Array | string;
|
||||
mimeType: string | undefined;
|
||||
}): Promise<void>;
|
||||
writeFolder(sources: Sources, folderPath: string): Promise<void>;
|
||||
|
||||
+8
-21
@@ -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<void> {
|
||||
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<Readable> {
|
||||
const joinedPath = join(
|
||||
`${this.options.storagePath}/`,
|
||||
params.folderPath,
|
||||
params.filename,
|
||||
);
|
||||
async read(params: { filePath: string }): Promise<Readable> {
|
||||
const joinedPath = join(`${this.options.storagePath}/`, params.filePath);
|
||||
let filePath: string;
|
||||
|
||||
try {
|
||||
|
||||
@@ -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<void> {
|
||||
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<Readable> {
|
||||
async read(params: { filePath: string }): Promise<Readable> {
|
||||
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(
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
+88
-11
@@ -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<FileEntity>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated Use write_v2 instead
|
||||
*/
|
||||
write(params: {
|
||||
file: string | Buffer | Uint8Array;
|
||||
name: string;
|
||||
folder: string;
|
||||
mimeType: string | undefined;
|
||||
}): Promise<void> {
|
||||
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<FileEntity> {
|
||||
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<Readable> {
|
||||
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<Readable> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
const folderPath = `${workspaceId}/${applicationId}/${fileFolder}/${destinationPath}`;
|
||||
|
||||
return driver.read({ filePath: folderPath });
|
||||
}
|
||||
|
||||
writeFolder(sources: Sources, folderPath: string): Promise<void> {
|
||||
@@ -31,12 +114,6 @@ export class FileStorageService implements StorageDriver {
|
||||
return driver.writeFolder(sources, folderPath);
|
||||
}
|
||||
|
||||
read(params: { folderPath: string; filename: string }): Promise<Readable> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
return driver.read(params);
|
||||
}
|
||||
|
||||
readFolder(folderPath: string): Promise<Sources> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
+77
@@ -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;
|
||||
});
|
||||
});
|
||||
+46
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ export enum FileFolder {
|
||||
BuiltFrontComponent = 'built-front-component',
|
||||
Asset = 'asset',
|
||||
Source = 'source',
|
||||
TemporaryFilesField = 'temporary-files-field',
|
||||
FilesField = 'files-field',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user