Files Field - Add new controller (#17561)

Add new files field controller to fetch files from FILES field
This commit is contained in:
Etienne
2026-01-30 14:44:11 +01:00
committed by GitHub
parent 9439afde22
commit 4b66ae5320
24 changed files with 959 additions and 141 deletions
@@ -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,
@@ -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;
@@ -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',
)
);
};
@@ -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;
};
@@ -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;
@@ -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<string, QueryResultGetterHandlerInterface>;
private objectHandlers: Map<string, QueryResultGetterHandlerInterface>;
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<string, QueryResultGetterHandlerInterface>([
private initializeObjectHandlers() {
this.objectHandlers = new Map<string, QueryResultGetterHandlerInterface>([
['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<ObjectRecord> {
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<ObjectRecord> {
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<ObjectRecord> =>
Promise.resolve(result),
}) ?? {
handle: (result: ObjectRecord): Promise<ObjectRecord> =>
Promise.resolve(result),
}
@@ -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<ObjectRecord> {
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;
}
}
@@ -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,
@@ -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<ObjectRecord>;
}
@@ -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 },
},
});
@@ -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;
@@ -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],
})
@@ -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<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(
@@ -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<ApplicationEntity>,
) {}
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<FileEntity> {
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,
},
});
}
}
@@ -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 {}
@@ -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,
);
}
}
}
@@ -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<FilesFieldExceptionCode> {
@@ -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 {}
@@ -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<ApplicationEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
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<FileEntity> {
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<Readable> {
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<FilesFieldTokenJwtPayload, 'type' | 'sub'>,
) {
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}`;
}
}
@@ -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<boolean> {
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<FilesFieldTokenJwtPayload>(fileToken, {
json: true,
});
request.workspaceId = decodedPayload.workspaceId;
if (decodedPayload.fileId !== fileId) {
return false;
}
return true;
}
}
@@ -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<FileDTO> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
const fileEntity = await this.filesFieldService.uploadFile({
file: buffer,
filename,
declaredMimeType: mimetype,
workspaceId,
applicationId: workspaceCustomApplicationId,
});
return fileEntity;
}
}
@@ -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;
};
@@ -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<UploadedFile> => {
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<void> => {
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 } },
},
});
});
});
@@ -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);