diff --git a/packages/twenty-eslint-rules/rules/rest-api-methods-should-be-guarded.ts b/packages/twenty-eslint-rules/rules/rest-api-methods-should-be-guarded.ts index 84685e70ed..b58ae53f72 100644 --- a/packages/twenty-eslint-rules/rules/rest-api-methods-should-be-guarded.ts +++ b/packages/twenty-eslint-rules/rules/rest-api-methods-should-be-guarded.ts @@ -52,11 +52,11 @@ export const rule = createRule<[], 'restApiMethodsShouldBeGuarded'>({ meta: { docs: { description: - 'REST API endpoints should have authentication guards (UserAuthGuard, WorkspaceAuthGuard, or FilePathGuard) or be explicitly marked as public (PublicEndpointGuard) and permission guards (SettingsPermissionsGuard or CustomPermissionGuard) to maintain our security model.', + 'REST API endpoints should have authentication guards (UserAuthGuard, WorkspaceAuthGuard, FilePathGuard, or FilesFieldGuard) or be explicitly marked as public (PublicEndpointGuard) and permission guards (SettingsPermissionsGuard or CustomPermissionGuard) to maintain our security model.', }, messages: { restApiMethodsShouldBeGuarded: - 'All REST API controller endpoints must have authentication guards (@UseGuards(UserAuthGuard/WorkspaceAuthGuard/FilePathGuard/PublicEndpointGuard)) and permission guards (@UseGuards(..., SettingsPermissionsGuard(PermissionFlagType.XXX)), CustomPermissionGuard for custom logic, or NoPermissionGuard for special cases).', + 'All REST API controller endpoints must have authentication guards (@UseGuards(UserAuthGuard/WorkspaceAuthGuard/FilePathGuard/FileIdGuard/FilesFieldGuard/PublicEndpointGuard)) and permission guards (@UseGuards(..., SettingsPermissionsGuard(PermissionFlagType.XXX)), CustomPermissionGuard for custom logic, or NoPermissionGuard for special cases).', }, schema: [], hasSuggestions: false, diff --git a/packages/twenty-eslint-rules/utils/typedTokenHelpers.ts b/packages/twenty-eslint-rules/utils/typedTokenHelpers.ts index 7f71237756..bbb14c744c 100644 --- a/packages/twenty-eslint-rules/utils/typedTokenHelpers.ts +++ b/packages/twenty-eslint-rules/utils/typedTokenHelpers.ts @@ -42,14 +42,15 @@ export const typedTokenHelpers = { TSESTree.AST_NODE_TYPES.Identifier && decorator.expression.callee.name === 'UseGuards' ) { - // Check the arguments for UserAuthGuard, WorkspaceAuthGuard, PublicEndpoint, or FilePathGuard + // Check the arguments for UserAuthGuard, WorkspaceAuthGuard, PublicEndpoint, FilePathGuard, or FilesFieldGuard return decorator.expression.arguments.some((arg) => { if (arg.type === TSESTree.AST_NODE_TYPES.Identifier) { return ( arg.name === 'UserAuthGuard' || arg.name === 'WorkspaceAuthGuard' || arg.name === 'PublicEndpointGuard' || - arg.name === 'FilePathGuard' + arg.name === 'FilePathGuard' || + arg.name === 'FilesFieldGuard' ); } return false; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.guard.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.guard.ts new file mode 100644 index 0000000000..78e39b651d --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.guard.ts @@ -0,0 +1,20 @@ +import { isDefined } from 'twenty-shared/utils'; + +import type { FileOutput } from './file-item.type'; + +export const isFileOutputArray = (value: unknown): value is FileOutput[] => { + return ( + Array.isArray(value) && + value.every( + (item): item is FileOutput => + isDefined(item) && + typeof item === 'object' && + 'fileId' in item && + typeof item.fileId === 'string' && + 'label' in item && + typeof item.label === 'string' && + 'extension' in item && + typeof item.extension === 'string', + ) + ); +}; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type.ts index 43488dce6b..e90f029932 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type.ts @@ -1,4 +1,12 @@ -export type FileItemInput = { +export type FileInput = { fileId: string; label: string; }; + +export type FileOutput = FileInput & { + extension: string; +}; + +export type SignedFileOutput = FileOutput & { + url: string; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts index 9e19b393be..8a8f29d6ab 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts @@ -8,7 +8,7 @@ import { } from 'twenty-shared/types'; import { z } from 'zod'; -import { type FileItemInput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type'; +import { type FileInput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type'; import { CommonQueryRunnerException, CommonQueryRunnerExceptionCode, @@ -27,7 +27,7 @@ export const validateFilesFieldOrThrow = ( value: unknown, fieldName: string, settings: FieldMetadataSettingsMapping[FieldMetadataType.FILES], -): FileItemInput[] | null => { +): FileInput[] | null => { if (isNull(value)) return null; let parsedValue: unknown = value; diff --git a/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts b/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts index 437ede452f..293f03acf7 100644 --- a/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { FieldMetadataType, @@ -10,10 +10,12 @@ import { isDefined } from 'twenty-shared/utils'; import { type QueryResultFieldValue } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-field-value'; import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface'; +import { FilesFieldQueryResultGetterHandler } from 'src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler'; import { ActivityQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/activity-query-result-getter.handler'; import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/attachment-query-result-getter.handler'; import { PersonQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler'; import { WorkspaceMemberQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler'; +import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; @@ -30,15 +32,22 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object // Right now the factory will override any change made on relations by the handlers @Injectable() export class CommonResultGettersService { - private readonly logger = new Logger(CommonResultGettersService.name); - private handlers: Map; + private objectHandlers: Map; + private fieldHandlers: Map< + FieldMetadataType, + QueryResultGetterHandlerInterface + >; - constructor(private readonly fileService: FileService) { - this.initializeHandlers(); + constructor( + private readonly fileService: FileService, + private readonly filesFieldService: FilesFieldService, + ) { + this.initializeObjectHandlers(); + this.initializeFieldHandlers(); } - private initializeHandlers() { - this.handlers = new Map([ + private initializeObjectHandlers() { + this.objectHandlers = new Map([ ['attachment', new AttachmentQueryResultGetterHandler(this.fileService)], ['person', new PersonQueryResultGetterHandler(this.fileService)], [ @@ -50,6 +59,18 @@ export class CommonResultGettersService { ]); } + private initializeFieldHandlers() { + this.fieldHandlers = new Map< + FieldMetadataType, + QueryResultGetterHandlerInterface + >([ + [ + FieldMetadataType.FILES, + new FilesFieldQueryResultGetterHandler(this.filesFieldService), + ], + ]); + } + public async processRecordArray( recordArray: ObjectRecord[], flatObjectMetadata: FlatObjectMetadata, @@ -85,8 +106,6 @@ export class CommonResultGettersService { workspaceId: string, fieldMapsForObject?: FieldMapsForObject, ): Promise { - const handler = this.getHandler(flatObjectMetadata.nameSingular); - const fieldMaps = fieldMapsForObject ?? buildFieldMapsFromFlatObjectMetadata( @@ -96,6 +115,18 @@ export class CommonResultGettersService { const { fieldIdByName } = fieldMaps; + const handlers = [ + this.getObjectHandler(flatObjectMetadata.nameSingular), + ...Object.keys(record) + .map( + (recordFieldName) => + flatFieldMetadataMaps.byId[fieldIdByName[recordFieldName]], + ) + .filter(isDefined) + .map((fieldMetadata) => this.fieldHandlers.get(fieldMetadata.type)) + .filter(isDefined), + ]; + const relationFields = Object.keys(record) .map( (recordFieldName) => @@ -146,10 +177,20 @@ export class CommonResultGettersService { ); } - const objectRecordProcessedWithoutRelationFields = await handler.handle( - record, - workspaceId, - ); + const fieldMetadata = Object.keys(record) + .map( + (recordFieldName) => + flatFieldMetadataMaps.byId[fieldIdByName[recordFieldName]], + ) + .filter(isDefined); + + const objectRecordProcessedWithoutRelationFields = + await this.processObjectRecordWithoutRelationFields( + record, + workspaceId, + handlers, + fieldMetadata, + ); const processedRecord = { ...objectRecordProcessedWithoutRelationFields, @@ -159,9 +200,33 @@ export class CommonResultGettersService { return processedRecord; } - private getHandler(objectType: string): QueryResultGetterHandlerInterface { + private async processObjectRecordWithoutRelationFields( + record: ObjectRecord, + workspaceId: string, + handlers: QueryResultGetterHandlerInterface[], + fieldMetadata: FlatFieldMetadata[], + ): Promise { + let processedRecord = record; + + for (const handler of handlers) { + processedRecord = await handler.handle( + processedRecord, + workspaceId, + fieldMetadata, + ); + } + + return processedRecord; + } + + private getObjectHandler( + objectType: string, + ): QueryResultGetterHandlerInterface { return ( - this.handlers.get(objectType) || { + (this.objectHandlers.get(objectType) || { + handle: (result: ObjectRecord): Promise => + Promise.resolve(result), + }) ?? { handle: (result: ObjectRecord): Promise => Promise.resolve(result), } diff --git a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler.ts new file mode 100644 index 0000000000..96585897b6 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler.ts @@ -0,0 +1,54 @@ +import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types'; + +import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface'; + +import { isFileOutputArray } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.guard'; +import type { SignedFileOutput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type'; +import { type FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +export class FilesFieldQueryResultGetterHandler + implements QueryResultGetterHandlerInterface +{ + constructor(private readonly filesFieldService: FilesFieldService) {} + + async handle( + record: ObjectRecord, + workspaceId: string, + flatFieldMetadata: FlatFieldMetadata[], + ): Promise { + const filesFields = flatFieldMetadata.filter( + (field) => field.type === FieldMetadataType.FILES, + ); + + if (filesFields.length === 0) { + return record; + } + + for (const field of filesFields) { + const filesFieldValue = record[field.name]; + + if (!isFileOutputArray(filesFieldValue)) { + continue; + } + + const signedFilesFieldValue: SignedFileOutput[] = []; + + for (const file of filesFieldValue) { + const url = this.filesFieldService.signFileUrl({ + fileId: file.fileId, + workspaceId, + }); + + signedFilesFieldValue.push({ + ...file, + url, + }); + } + + record[field.name] = signedFilesFieldValue; + } + + return record; + } +} diff --git a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts index 74838bc308..7bce31c787 100644 --- a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts +++ b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts @@ -13,6 +13,7 @@ import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-que import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; +import { FilesFieldModule } from 'src/engine/core-modules/file/files-field/files-field.module'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module'; import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module'; @@ -34,6 +35,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache UserRoleModule, ApiKeyModule, FileModule, + FilesFieldModule, ViewModule, ViewFilterModule, ViewFilterGroupModule, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface.ts index 5e757e0000..3db3b1aa3d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface.ts @@ -1,8 +1,11 @@ import { type ObjectRecord } from 'twenty-shared/types'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + export interface QueryResultGetterHandlerInterface { handle( objectRecord: ObjectRecord, workspaceId: string, + flatFieldMetadata: FlatFieldMetadata[], ): Promise; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts index 12dc821a01..fac56b6169 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts @@ -13,8 +13,7 @@ const FileObjectType = new GraphQLObjectType({ fileId: { type: new GraphQLNonNull(UUIDScalarType) }, label: { type: new GraphQLNonNull(GraphQLString) }, extension: { type: GraphQLString }, - //TODO: Will be made non-nullable in a future PR - // extension: { type: new GraphQLNonNull(GraphQLString) }, + url: { type: GraphQLString }, }, }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index e03cb008e7..225971d8db 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -57,6 +57,12 @@ export type FileTokenJwtPayload = CommonPropertiesJwtPayload & { personId?: string; }; +export type FilesFieldTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.FILE; + workspaceId: string; + fileId: string; +}; + export type LoginTokenJwtPayload = CommonPropertiesJwtPayload & { type: JwtTokenTypeEnum.LOGIN; workspaceId: string; @@ -127,4 +133,5 @@ export type JwtPayload = | TransientTokenJwtPayload | RefreshTokenJwtPayload | FileTokenJwtPayload + | FilesFieldTokenJwtPayload | PostgresProxyTokenJwtPayload; diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts index dbc2f79e4a..1da6e3c49d 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts @@ -1,20 +1,13 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileUploadResolver } from 'src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @Module({ - imports: [ - FileModule, - HttpModule, - PermissionsModule, - TypeOrmModule.forFeature([ApplicationEntity]), - ], + imports: [FileModule, HttpModule, PermissionsModule], providers: [FileUploadService, FileUploadResolver], exports: [FileUploadService, FileUploadResolver], }) 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 cc91fc4807..7515ac4715 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,7 +7,6 @@ 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'; @@ -54,28 +53,6 @@ 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 8c2bc4784e..dcce3b7d5a 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 @@ -1,21 +1,16 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import DOMPurify from 'dompurify'; import FileType from 'file-type'; import sharp from 'sharp'; import { FileFolder } from 'twenty-shared/types'; -import { Repository } from 'typeorm'; import { v4 } from 'uuid'; import { settings } from 'src/engine/constants/settings'; -import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; 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 { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils'; import { getCropSize, getImageBufferFromUrl } from 'src/utils/image'; export type SignedFile = { path: string; token: string }; @@ -32,8 +27,6 @@ export class FileUploadService { private readonly fileStorage: FileStorageService, private readonly fileService: FileService, private readonly httpService: HttpService, - @InjectRepository(ApplicationEntity) - private readonly applicationRepository: Repository, ) {} private async _uploadFile({ @@ -55,26 +48,6 @@ export class FileUploadService { }); } - private _sanitizeFile({ - file, - ext, - mimeType, - }: { - file: Buffer | Uint8Array | string; - ext: string; - mimeType: string | undefined; - }): Buffer | Uint8Array | string { - if (ext === 'svg' || mimeType === 'image/svg+xml') { - const { JSDOM } = require('jsdom'); - const window = new JSDOM('').window; - const purify = DOMPurify(window); - - return purify.sanitize(file.toString()); - } - - return file; - } - /** * @deprecated Use uploadWorkspaceRecordFile if uploading workspace records-scoped files. Or create your dedicated upload file service. */ @@ -95,7 +68,7 @@ export class FileUploadService { const folder = this.getWorkspaceFolderName(workspaceId, fileFolder); await this._uploadFile({ - file: this._sanitizeFile({ file, ext, mimeType }), + file: sanitizeFile({ file, ext, mimeType }), filename: name, mimeType, folder, @@ -216,50 +189,4 @@ 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}` : ''}`; - - const application = await this.applicationRepository.findOneOrFail({ - where: { - id: applicationId, - workspaceId, - }, - }); - - return await this.fileStorage.write_v2({ - sourceFile: sanitizedFile, - destinationPath: name, - mimeType, - fileFolder: FileFolder.FilesField, - applicationUniversalIdentifier: application.universalIdentifier, - workspaceId, - fileId, - settings: { - isTemporaryFile: true, - toDelete: false, - }, - }); - } } diff --git a/packages/twenty-server/src/engine/core-modules/file/file.module.ts b/packages/twenty-server/src/engine/core-modules/file/file.module.ts index a6db918773..5584e3ce45 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file.module.ts @@ -4,8 +4,6 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; -import { FilesFieldDeletionJob } from 'src/engine/core-modules/file/files-field/jobs/files-field-deletion.job'; -import { FilesFieldDeletionListener } from 'src/engine/core-modules/file/files-field/listeners/files-field-deletion.listener'; import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard'; import { FileDeletionJob } from 'src/engine/core-modules/file/jobs/file-deletion.job'; import { FileWorkspaceFolderDeletionJob } from 'src/engine/core-modules/file/jobs/file-workspace-folder-deletion.job'; @@ -13,13 +11,12 @@ import { FileAttachmentListener } from 'src/engine/core-modules/file/listeners/f import { FileWorkspaceMemberListener } from 'src/engine/core-modules/file/listeners/file-workspace-member.listener'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { FileController } from './controllers/file.controller'; import { FileEntity } from './entities/file.entity'; import { FileUploadService } from './file-upload/services/file-upload.service'; -import { FilesFieldService } from './files-field/files-field.service'; +import { FilesFieldModule } from './files-field/files-field.module'; import { FileResolver } from './resolvers/file.resolver'; import { FileMetadataService } from './services/file-metadata.service'; import { FileService } from './services/file.service'; @@ -31,23 +28,20 @@ import { FileService } from './services/file.service'; HttpModule, PermissionsModule, FileStorageModule, - WorkspaceManyOrAllFlatEntityMapsCacheModule, + FilesFieldModule, ], providers: [ FileService, FileMetadataService, - FilesFieldService, FileResolver, FilePathGuard, FileAttachmentListener, FileWorkspaceMemberListener, - FilesFieldDeletionListener, FileWorkspaceFolderDeletionJob, FileDeletionJob, - FilesFieldDeletionJob, FileUploadService, ], - exports: [FileService, FileMetadataService, FilesFieldService], + exports: [FileService, FileMetadataService], controllers: [FileController], }) export class FileModule {} diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/controllers/files-field.controller.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/controllers/files-field.controller.ts new file mode 100644 index 0000000000..f6e51f6692 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/controllers/files-field.controller.ts @@ -0,0 +1,73 @@ +import { + Controller, + Get, + Param, + Req, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { Request, Response } from 'express'; + +import { + FileStorageException, + FileStorageExceptionCode, +} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; + +import { + FileException, + FileExceptionCode, +} from 'src/engine/core-modules/file/file.exception'; +import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +import { FilesFieldGuard } from 'src/engine/core-modules/file/files-field/guards/files-field.guard'; +import { FileApiExceptionFilter } from 'src/engine/core-modules/file/filters/file-api-exception.filter'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; + +@Controller('files-field') +@UseFilters(FileApiExceptionFilter) +export class FilesFieldController { + constructor(private readonly filesFieldService: FilesFieldService) {} + + @Get(':id') + @UseGuards(FilesFieldGuard, NoPermissionGuard) + async getFileById( + @Res() res: Response, + @Req() req: Request, + @Param('id') fileId: string, + ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const workspaceId = (req as any)?.workspaceId; + + try { + const fileStream = await this.filesFieldService.getFileStream({ + fileId, + workspaceId, + }); + + fileStream.on('error', () => { + throw new FileException( + 'Error streaming file from storage', + FileExceptionCode.INTERNAL_SERVER_ERROR, + ); + }); + + fileStream.pipe(res); + } catch (error) { + if ( + error instanceof FileStorageException && + error.code === FileStorageExceptionCode.FILE_NOT_FOUND + ) { + throw new FileException( + 'File not found', + FileExceptionCode.FILE_NOT_FOUND, + ); + } + + throw new FileException( + `Error retrieving file: ${error.message}`, + FileExceptionCode.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.exception.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.exception.ts index 69b4f3c58b..01b5e9ac89 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.exception.ts @@ -4,6 +4,7 @@ import { CustomException } from 'src/utils/custom-exception'; export enum FilesFieldExceptionCode { FILE_DELETION_FAILED = 'FILE_DELETION_FAILED', + TEMPORARY_FILE_NOT_ALLOWED = 'TEMPORARY_FILE_NOT_ALLOWED', } export class FilesFieldException extends CustomException { diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.module.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.module.ts new file mode 100644 index 0000000000..15bbc086ec --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { FilesFieldController } from 'src/engine/core-modules/file/files-field/controllers/files-field.controller'; +import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +import { FilesFieldGuard } from 'src/engine/core-modules/file/files-field/guards/files-field.guard'; +import { FilesFieldDeletionJob } from 'src/engine/core-modules/file/files-field/jobs/files-field-deletion.job'; +import { FilesFieldDeletionListener } from 'src/engine/core-modules/file/files-field/listeners/files-field-deletion.listener'; +import { FilesFieldResolver } from 'src/engine/core-modules/file/files-field/resolvers/files-field.resolver'; +import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; + +@Module({ + imports: [ + JwtModule, + TypeOrmModule.forFeature([FileEntity, WorkspaceEntity, ApplicationEntity]), + PermissionsModule, + FileStorageModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, + ], + providers: [ + FilesFieldService, + FilesFieldResolver, + FilesFieldGuard, + FilesFieldDeletionListener, + FilesFieldDeletionJob, + ], + exports: [FilesFieldService], + controllers: [FilesFieldController], +}) +export class FilesFieldModule {} diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts index 48383eca45..9d12b73b53 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts @@ -1,9 +1,25 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Readable } from 'stream'; import { msg } from '@lingui/core/macro'; import { FileFolder } from 'twenty-shared/types'; +import { Repository } from 'typeorm'; +import { v4 } from 'uuid'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { + FilesFieldTokenJwtPayload, + JwtTokenTypeEnum, +} from 'src/engine/core-modules/auth/types/auth-context.type'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils'; +import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils'; +import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { FilesFieldException, @@ -12,7 +28,61 @@ import { @Injectable() export class FilesFieldService { - constructor(private readonly fileStorageService: FileStorageService) {} + constructor( + private readonly fileStorageService: FileStorageService, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + @InjectRepository(FileEntity) + private readonly fileRepository: Repository, + private readonly twentyConfigService: TwentyConfigService, + private readonly jwtWrapperService: JwtWrapperService, + ) {} + + async uploadFile({ + 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 = sanitizeFile({ file, ext, mimeType }); + + const fileId = v4(); + const name = `${fileId}${ext ? `.${ext}` : ''}`; + + const application = await this.applicationRepository.findOneOrFail({ + where: { + id: applicationId, + workspaceId, + }, + }); + + return await this.fileStorageService.write_v2({ + sourceFile: sanitizedFile, + destinationPath: name, + mimeType, + fileFolder: FileFolder.FilesField, + applicationUniversalIdentifier: application.universalIdentifier, + workspaceId, + fileId, + settings: { + isTemporaryFile: true, + toDelete: false, + }, + }); + } async deleteFilesFieldFile({ fileId, @@ -37,4 +107,72 @@ export class FilesFieldService { ); } } + + async getFileStream({ + fileId, + workspaceId, + }: { + fileId: string; + workspaceId: string; + }): Promise { + const file = await this.fileRepository.findOneOrFail({ + where: { + id: fileId, + workspaceId, + application: { + workspaceId, + }, + }, + }); + + if (file.settings?.isTemporaryFile === true) { + throw new FilesFieldException( + `File ${fileId} is not associated with a permanent files field`, + FilesFieldExceptionCode.TEMPORARY_FILE_NOT_ALLOWED, + { + userFriendlyMessage: msg`File ${fileId} is not associated with a files field. It can't be downloaded.`, + }, + ); + } + + const application = await this.applicationRepository.findOneOrFail({ + where: { + id: file.applicationId, + workspaceId, + }, + }); + + return await this.fileStorageService.read_v2({ + destinationPath: removeFileFolderFromFileEntityPath(file.path), + fileFolder: FileFolder.FilesField, + applicationUniversalIdentifier: application.universalIdentifier, + workspaceId, + }); + } + + signFileUrl( + payloadToEncode: Omit, + ) { + const fileTokenExpiresIn = this.twentyConfigService.get( + 'FILE_TOKEN_EXPIRES_IN', + ); + + const payload: FilesFieldTokenJwtPayload = { + ...payloadToEncode, + sub: payloadToEncode.workspaceId, + type: JwtTokenTypeEnum.FILE, + }; + + const secret = this.jwtWrapperService.generateAppSecret( + payload.type, + payloadToEncode.workspaceId, + ); + + const token = this.jwtWrapperService.sign(payload, { + secret, + expiresIn: fileTokenExpiresIn, + }); + + return `${process.env.SERVER_URL}/files-field/${payloadToEncode.fileId}?token=${token}`; + } } diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/guards/files-field.guard.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/guards/files-field.guard.ts new file mode 100644 index 0000000000..e54e9c2032 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/guards/files-field.guard.ts @@ -0,0 +1,54 @@ +import { + type CanActivate, + type ExecutionContext, + Injectable, +} from '@nestjs/common'; + +import { FileFolder } from 'twenty-shared/types'; + +import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; + +import { FilesFieldTokenJwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +@Injectable() +export class FilesFieldGuard implements CanActivate { + constructor(private readonly jwtWrapperService: JwtWrapperService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + const fileToken = request.query.token; + const fileId = request.params.id; + + if (!fileToken) { + return false; + } + + try { + const payload = await this.jwtWrapperService.verifyJwtToken(fileToken, { + ignoreExpiration: + fileFolderConfigs[FileFolder.FilesField].ignoreExpirationToken, + }); + + if (!payload.workspaceId) { + return false; + } + } catch { + return false; + } + + const decodedPayload = + this.jwtWrapperService.decode(fileToken, { + json: true, + }); + + request.workspaceId = decodedPayload.workspaceId; + + if (decodedPayload.fileId !== fileId) { + return false; + } + + return true; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts new file mode 100644 index 0000000000..3785728dad --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts @@ -0,0 +1,47 @@ +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; +import { PermissionFlagType } from 'twenty-shared/constants'; + +import type { FileUpload } from 'graphql-upload/processRequest.mjs'; + +import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto'; +import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { streamToBuffer } from 'src/utils/stream-to-buffer'; + +@UseGuards(WorkspaceAuthGuard) +@UsePipes(ResolverValidationPipe) +@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter) +@Resolver() +export class FilesFieldResolver { + constructor(private readonly filesFieldService: FilesFieldService) {} + + @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.filesFieldService.uploadFile({ + file: buffer, + filename, + declaredMimeType: mimetype, + workspaceId, + applicationId: workspaceCustomApplicationId, + }); + + return fileEntity; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/utils/sanitize-file.utils.ts b/packages/twenty-server/src/engine/core-modules/file/utils/sanitize-file.utils.ts new file mode 100644 index 0000000000..c24f8b9c43 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/utils/sanitize-file.utils.ts @@ -0,0 +1,31 @@ +import DOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; + +export const sanitizeFile = ({ + file, + ext, + mimeType, +}: { + file: Buffer | Uint8Array | string; + ext: string; + mimeType: string | undefined; +}): Buffer | Uint8Array | string => { + if (ext === 'svg' || mimeType === 'image/svg+xml') { + const window = new JSDOM('').window; + const purify = DOMPurify(window); + + let fileString: string; + + if (typeof file === 'string') { + fileString = file; + } else if (Buffer.isBuffer(file)) { + fileString = file.toString('utf-8'); + } else { + fileString = Buffer.from(file).toString('utf-8'); + } + + return purify.sanitize(fileString); + } + + return file; +}; diff --git a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts new file mode 100644 index 0000000000..f6b6aa0b66 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts @@ -0,0 +1,380 @@ +import gql from 'graphql-tag'; +import request from 'supertest'; +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 { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +const uploadWorkspaceFieldFileMutation = gql` + mutation UploadFilesFieldFile($file: Upload!) { + uploadFilesFieldFile(file: $file) { + id + path + size + createdAt + } + } +`; + +const deleteFileMutation = gql` + mutation DeleteFile($fileId: UUID!) { + deleteFile(fileId: $fileId) { + id + } + } +`; + +const createRecordsQuery = gql` + mutation CreateRecords( + $data: [FileDownloadTestObjectCreateInput!]! + $upsert: Boolean + ) { + createFileDownloadTestObjects(data: $data, upsert: $upsert) { + id + name + filesField { + fileId + label + extension + url + } + } + } +`; + +const deleteRecordsQuery = gql` + mutation DeleteRecords($filter: FileDownloadTestObjectFilterInput!) { + deleteFileDownloadTestObjects(filter: $filter) { + id + } + } +`; + +type UploadedFile = { + id: string; + contentType: string; + content: string; +}; + +const uploadFile = async ( + filename: string, + content: string, + contentType: string, +): Promise => { + const response = await makeGraphqlAPIRequestWithFileUpload( + { + query: uploadWorkspaceFieldFileMutation, + variables: { file: null }, + }, + { + field: 'file', + buffer: Buffer.from(content), + filename, + contentType, + }, + ); + + expect(response.body.errors).toBeUndefined(); + + return { + id: response.body.data.uploadFilesFieldFile.id, + contentType, + content, + }; +}; + +const deleteFile = async (fileId: string): Promise => { + await makeGraphqlAPIRequest({ + query: deleteFileMutation, + variables: { fileId }, + }); +}; + +describe('files-field.controller - GET /files-field/:id', () => { + let createdObjectMetadataId = ''; + let uploadedFiles: UploadedFile[] = []; + + beforeAll(async () => { + jest.useRealTimers(); + + await makeGraphqlAPIRequest( + updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + FeatureFlagKey.IS_FILES_FIELD_ENABLED, + true, + ), + ); + + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'fileDownloadTestObject', + namePlural: 'fileDownloadTestObjects', + labelSingular: 'File Download Test Object', + labelPlural: 'File Download Test Objects', + icon: 'IconFile', + }, + }); + + createdObjectMetadataId = objectMetadataId; + + await createOneFieldMetadata({ + input: { + name: 'filesField', + label: 'Files Field', + type: FieldMetadataType.FILES, + objectMetadataId: createdObjectMetadataId, + settings: { maxNumberOfValues: 5 }, + }, + gqlFields: ` + id + name + label + type + `, + }); + }); + + afterEach(async () => { + for (const file of uploadedFiles) { + await deleteFile(file.id); + } + uploadedFiles = []; + }); + + afterAll(async () => { + jest.useFakeTimers(); + + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: createdObjectMetadataId, + updatePayload: { + isActive: false, + }, + }, + }); + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataId }, + }); + + await makeGraphqlAPIRequest( + updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + FeatureFlagKey.IS_FILES_FIELD_ENABLED, + false, + ), + ); + }); + + it('should download file successfully with valid url', async () => { + const testFileContent = 'This is test file content for download'; + const textFile = await uploadFile( + 'download-test.txt', + testFileContent, + 'text/plain', + ); + + uploadedFiles.push(textFile); + + const createResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + name: 'Record for download test', + filesField: [ + { + fileId: textFile.id, + label: 'download-test.txt', + }, + ], + }, + ], + upsert: false, + }, + }); + + expect(createResponse.body.errors).toBeUndefined(); + + const createdRecord = + createResponse.body.data.createFileDownloadTestObjects[0]; + const fileUrl = createdRecord.filesField[0].url; + const fileId = createdRecord.filesField[0].fileId; + + expect(fileUrl).toBeDefined(); + expect(fileId).toBe(textFile.id); + + // Extract path from full URL (remove domain) + const urlPath = new URL(fileUrl).pathname + new URL(fileUrl).search; + + const downloadResponse = await request(global.app.getHttpServer()).get( + urlPath, + ); + + expect(downloadResponse.status).toBe(200); + expect(downloadResponse.text).toBe(testFileContent); + + await makeGraphqlAPIRequest({ + query: deleteRecordsQuery, + variables: { + filter: { id: { eq: createdRecord.id } }, + }, + }); + }); + + it('should download image file successfully with valid url', async () => { + const imageContent = 'fake-png-image-binary-content'; + const imageFile = await uploadFile( + 'test-image.png', + imageContent, + 'image/png', + ); + + uploadedFiles.push(imageFile); + + const createResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + name: 'Record with image', + filesField: [ + { + fileId: imageFile.id, + label: 'test-image.png', + }, + ], + }, + ], + upsert: false, + }, + }); + + expect(createResponse.body.errors).toBeUndefined(); + + const createdRecord = + createResponse.body.data.createFileDownloadTestObjects[0]; + const fileUrl = createdRecord.filesField[0].url; + + expect(fileUrl).toBeDefined(); + expect(createdRecord.filesField[0].extension).toBe('.png'); + + // Extract path from full URL (remove domain) + const urlPath = new URL(fileUrl).pathname + new URL(fileUrl).search; + + const downloadResponse = await request(global.app.getHttpServer()).get( + urlPath, + ); + + expect(downloadResponse.status).toBe(200); + expect(downloadResponse.text).toBe(imageContent); + + await makeGraphqlAPIRequest({ + query: deleteRecordsQuery, + variables: { + filter: { id: { eq: createdRecord.id } }, + }, + }); + }); + + it('should return 403 when token is missing', async () => { + const textFile = await uploadFile( + 'unauthorized-test.txt', + 'content', + 'text/plain', + ); + + uploadedFiles.push(textFile); + + const createResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + name: 'Record for unauthorized test', + filesField: [ + { + fileId: textFile.id, + label: 'unauthorized-test.txt', + }, + ], + }, + ], + upsert: false, + }, + }); + + const createdRecord = + createResponse.body.data.createFileDownloadTestObjects[0]; + const fileId = createdRecord.filesField[0].fileId; + + const downloadResponse = await request(global.app.getHttpServer()).get( + `/files-field/${fileId}`, + ); + + expect(downloadResponse.status).toBe(403); + + await makeGraphqlAPIRequest({ + query: deleteRecordsQuery, + variables: { + filter: { id: { eq: createdRecord.id } }, + }, + }); + }); + + it('should return 403 when url is invalid', async () => { + const textFile = await uploadFile( + 'invalid-token-test.txt', + 'content', + 'text/plain', + ); + + uploadedFiles.push(textFile); + + const createResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + name: 'Record for invalid token test', + filesField: [ + { + fileId: textFile.id, + label: 'invalid-token-test.txt', + }, + ], + }, + ], + upsert: false, + }, + }); + + const createdRecord = + createResponse.body.data.createFileDownloadTestObjects[0]; + const fileId = createdRecord.filesField[0].fileId; + + const downloadResponse = await request(global.app.getHttpServer()) + .get(`/files-field/${fileId}`) + .query({ token: 'invalid-token-12345' }); + + expect(downloadResponse.status).toBe(403); + + await makeGraphqlAPIRequest({ + query: deleteRecordsQuery, + variables: { + filter: { id: { eq: createdRecord.id } }, + }, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts index 8fef488f12..1405c1233f 100644 --- a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts @@ -42,6 +42,7 @@ const createRecordsQuery = gql` fileId label extension + url } } } @@ -59,6 +60,7 @@ const updateRecordQuery = gql` fileId label extension + url } } } @@ -278,6 +280,8 @@ describe('fileFieldSync - FILES field <> files sync', () => { expect(createdRecord.filesField[0].extension).toBe('.png'); expect(createdRecord.filesField[1].fileId).toBe(textFile.id); expect(createdRecord.filesField[1].extension).toBe('.txt'); + expect(createdRecord.filesField[0].url).toBeDefined(); + expect(createdRecord.filesField[1].url).toBeDefined(); expect(await checkFileExistsInDB(imageFile.id)).toBe(true); expect(await checkFileExistsInDB(textFile.id)).toBe(true); @@ -382,14 +386,17 @@ describe('fileFieldSync - FILES field <> files sync', () => { expect(updatedRecord.filesField[1].fileId).toBe(imageFile.id); expect(updatedRecord.filesField[1].label).toBe('imageFile-label.png'); expect(updatedRecord.filesField[1].extension).toBe('.png'); + expect(updatedRecord.filesField[1].url).toBeDefined(); expect(updatedRecord.filesField[2].fileId).toBe(anotherImageFile.id); expect(updatedRecord.filesField[2].label).toBe( 'updated-anotherImageFile-label.png', ); expect(updatedRecord.filesField[2].extension).toBe('.png'); + expect(updatedRecord.filesField[2].url).toBeDefined(); expect(updatedRecord.filesField[0].fileId).toBe(textFile.id); expect(updatedRecord.filesField[0].label).toBe('new-added-text-file.txt'); expect(updatedRecord.filesField[0].extension).toBe('.txt'); + expect(updatedRecord.filesField[0].url).toBeDefined(); expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); expect(await checkFileIsInPermanentStorage(anotherImageFile.id)).toBe(true); @@ -476,9 +483,10 @@ describe('fileFieldSync - FILES field <> files sync', () => { expect(updatedRecord.filesField).toHaveLength(2); expect(updatedRecord.filesField[1].fileId).toBe(imageFile.id); expect(updatedRecord.filesField[1].label).toBe('original-image.png'); + expect(updatedRecord.filesField[1].url).toBeDefined(); expect(updatedRecord.filesField[0].fileId).toBe(textFile.id); expect(updatedRecord.filesField[0].label).toBe('added-text.txt'); - + expect(updatedRecord.filesField[0].url).toBeDefined(); expect(await checkFileExistsInDB(imageFile.id)).toBe(true); expect(await checkFileExistsInDB(textFile.id)).toBe(true); expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true);