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