Add endpoint to upload application file (#17270)

As title
This commit is contained in:
martmull
2026-01-20 15:10:34 +01:00
committed by GitHub
parent 801878cb2b
commit 02a181a49f
24 changed files with 302 additions and 39 deletions
@@ -1567,12 +1567,16 @@ export type File = {
export enum FileFolder {
AgentChat = 'AgentChat',
Assets = 'Assets',
Attachment = 'Attachment',
File = 'File',
FrontComponents = 'FrontComponents',
Functions = 'Functions',
PersonPicture = 'PersonPicture',
ProfilePicture = 'ProfilePicture',
ServerlessFunction = 'ServerlessFunction',
ServerlessFunctionToDelete = 'ServerlessFunctionToDelete',
SourceCode = 'SourceCode',
WorkspaceLogo = 'WorkspaceLogo'
}
@@ -2130,6 +2134,7 @@ export type Mutation = {
updateWorkspace: Workspace;
updateWorkspaceFeatureFlag: Scalars['Boolean'];
updateWorkspaceMemberRole: WorkspaceMember;
uploadApplicationFile: File;
uploadFile: SignedFile;
uploadImage: SignedFile;
uploadWorkspaceLogo: SignedFile;
@@ -3045,6 +3050,14 @@ export type MutationUpdateWorkspaceMemberRoleArgs = {
};
export type MutationUploadApplicationFileArgs = {
applicationUniversalIdentifier: Scalars['String'];
file: Scalars['Upload'];
fileFolder: FileFolder;
filePath: Scalars['String'];
};
export type MutationUploadFileArgs = {
file: Scalars['Upload'];
fileFolder?: InputMaybe<FileFolder>;
@@ -1544,12 +1544,16 @@ export type File = {
export enum FileFolder {
AgentChat = 'AgentChat',
Assets = 'Assets',
Attachment = 'Attachment',
File = 'File',
FrontComponents = 'FrontComponents',
Functions = 'Functions',
PersonPicture = 'PersonPicture',
ProfilePicture = 'ProfilePicture',
ServerlessFunction = 'ServerlessFunction',
ServerlessFunctionToDelete = 'ServerlessFunctionToDelete',
SourceCode = 'SourceCode',
WorkspaceLogo = 'WorkspaceLogo'
}
@@ -2096,6 +2100,7 @@ export type Mutation = {
updateWorkspace: Workspace;
updateWorkspaceFeatureFlag: Scalars['Boolean'];
updateWorkspaceMemberRole: WorkspaceMember;
uploadApplicationFile: File;
uploadFile: SignedFile;
uploadImage: SignedFile;
uploadWorkspaceLogo: SignedFile;
@@ -2955,6 +2960,14 @@ export type MutationUpdateWorkspaceMemberRoleArgs = {
};
export type MutationUploadApplicationFileArgs = {
applicationUniversalIdentifier: Scalars['String'];
file: Scalars['Upload'];
fileFolder: FileFolder;
filePath: Scalars['String'];
};
export type MutationUploadFileArgs = {
file: Scalars['Upload'];
fileFolder?: InputMaybe<FileFolder>;
@@ -6,12 +6,15 @@ import {
printSchema,
} from 'graphql/index';
import { createClient } from 'graphql-sse';
import * as fs from 'fs';
import * as path from 'path';
import { type ApiResponse } from '../types/api-response.types';
import { ConfigService } from '@/cli/utilities/config/services/config.service';
import {
type PackageJson,
type ApplicationManifest,
} from 'twenty-shared/application';
import { type FileFolder } from 'twenty-shared/types';
export class ApiService {
private client: AxiosInstance;
@@ -414,4 +417,133 @@ export class ApiService {
},
);
}
async uploadFile({
filePath,
fileFolder,
applicationUniversalIdentifier,
}: {
filePath: string;
fileFolder: FileFolder;
applicationUniversalIdentifier: string;
}): Promise<ApiResponse<boolean>> {
try {
const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) {
return {
success: false,
error: `File not found: ${absolutePath}`,
};
}
const filename = path.basename(absolutePath);
const buffer = fs.readFileSync(absolutePath);
const mimeType = this.getMimeType(filename);
const mutation = `
mutation UploadApplicationFile($file: Upload!, $applicationUniversalIdentifier: String!, $fileFolder: FileFolder!, $filePath: String!) {
uploadApplicationFile(file: $file, applicationUniversalIdentifier: $applicationUniversalIdentifier, fileFolder: $fileFolder, filePath: $filePath)
{ path }
}
`;
const operations = JSON.stringify({
query: mutation,
variables: {
file: null,
applicationUniversalIdentifier,
filePath,
fileFolder,
},
});
const map = JSON.stringify({
'0': ['variables.file'],
});
const formData = new FormData();
formData.append('operations', operations);
formData.append('map', map);
formData.append(
'0',
new Blob([new Uint8Array(buffer)], { type: mimeType }),
filename,
);
const response: AxiosResponse = await this.client.post(
'/graphql',
formData,
);
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.message || 'Failed to upload file',
};
}
return {
success: true,
data: response.data.data.uploadApplicationFile,
message: `Successfully uploaded ${filename}`,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
return {
success: false,
error,
};
}
}
private getMimeType(filename: string): string {
const ext = path.extname(filename).toLowerCase();
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx':
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx':
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.csv': 'text/csv',
'.json': 'application/json',
'.xml': 'application/xml',
'.zip': 'application/zip',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.js': 'application/javascript',
'.ts': 'application/typescript',
'.jsx': 'application/javascript',
'.tsx': 'application/typescript',
'.html': 'text/html',
'.css': 'text/css',
};
return mimeTypes[ext] || 'application/octet-stream';
}
}
@@ -1,4 +1,4 @@
import { type FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { type FileFolder } from 'twenty-shared/types';
import { type ShortCropSize } from 'src/utils/image';
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-sync.service';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
@@ -20,9 +21,12 @@ import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@Module({
imports: [
TypeOrmModule.forFeature([FileEntity]),
ApplicationModule,
ApplicationVariableEntityModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
@@ -40,6 +44,7 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
ObjectPermissionModule,
PermissionFlagModule,
WorkflowCommonModule,
FileStorageModule,
],
providers: [
ApplicationResolver,
@@ -1,7 +1,15 @@
import { UseFilters, UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import path, { join } from 'path';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type Repository } from 'typeorm';
import { FileFolder } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
@@ -11,12 +19,21 @@ import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/applica
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
@UseGuards(
WorkspaceAuthGuard,
@@ -29,6 +46,9 @@ export class ApplicationResolver {
constructor(
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationService: ApplicationService,
private readonly fileStorageService: FileStorageService,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
) {}
@Query(() => [ApplicationDTO])
@@ -75,4 +95,61 @@ export class ApplicationResolver {
return true;
}
@Mutation(() => FileDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadApplicationFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, mimetype }: FileUpload,
@Args()
{
applicationUniversalIdentifier,
fileFolder,
filePath,
}: UploadApplicationFileInput,
): Promise<FileDTO> {
const allowedApplicationFileFolders: FileFolder[] = [
FileFolder.Functions,
FileFolder.FrontComponents,
FileFolder.Assets,
FileFolder.SourceCode,
];
if (!allowedApplicationFileFolders.includes(fileFolder)) {
throw new ApplicationException(
`Invalid fileFolder for application file upload. Allowed values: ${allowedApplicationFileFolders.join(', ')}`,
ApplicationExceptionCode.INVALID_INPUT,
);
}
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const dirname = path.dirname(filePath);
const filename = path.basename(filePath);
const folderPath = join(
`workspace-${workspaceId}`,
applicationUniversalIdentifier,
fileFolder,
dirname,
);
await this.fileStorageService.write({
file: buffer,
name: filename,
folder: folderPath,
mimeType: mimetype,
});
const createdFile = this.fileRepository.create({
path: join(folderPath, filename),
size: buffer.length,
workspaceId,
});
return await this.fileRepository.save(createdFile);
}
}
@@ -0,0 +1,15 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { FileFolder } from 'twenty-shared/types';
@ArgsType()
export class UploadApplicationFileInput {
@Field(() => String)
applicationUniversalIdentifier: string;
@Field(() => FileFolder)
fileFolder: FileFolder;
@Field(() => String)
filePath: string;
}
@@ -3,8 +3,7 @@ import { Args, Mutation, Resolver } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileFolder } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
@@ -5,8 +5,7 @@ import DOMPurify from 'dompurify';
import FileType from 'file-type';
import sharp from 'sharp';
import { v4 } from 'uuid';
import { type FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
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';
@@ -1,17 +1,7 @@
import { registerEnumType } from '@nestjs/graphql';
import { type KebabCase } from 'type-fest';
export enum FileFolder {
ProfilePicture = 'profile-picture',
WorkspaceLogo = 'workspace-logo',
Attachment = 'attachment',
PersonPicture = 'person-picture',
ServerlessFunction = 'serverless-function',
ServerlessFunctionToDelete = 'serverless-function-to-delete',
File = 'file',
AgentChat = 'agent-chat',
}
import { FileFolder } from 'twenty-shared/types';
registerEnumType(FileFolder, {
name: 'FileFolder',
@@ -46,6 +36,18 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.AgentChat]: {
ignoreExpirationToken: false,
},
[FileFolder.Functions]: {
ignoreExpirationToken: false,
},
[FileFolder.FrontComponents]: {
ignoreExpirationToken: false,
},
[FileFolder.Assets]: {
ignoreExpirationToken: true,
},
[FileFolder.SourceCode]: {
ignoreExpirationToken: false,
},
};
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
@@ -3,8 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { extractFolderPathFilenameAndTypeOrThrow } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileFolder } from 'twenty-shared/types';
import { type FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileFolder } from 'twenty-shared/types';
import { checkFileFolder } from 'src/engine/core-modules/file/utils/check-file-folder.utils';
@@ -1,4 +1,4 @@
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileFolder } from 'twenty-shared/types';
import { checkFilename } from 'src/engine/core-modules/file/utils/check-file-name.utils';
@@ -1,4 +1,4 @@
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileFolder } from 'twenty-shared/types';
import { checkFilePath } from 'src/engine/core-modules/file/utils/check-file-path.utils';
@@ -1,9 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import {
type AllowedFolders,
FileFolder,
} from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileFolder } from 'twenty-shared/types';
import { type AllowedFolders } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { kebabCase } from 'src/utils/kebab-case';
@@ -1,9 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import {
type AllowedFolders,
FileFolder,
} from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileFolder } from 'twenty-shared/types';
import { type AllowedFolders } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { kebabCase } from 'src/utils/kebab-case';
import { settings } from 'src/engine/constants/settings';
@@ -1,8 +1,7 @@
import { join } from 'path';
import { isDefined } from 'twenty-shared/utils';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileFolder } from 'twenty-shared/types';
import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import {
@@ -9,12 +9,12 @@ import {
type CodeExecutionState,
} from 'twenty-shared/ai';
import { v4 } from 'uuid';
import { FileFolder } from 'twenty-shared/types';
import {
type InputFile,
type OutputFile,
} from 'src/engine/core-modules/code-interpreter/drivers/interfaces/code-interpreter-driver.interface';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import {
type AccessTokenJwtPayload,
@@ -2,8 +2,7 @@ import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileFolder } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
@@ -2,8 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type DataSource, type Repository } from 'typeorm';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileFolder } from 'twenty-shared/types';
import { type ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service';
@@ -4,9 +4,9 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { type QueryRunner, IsNull, Not, type Repository } from 'typeorm';
import { FileFolder } from 'twenty-shared/types';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service';
@@ -19,8 +19,7 @@ import assert from 'assert';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileFolder } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
@@ -0,0 +1,14 @@
export enum FileFolder {
ProfilePicture = 'profile-picture',
WorkspaceLogo = 'workspace-logo',
Attachment = 'attachment',
PersonPicture = 'person-picture',
ServerlessFunction = 'serverless-function',
ServerlessFunctionToDelete = 'serverless-function-to-delete',
File = 'file',
AgentChat = 'agent-chat',
Functions = 'functions',
FrontComponents = 'front-components',
Assets = 'assets',
SourceCode = 'source-code',
}
@@ -108,6 +108,7 @@ export type {
export { NumberDataType, DateDisplayFormat } from './FieldMetadataSettings';
export { FieldMetadataType } from './FieldMetadataType';
export type { FieldRatingValue } from './FieldRatingValue';
export { FileFolder } from './FileFolder';
export type {
FilterableFieldType,
FilterableAndTSVectorFieldType,