Files Field - Add new controller (#17561)
Add new files field controller to fetch files from FILES field
This commit is contained in:
@@ -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;
|
||||
|
||||
+20
@@ -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',
|
||||
)
|
||||
);
|
||||
};
|
||||
+9
-1
@@ -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;
|
||||
};
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+80
-15
@@ -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),
|
||||
}
|
||||
|
||||
+54
@@ -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,
|
||||
|
||||
+3
@@ -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>;
|
||||
}
|
||||
|
||||
+1
-2
@@ -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
-8
@@ -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],
|
||||
})
|
||||
|
||||
-23
@@ -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(
|
||||
|
||||
+2
-75
@@ -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 {}
|
||||
|
||||
+73
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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 {}
|
||||
+139
-1
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -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;
|
||||
}
|
||||
}
|
||||
+47
@@ -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;
|
||||
};
|
||||
+380
@@ -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 } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+9
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user