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
@@ -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;
};