File - Migrate core pictures (workspace and member logo) + workflow attachments (#17924)

- Create a common file-by-id download controller
- Create core picture module with resolver and logic to handle
workspaceLogo and workspaceMemberProfilePicture update
- Create workflow file module (same)
- Data migration
This commit is contained in:
Etienne
2026-02-17 16:42:50 +01:00
committed by GitHub
parent 963f2de864
commit 163c1175cb
85 changed files with 2212 additions and 335 deletions
@@ -504,13 +504,14 @@ export class SignInUpService {
workspaceToCreate,
);
await this.applicationService.createWorkspaceCustomApplication(
{
workspaceId,
applicationId: workspaceCustomApplicationId,
},
queryRunner,
);
const customApplication =
await this.applicationService.createWorkspaceCustomApplication(
{
workspaceId,
applicationId: workspaceCustomApplicationId,
},
queryRunner,
);
const isExistingUser = userData.type === 'existingUser';
const user = isExistingUser
@@ -532,6 +533,7 @@ export class SignInUpService {
pictureUrl: isExistingUser
? undefined
: userData.newUserWithPicture.picture,
applicationUniversalIdentifier: customApplication.universalIdentifier,
},
queryRunner,
);
@@ -20,7 +20,7 @@ import {
type ApiKeyTokenJwtPayload,
ApplicationAccessTokenJwtPayload,
type AuthContext,
type FileTokenJwtPayload,
FileTokenJwtPayloadLegacy,
type JwtPayload,
JwtTokenTypeEnum,
type WorkspaceAgnosticTokenJwtPayload,
@@ -55,7 +55,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
const secretOrKeyProviderFunction = async (_request, rawJwtToken, done) => {
try {
const decodedToken = jwtWrapperService.decode<
| FileTokenJwtPayload
| FileTokenJwtPayloadLegacy
| AccessTokenJwtPayload
| WorkspaceAgnosticTokenJwtPayload
>(rawJwtToken);
@@ -48,7 +48,7 @@ type CommonPropertiesJwtPayload = {
sub: string;
};
export type FileTokenJwtPayload = CommonPropertiesJwtPayload & {
export type FileTokenJwtPayloadLegacy = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.FILE;
workspaceId: string;
filename: string;
@@ -58,7 +58,7 @@ export type FileTokenJwtPayload = CommonPropertiesJwtPayload & {
personId?: string;
};
export type FilesFieldTokenJwtPayload = CommonPropertiesJwtPayload & {
export type FileTokenJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.FILE;
workspaceId: string;
fileId: string;
@@ -145,5 +145,5 @@ export type JwtPayload =
| TransientTokenJwtPayload
| RefreshTokenJwtPayload
| FileTokenJwtPayload
| FilesFieldTokenJwtPayload
| FileTokenJwtPayloadLegacy
| PostgresProxyTokenJwtPayload;
@@ -13,6 +13,8 @@ export enum FeatureFlagKey {
IS_NOTE_TARGET_MIGRATED = 'IS_NOTE_TARGET_MIGRATED',
IS_TASK_TARGET_MIGRATED = 'IS_TASK_TARGET_MIGRATED',
IS_FILES_FIELD_MIGRATED = 'IS_FILES_FIELD_MIGRATED',
IS_CORE_PICTURE_MIGRATED = 'IS_CORE_PICTURE_MIGRATED',
IS_OTHER_FILE_MIGRATED = 'IS_OTHER_FILE_MIGRATED',
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED',
@@ -19,30 +19,35 @@ 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 {
FileByIdGuard,
SupportedFileFolder,
} from 'src/engine/core-modules/file/guards/file-by-id.guard';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
@Controller('files-field')
@Controller('file')
@UseFilters(FileApiExceptionFilter)
export class FilesFieldController {
constructor(private readonly filesFieldService: FilesFieldService) {}
export class FileByIdController {
constructor(private readonly fileService: FileService) {}
@Get(':id')
@UseGuards(FilesFieldGuard, NoPermissionGuard)
@Get(':fileFolder/:id')
@UseGuards(FileByIdGuard, NoPermissionGuard)
async getFileById(
@Res() res: Response,
@Req() req: Request,
@Param('fileFolder') fileFolder: SupportedFileFolder,
@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({
const fileStream = await this.fileService.getFileStreamById({
fileId,
workspaceId,
fileFolder,
});
fileStream.on('error', () => {
@@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('FilesFieldFile')
export class FilesFieldFileDTO {
@ObjectType('FileWithSignedUrl')
export class FileWithSignedUrlDto {
@Field(() => UUIDScalarType)
id: string;
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileCorePictureResolver } from 'src/engine/core-modules/file/file-core-picture/resolvers/file-core-picture.resolver';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
JwtModule,
TypeOrmModule.forFeature([FileEntity, WorkspaceEntity, ApplicationEntity]),
PermissionsModule,
FileStorageModule,
ApplicationModule,
FileUrlModule,
SecureHttpClientModule,
],
providers: [FileCorePictureService, FileCorePictureResolver],
exports: [FileCorePictureService],
})
export class FileCorePictureModule {}
@@ -0,0 +1,66 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation } 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 { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.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 { UploadProfilePicturePermissionGuard } from 'src/engine/core-modules/user-workspace/guards/upload-profile-picture-permission.guard';
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)
@MetadataResolver()
export class FileCorePictureResolver {
constructor(
private readonly fileCorePictureService: FileCorePictureService,
) {}
@Mutation(() => FileWithSignedUrlDto)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
)
async uploadWorkspaceLogo(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDto> {
const buffer = await streamToBuffer(createReadStream());
return await this.fileCorePictureService.uploadWorkspacePicture({
file: buffer,
filename,
workspace,
});
}
@Mutation(() => FileWithSignedUrlDto)
@UseGuards(WorkspaceAuthGuard, UploadProfilePicturePermissionGuard)
async uploadWorkspaceMemberProfilePicture(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDto> {
const buffer = await streamToBuffer(createReadStream());
return await this.fileCorePictureService.uploadWorkspaceMemberProfilePicture(
{
file: buffer,
filename,
workspaceId,
},
);
}
}
@@ -0,0 +1,259 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { buffer as streamToBuffer } from 'node:stream/consumers';
import { isNonEmptyString } from '@sniptt/guards';
import FileType from 'file-type';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Like, type QueryRunner, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
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 { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { getImageBufferFromUrl } from 'src/utils/image';
@Injectable()
export class FileCorePictureService {
constructor(
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
private readonly fileUrlService: FileUrlService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
private async uploadCorePicture({
file,
filename,
workspaceId,
applicationUniversalIdentifier,
queryRunner,
}: {
file: Buffer;
filename: string;
workspaceId: string;
applicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileEntity> {
const { mimeType, ext } = await extractFileInfo({ file, filename });
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const finalName = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
const universalIdentifier =
applicationUniversalIdentifier ??
(
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
)
).workspaceCustomFlatApplication.universalIdentifier;
const savedFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedFile,
resourcePath: `${FileFolder.CorePicture}/${finalName}`,
mimeType,
fileFolder: FileFolder.CorePicture,
applicationUniversalIdentifier: universalIdentifier,
workspaceId,
fileId,
settings: {
isTemporaryFile: false,
toDelete: false,
},
queryRunner,
});
return savedFile;
}
async uploadWorkspacePicture({
file,
filename,
workspace,
}: {
file: Buffer;
filename: string;
workspace: WorkspaceEntity;
}): Promise<FileWithSignedUrlDto> {
const savedFile = await this.uploadCorePicture({
file,
filename,
workspaceId: workspace.id,
});
await this.workspaceRepository.update(workspace.id, {
logoFileId: savedFile.id,
});
if (isDefined(workspace.logoFileId)) {
await this.deleteCorePicture({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
});
}
const url = this.fileUrlService.signFileByIdUrl({
fileId: savedFile.id,
fileFolder: FileFolder.CorePicture,
workspaceId: workspace.id,
});
return {
...savedFile,
url,
};
}
async uploadWorkspaceMemberProfilePicture({
file,
filename,
workspaceId,
applicationUniversalIdentifier,
queryRunner,
}: {
file: Buffer;
filename: string;
workspaceId: string;
applicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileWithSignedUrlDto> {
const savedFile = await this.uploadCorePicture({
file,
filename,
workspaceId,
applicationUniversalIdentifier,
queryRunner,
});
const url = this.fileUrlService.signFileByIdUrl({
fileId: savedFile.id,
workspaceId,
fileFolder: FileFolder.CorePicture,
});
return {
...savedFile,
url,
};
}
async deleteCorePicture({
fileId,
workspaceId,
}: {
fileId: string;
workspaceId: string;
}): Promise<void> {
const file = await this.fileRepository.findOneOrFail({
where: {
id: fileId,
path: Like(`${FileFolder.CorePicture}/%`),
workspaceId,
},
});
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
await this.fileStorageService.delete({
workspaceId,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
fileFolder: FileFolder.CorePicture,
resourcePath: removeFileFolderFromFileEntityPath(file.path),
});
}
async uploadWorkspaceMemberProfilePictureFromUrl({
imageUrl,
workspaceId,
applicationUniversalIdentifier,
queryRunner,
}: {
imageUrl: string;
workspaceId: string;
applicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileWithSignedUrlDto | undefined> {
const httpClient = this.secureHttpClientService.getHttpClient();
const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
const type = await FileType.fromBuffer(buffer);
if (!isDefined(type) || !type.mime.startsWith('image/')) {
return;
}
return this.uploadWorkspaceMemberProfilePicture({
file: buffer,
filename: `avatar.${type.ext}`,
workspaceId,
applicationUniversalIdentifier,
queryRunner,
});
}
async copyWorkspaceMemberProfilePicture({
sourceWorkspaceId,
sourceFileId,
targetWorkspaceId,
targetApplicationUniversalIdentifier,
queryRunner,
}: {
sourceWorkspaceId: string;
sourceFileId: string;
targetWorkspaceId: string;
targetApplicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileWithSignedUrlDto> {
const sourceFile = await this.fileRepository.findOneOrFail({
where: {
id: sourceFileId,
workspaceId: sourceWorkspaceId,
path: Like(`${FileFolder.CorePicture}/%`),
},
});
const { workspaceCustomFlatApplication: sourceApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId: sourceWorkspaceId,
},
);
const fileStream = await this.fileStorageService.readFile({
workspaceId: sourceWorkspaceId,
applicationUniversalIdentifier: sourceApplication.universalIdentifier,
fileFolder: FileFolder.CorePicture,
resourcePath: removeFileFolderFromFileEntityPath(sourceFile.path),
});
const filename = sourceFile.path.split('/').pop() ?? '';
return this.uploadWorkspaceMemberProfilePicture({
file: await streamToBuffer(fileStream),
filename,
workspaceId: targetWorkspaceId,
applicationUniversalIdentifier: targetApplicationUniversalIdentifier,
queryRunner,
});
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { FileUrlService } from './file-url.service';
@Module({
imports: [JwtModule],
providers: [FileUrlService],
exports: [FileUrlService],
})
export class FileUrlModule {}
@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
import {
FileTokenJwtPayload,
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class FileUrlService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly twentyConfigService: TwentyConfigService,
) {}
signFileByIdUrl({
fileId,
workspaceId,
fileFolder,
}: {
fileId: string;
workspaceId: string;
fileFolder: FileFolder;
}): string {
const fileTokenExpiresIn = this.twentyConfigService.get(
'FILE_TOKEN_EXPIRES_IN',
);
const payload: FileTokenJwtPayload = {
workspaceId,
fileId,
sub: workspaceId,
type: JwtTokenTypeEnum.FILE,
};
const secret = this.jwtWrapperService.generateAppSecret(
payload.type,
workspaceId,
);
const token = this.jwtWrapperService.sign(payload, {
secret,
expiresIn: fileTokenExpiresIn,
});
const serverUrl = this.twentyConfigService.get('SERVER_URL');
return `${serverUrl}/file/${fileFolder}/${fileId}?token=${token}`;
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { FileWorkflowResolver } from 'src/engine/core-modules/file/file-workflow/resolvers/file-workflow.resolver';
import { FileWorkflowService } from 'src/engine/core-modules/file/file-workflow/services/file-workflow.service';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [FileUrlModule, ApplicationModule, PermissionsModule],
providers: [FileWorkflowService, FileWorkflowResolver],
exports: [FileWorkflowService],
})
export class FileWorkflowModule {}
@@ -0,0 +1,44 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation } 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 { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileWorkflowService } from 'src/engine/core-modules/file/file-workflow/services/file-workflow.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)
@MetadataResolver()
export class FileWorkflowResolver {
constructor(private readonly fileWorkflowService: FileWorkflowService) {}
@Mutation(() => FileWithSignedUrlDto)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadWorkflowFile(
@AuthWorkspace()
{ id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename }: FileUpload,
): Promise<FileWithSignedUrlDto> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
return await this.fileWorkflowService.uploadFile({
file: buffer,
filename,
workspaceId,
});
}
}
@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { FileFolder } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
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';
@Injectable()
export class FileWorkflowService {
constructor(
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
private readonly fileUrlService: FileUrlService,
) {}
async uploadFile({
file,
filename,
workspaceId,
}: {
file: Buffer;
filename: string;
workspaceId: string;
}): Promise<FileWithSignedUrlDto> {
const { mimeType, ext } = await extractFileInfo({
file,
filename,
});
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const savedFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedFile,
resourcePath: name,
mimeType,
fileFolder: FileFolder.Workflow,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceId,
fileId,
settings: {
isTemporaryFile: true,
toDelete: false,
},
});
return {
...savedFile,
url: this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.Workflow,
}),
};
}
}
@@ -11,6 +11,8 @@ import {
export const FileExceptionCode = appendCommonExceptionCode({
UNAUTHENTICATED: 'UNAUTHENTICATED',
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
INVALID_FILE_FOLDER: 'INVALID_FILE_FOLDER',
TEMPORARY_FILE_NOT_ALLOWED: 'TEMPORARY_FILE_NOT_ALLOWED',
} as const);
const getFileExceptionUserFriendlyMessage = (
@@ -21,6 +23,10 @@ const getFileExceptionUserFriendlyMessage = (
return msg`Authentication is required.`;
case FileExceptionCode.FILE_NOT_FOUND:
return msg`File not found.`;
case FileExceptionCode.INVALID_FILE_FOLDER:
return msg`Invalid file folder.`;
case FileExceptionCode.TEMPORARY_FILE_NOT_ALLOWED:
return msg`Temporary file cannot be downloaded.`;
case FileExceptionCode.INTERNAL_SERVER_ERROR:
return STANDARD_ERROR_MESSAGE;
default:
@@ -13,10 +13,15 @@ import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-clie
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { FileByIdController } from './controllers/file-by-id.controller';
import { FileController } from './controllers/file.controller';
import { FileEntity } from './entities/file.entity';
import { FileCorePictureModule } from './file-core-picture/file-core-picture.module';
import { FileUploadService } from './file-upload/services/file-upload.service';
import { FileUrlModule } from './file-url/file-url.module';
import { FileWorkflowModule } from './file-workflow/file-workflow.module';
import { FilesFieldModule } from './files-field/files-field.module';
import { FileByIdGuard } from './guards/file-by-id.guard';
import { FileResolver } from './resolvers/file.resolver';
import { FileMetadataService } from './services/file-metadata.service';
import { FileService } from './services/file.service';
@@ -27,7 +32,10 @@ import { FileService } from './services/file.service';
TypeOrmModule.forFeature([FileEntity, WorkspaceEntity, ApplicationEntity]),
PermissionsModule,
FileStorageModule,
FileUrlModule,
FilesFieldModule,
FileCorePictureModule,
FileWorkflowModule,
SecureHttpClientModule,
],
providers: [
@@ -35,13 +43,22 @@ import { FileService } from './services/file.service';
FileMetadataService,
FileResolver,
FilePathGuard,
FileByIdGuard,
FileAttachmentListener,
FileWorkspaceMemberListener,
FileWorkspaceFolderDeletionJob,
FileDeletionJob,
FileUploadService,
],
exports: [FileService, FileMetadataService],
controllers: [FileController],
exports: [
FileService,
FileMetadataService,
FileUrlModule,
FilesFieldModule,
FileCorePictureModule,
FileWorkflowModule,
FileUploadService,
],
controllers: [FileController, FileByIdController],
})
export class FileModule {}
@@ -3,13 +3,11 @@ 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 { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.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 { FilesFieldResolver } from 'src/engine/core-modules/file/files-field/resolvers/files-field.resolver';
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/services/files-field.service';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -20,23 +18,21 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
imports: [
JwtModule,
TypeOrmModule.forFeature([
FileEntity,
WorkspaceEntity,
ApplicationEntity,
FieldMetadataEntity,
]),
PermissionsModule,
FileStorageModule,
FileUrlModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [
FilesFieldService,
FilesFieldResolver,
FilesFieldGuard,
FilesFieldDeletionListener,
FilesFieldDeletionJob,
],
exports: [FilesFieldService],
controllers: [FilesFieldController],
})
export class FilesFieldModule {}
@@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service';
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/services/files-field.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@@ -6,15 +6,15 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { FilesFieldFileDTO } from 'src/engine/core-modules/file/files-field/dtos/files-field-file.dto';
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/services/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 { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UseGuards(WorkspaceAuthGuard)
@@ -24,7 +24,7 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
export class FilesFieldResolver {
constructor(private readonly filesFieldService: FilesFieldService) {}
@Mutation(() => FilesFieldFileDTO)
@Mutation(() => FileWithSignedUrlDto)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadFilesFieldFile(
@AuthWorkspace()
@@ -37,7 +37,7 @@ export class FilesFieldResolver {
nullable: false,
})
fieldMetadataId: string,
): Promise<FilesFieldFileDTO> {
): Promise<FileWithSignedUrlDto> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
@@ -1,33 +1,23 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Readable } from 'stream';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { FileFolder } from 'twenty-shared/types';
import { Like, Repository } from 'typeorm';
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 { FilesFieldFileDTO } from 'src/engine/core-modules/file/files-field/dtos/files-field-file.dto';
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 { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import {
FilesFieldException,
FilesFieldExceptionCode,
} from './files-field.exception';
} from 'src/engine/core-modules/file/files-field/files-field.exception';
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 { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@Injectable()
export class FilesFieldService {
@@ -37,10 +27,7 @@ export class FilesFieldService {
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
private readonly twentyConfigService: TwentyConfigService,
private readonly jwtWrapperService: JwtWrapperService,
private readonly fileUrlService: FileUrlService,
) {}
async uploadFile({
@@ -53,7 +40,7 @@ export class FilesFieldService {
filename: string;
workspaceId: string;
fieldMetadataId: string;
}): Promise<FilesFieldFileDTO> {
}): Promise<FileWithSignedUrlDto> {
const { mimeType, ext } = await extractFileInfo({
file,
filename,
@@ -95,7 +82,11 @@ export class FilesFieldService {
return {
...savedFile,
url: this.signFileUrl({ fileId, workspaceId }),
url: this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.FilesField,
}),
};
}
@@ -122,70 +113,4 @@ export class FilesFieldService {
);
}
}
async getFileStream({
fileId,
workspaceId,
}: {
fileId: string;
workspaceId: string;
}): Promise<Readable> {
const file = await this.fileRepository.findOneOrFail({
where: {
id: fileId,
path: Like(`${FileFolder.FilesField}/%`),
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.readFile({
resourcePath: 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,57 @@
import { FileFolder } from 'twenty-shared/types';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
describe('extractFileIdFromUrl', () => {
const validUuid = '550e8400-e29b-41d4-a716-446655440000';
it('should extract valid UUID from URL with matching file folder', () => {
const url = `https://example.com/file/${FileFolder.FilesField}/${validUuid}`;
expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(validUuid);
});
it('should return null for invalid URL', () => {
expect(extractFileIdFromUrl('not-a-valid-url', FileFolder.FilesField)).toBe(
null,
);
});
it('should return null for external link with different path', () => {
const url = `https://example.com/external-path/${validUuid}`;
expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(null);
});
it('should return null when fileId is not a valid UUID', () => {
const url = `https://example.com/file/${FileFolder.FilesField}/not-a-uuid`;
expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(null);
});
it('should return null when pathname has no fileId segment', () => {
const url = `https://example.com/file/${FileFolder.FilesField}/`;
expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(null);
});
it('should work with different file folders', () => {
const corePictureUrl = `https://example.com/file/${FileFolder.CorePicture}/${validUuid}`;
expect(extractFileIdFromUrl(corePictureUrl, FileFolder.CorePicture)).toBe(
validUuid,
);
});
it('should work with query params', () => {
const url = `http://localhost:3000/file/${FileFolder.FilesField}/${validUuid}?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3b3Jrc3BhY2VJZCI6IjNmZWNhYzJkLWMbOWEtNGExOC1hZjVlLTk0NjYwMTNhODFkMSIsImZpbGVJZCI6IjVlYmJjODQ0LTAzYTUtNGEyNS05NDliLWE2NWNmMjkzMWExOSIsInN1YiI6IjNmZWNhYzJkLWMwOWEtNGExOC1hZjVlLTk0NjYwMTNhODFkMSIsInR5cGUiOiJGSUxFIiwiaWF0IjoxNzcxMjYzNjEwLCJleHAiOjE3NzEzNTAwMTB9.qBy0SvkAuaq-KwWIALasRVJwSkN9Llu15LXUnVZMy-Y`;
expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(validUuid);
});
it('should return null when file folder does not match', () => {
const url = `https://example.com/file/${FileFolder.CorePicture}/${validUuid}`;
expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(null);
});
});
@@ -1,6 +1,10 @@
import { type FileFolder } from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
export const extractFileIdFromUrl = (url: string): string | null => {
export const extractFileIdFromUrl = (
url: string,
fileFolder: FileFolder,
): string | null => {
let parsedUrl: URL;
try {
@@ -10,13 +14,13 @@ export const extractFileIdFromUrl = (url: string): string | null => {
}
const pathname = parsedUrl.pathname;
const isLinkExternal = !pathname.startsWith('/files-field/');
const isLinkExternal = !pathname.startsWith(`/file/${fileFolder}/`);
if (isLinkExternal) {
return null;
}
const fileId = pathname.match(/files-field\/([^/]+)/)?.[1];
const fileId = pathname.match(`/${fileFolder}/([^/]+)`)?.[1];
return isDefined(fileId) && isValidUuid(fileId) ? fileId : null;
};
@@ -1,25 +1,33 @@
import {
type CanActivate,
type ExecutionContext,
Injectable,
} from '@nestjs/common';
import { CanActivate, 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 { FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
export const SUPPORTED_FILE_FOLDERS = [
FileFolder.CorePicture,
FileFolder.FilesField,
FileFolder.Workflow,
] as const;
export type SupportedFileFolder = (typeof SUPPORTED_FILE_FOLDERS)[number];
@Injectable()
export class FilesFieldGuard implements CanActivate {
export class FileByIdGuard 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;
const fileFolder = request.params.fileFolder as FileFolder;
const fileToken = request.query.token;
if (!this.isSupportedFileFolder(fileFolder)) {
return false;
}
if (!fileToken) {
return false;
@@ -27,8 +35,7 @@ export class FilesFieldGuard implements CanActivate {
try {
const payload = await this.jwtWrapperService.verifyJwtToken(fileToken, {
ignoreExpiration:
fileFolderConfigs[FileFolder.FilesField].ignoreExpirationToken,
ignoreExpiration: fileFolderConfigs[fileFolder].ignoreExpirationToken,
});
if (!payload.workspaceId) {
@@ -38,10 +45,12 @@ export class FilesFieldGuard implements CanActivate {
return false;
}
const decodedPayload =
this.jwtWrapperService.decode<FilesFieldTokenJwtPayload>(fileToken, {
const decodedPayload = this.jwtWrapperService.decode<FileTokenJwtPayload>(
fileToken,
{
json: true,
});
},
);
request.workspaceId = decodedPayload.workspaceId;
@@ -51,4 +60,10 @@ export class FilesFieldGuard implements CanActivate {
return true;
}
private isSupportedFileFolder(
fileFolder: string,
): fileFolder is SupportedFileFolder {
return SUPPORTED_FILE_FOLDERS.includes(fileFolder as SupportedFileFolder);
}
}
@@ -4,7 +4,7 @@ import {
Injectable,
} from '@nestjs/common';
import { type FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FileTokenJwtPayloadLegacy } from 'src/engine/core-modules/auth/types/auth-context.type';
import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@@ -39,12 +39,10 @@ export class FilePathGuard implements CanActivate {
return false;
}
const decodedPayload = this.jwtWrapperService.decode<FileTokenJwtPayload>(
fileSignature,
{
const decodedPayload =
this.jwtWrapperService.decode<FileTokenJwtPayloadLegacy>(fileSignature, {
json: true,
},
);
});
request.workspaceId = decodedPayload.workspaceId;
@@ -24,6 +24,9 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.PersonPicture]: {
ignoreExpirationToken: false,
},
[FileFolder.CorePicture]: {
ignoreExpirationToken: true,
},
[FileFolder.File]: {
ignoreExpirationToken: false,
},
@@ -48,6 +51,9 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.Dependencies]: {
ignoreExpirationToken: false,
},
[FileFolder.Workflow]: {
ignoreExpirationToken: false,
},
};
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
@@ -6,6 +6,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { FileMetadataService } from 'src/engine/core-modules/file/services/file-metadata.service';
@@ -15,7 +16,6 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
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 { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UseGuards(WorkspaceAuthGuard)
@@ -25,7 +25,9 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
export class FileResolver {
constructor(private readonly fileMetadataService: FileMetadataService) {}
@Mutation(() => FileDTO)
@Mutation(() => FileDTO, {
deprecationReason: 'Use specific file service instead',
})
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async createFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@@ -43,7 +45,9 @@ export class FileResolver {
});
}
@Mutation(() => FileDTO)
@Mutation(() => FileDTO, {
deprecationReason: '',
})
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async deleteFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FileFolder } from 'twenty-shared/types';
import { extractFolderPathFilenameAndTypeOrThrow } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { FileFolder } from 'twenty-shared/types';
import { type FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -20,6 +20,9 @@ export class FileMetadataService {
private readonly fileUploadService: FileUploadService,
) {}
/**
* @deprecated
*/
async createFile({
file,
filename,
@@ -54,6 +57,9 @@ export class FileMetadataService {
return savedFile;
}
/**
* @deprecated
*/
async deleteFileById(
id: string,
workspaceId: string,
@@ -1,6 +1,9 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
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 { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -32,6 +35,14 @@ describe('FileService', () => {
provide: JwtWrapperService,
useValue: {},
},
{
provide: getRepositoryToken(FileEntity),
useValue: {},
},
{
provide: getRepositoryToken(ApplicationEntity),
useValue: {},
},
],
}).compile();
@@ -1,20 +1,26 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { basename, dirname, extname } from 'path';
import { type Readable } from 'stream';
import { isNonEmptyString } from '@sniptt/guards';
import { FileFolder } from 'twenty-shared/types';
import {
buildSignedPath,
extractFolderPathFilenameAndTypeOrThrow,
} from 'twenty-shared/utils';
import { Like, Repository } from 'typeorm';
import { v4 as uuidV4 } from 'uuid';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
type FileTokenJwtPayload,
type FileTokenJwtPayloadLegacy,
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 { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.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';
@@ -24,6 +30,10 @@ export class FileService {
private readonly jwtWrapperService: JwtWrapperService,
private readonly fileStorageService: FileStorageService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async getFileStream(
@@ -38,6 +48,40 @@ export class FileService {
});
}
async getFileStreamById({
fileId,
workspaceId,
fileFolder,
}: {
fileId: string;
workspaceId: string;
fileFolder: FileFolder;
}): Promise<Readable> {
{
const file = await this.fileRepository.findOneOrFail({
where: {
id: fileId,
workspaceId,
path: Like(`${fileFolder}/%`),
},
});
const application = await this.applicationRepository.findOneOrFail({
where: {
id: file.applicationId,
workspaceId,
},
});
return this.fileStorageService.readFile({
resourcePath: removeFileFolderFromFileEntityPath(file.path),
fileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
});
}
}
signFileUrl({ url, workspaceId }: { url: string; workspaceId: string }) {
if (!isNonEmptyString(url)) {
return url;
@@ -52,12 +96,14 @@ export class FileService {
});
}
encodeFileToken(payloadToEncode: Omit<FileTokenJwtPayload, 'type' | 'sub'>) {
encodeFileToken(
payloadToEncode: Omit<FileTokenJwtPayloadLegacy, 'type' | 'sub'>,
) {
const fileTokenExpiresIn = this.twentyConfigService.get(
'FILE_TOKEN_EXPIRES_IN',
);
const payload: FileTokenJwtPayload = {
const payload: FileTokenJwtPayloadLegacy = {
...payloadToEncode,
sub: payloadToEncode.workspaceId,
type: JwtTokenTypeEnum.FILE,
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@@ -19,6 +20,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
MessagingImportManagerModule,
MessagingSendManagerModule,
TypeOrmModule.forFeature([FileEntity]),
FeatureFlagModule,
FileModule,
JwtModule,
SecureHttpClientModule,
@@ -7,8 +7,8 @@ import {
type CodeExecutionFile,
type CodeExecutionState,
} from 'twenty-shared/ai';
import { v4 } from 'uuid';
import { FileFolder } from 'twenty-shared/types';
import { v4 } from 'uuid';
import {
type InputFile,
@@ -1,17 +1,23 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Readable } from 'stream';
import { render, toPlainText } from '@react-email/render';
import DOMPurify from 'dompurify';
import { reactMarkupFromJSON } from 'twenty-emails';
import { FileFolder } from 'twenty-shared/types';
import {
extractFolderPathFilenameAndTypeOrThrow,
isDefined,
isValidUuid,
} from 'twenty-shared/utils';
import { WorkflowAttachment } from 'twenty-shared/workflow';
import { In, type Repository } from 'typeorm';
import { z } from 'zod';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import {
@@ -40,8 +46,16 @@ export class EmailComposerService {
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
private readonly fileService: FileService,
private readonly featureFlagService: FeatureFlagService,
) {}
private async isOtherFileMigrated(workspaceId: string): Promise<boolean> {
return this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_OTHER_FILE_MIGRATED,
workspaceId,
);
}
private async getConnectedAccount(
connectedAccountId: string,
workspaceId: string,
@@ -166,7 +180,7 @@ export class EmailComposerService {
}
private async getAttachments(
files: Array<{ id: string; name: string; type: string }>,
files: Array<WorkflowAttachment>,
workspaceId: string,
): Promise<MessageAttachment[]> {
if (files.length === 0) {
@@ -207,11 +221,23 @@ export class EmailComposerService {
fileEntity.path,
);
const stream = await this.fileService.getFileStream(
folderPath,
filename,
workspaceId,
);
const isOtherFileMigrated = await this.isOtherFileMigrated(workspaceId);
let stream: Readable;
if (isOtherFileMigrated) {
stream = await this.fileService.getFileStreamById({
fileId: fileMetadata.id,
workspaceId,
fileFolder: FileFolder.Workflow,
});
} else {
stream = await this.fileService.getFileStream(
folderPath,
filename,
workspaceId,
);
}
const buffer = await streamToBuffer(stream);
@@ -7,6 +7,7 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
@@ -49,6 +50,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
TokenModule,
PermissionsModule,
OnboardingModule,
FeatureFlagModule,
],
services: [UserWorkspaceService],
}),
@@ -21,7 +21,7 @@ export class UserWorkspaceResolver {
@Mutation(() => SignedFileDTO)
@UseGuards(WorkspaceAuthGuard, UploadProfilePicturePermissionGuard)
async uploadWorkspaceMemberProfilePicture(
async uploadWorkspaceMemberProfilePictureLegacy(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@@ -1,15 +1,17 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type DataSource, type Repository } from 'typeorm';
import { FileFolder } from 'twenty-shared/types';
import { type DataSource, type Repository } from 'typeorm';
import { type ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import {
FileUploadService,
type SignedFilesResult,
@@ -118,6 +120,10 @@ describe('UserWorkspaceService', () => {
assignRoleToManyUserWorkspace: jest.fn(),
},
},
{
provide: FileCorePictureService,
useValue: {},
},
{
provide: FileStorageService,
useValue: {
@@ -146,6 +152,12 @@ describe('UserWorkspaceService', () => {
setOnboardingCreateProfilePending: jest.fn(),
},
},
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn(),
},
},
],
}).compile();
@@ -2,9 +2,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { type QueryRunner, IsNull, Not, type Repository } from 'typeorm';
import { FileFolder } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Not, type QueryRunner, type Repository } from 'typeorm';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
@@ -17,7 +17,11 @@ import {
import { type AvailableWorkspace } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -53,9 +57,11 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
private readonly approvedAccessDomainService: ApprovedAccessDomainService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly userRoleService: UserRoleService,
private readonly fileCorePictureService: FileCorePictureService,
private readonly fileUploadService: FileUploadService,
private readonly fileService: FileService,
private readonly onboardingService: OnboardingService,
private readonly featureFlagService: FeatureFlagService,
) {
super(userWorkspaceRepository);
}
@@ -66,11 +72,13 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
workspaceId,
isExistingUser,
pictureUrl,
applicationUniversalIdentifier,
}: {
userId: string;
workspaceId: string;
isExistingUser: boolean;
pictureUrl?: string;
applicationUniversalIdentifier?: string;
},
queryRunner?: QueryRunner,
): Promise<UserWorkspaceEntity> {
@@ -79,6 +87,8 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
workspaceId,
isExistingUser,
pictureUrl,
applicationUniversalIdentifier,
queryRunner,
);
const userWorkspace = this.userWorkspaceRepository.create({
@@ -382,6 +392,38 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
workspaceId: string,
isExistingUser: boolean,
pictureUrl?: string,
applicationUniversalIdentifier?: string,
queryRunner?: QueryRunner,
) {
const isOtherFileMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_OTHER_FILE_MIGRATED,
workspaceId,
);
if (isOtherFileMigrated) {
return this.computeDefaultAvatarUrlMigrated(
userId,
workspaceId,
isExistingUser,
pictureUrl,
applicationUniversalIdentifier,
queryRunner,
);
}
return this.computeDefaultAvatarUrlLegacy(
userId,
workspaceId,
isExistingUser,
pictureUrl,
);
}
private async computeDefaultAvatarUrlLegacy(
userId: string,
workspaceId: string,
isExistingUser: boolean,
pictureUrl?: string,
) {
if (isExistingUser) {
const userWorkspace = await this.userWorkspaceRepository.findOne({
@@ -428,6 +470,69 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
return files[0].path;
}
private async computeDefaultAvatarUrlMigrated(
userId: string,
workspaceId: string,
isExistingUser: boolean,
pictureUrl?: string,
applicationUniversalIdentifier?: string,
queryRunner?: QueryRunner,
) {
if (isExistingUser) {
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: {
userId,
defaultAvatarUrl: Not(IsNull()),
},
order: {
createdAt: 'ASC',
},
});
if (!isDefined(userWorkspace?.defaultAvatarUrl)) return;
const sourceFileId = extractFileIdFromUrl(
userWorkspace.defaultAvatarUrl,
FileFolder.CorePicture,
);
if (!isDefined(sourceFileId)) return;
try {
const savedFile =
await this.fileCorePictureService.copyWorkspaceMemberProfilePicture({
sourceWorkspaceId: userWorkspace.workspaceId,
sourceFileId,
targetWorkspaceId: workspaceId,
targetApplicationUniversalIdentifier:
applicationUniversalIdentifier,
queryRunner,
});
return savedFile.url;
} catch (error) {
if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) {
return;
}
throw error;
}
}
if (!isDefined(pictureUrl) || pictureUrl === '') return;
const savedFile =
await this.fileCorePictureService.uploadWorkspaceMemberProfilePictureFromUrl(
{
imageUrl: pictureUrl,
workspaceId,
applicationUniversalIdentifier,
queryRunner,
},
);
return savedFile?.url;
}
castWorkspaceToAvailableWorkspace(workspace: WorkspaceEntity) {
return {
id: workspace.id,
@@ -13,6 +13,7 @@ import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomai
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { type MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
@@ -115,6 +116,7 @@ describe('WorkspaceService', () => {
FeatureFlagService,
ExceptionHandlerService,
PermissionsService,
FileCorePictureService,
].map((service) => ({
provide: service,
useValue: {},
@@ -18,6 +18,7 @@ import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custo
import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import {
FileWorkspaceFolderDeletionJob,
type FileWorkspaceFolderDeletionJobData,
@@ -108,6 +109,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
private readonly subdomainManagerService: SubdomainManagerService,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly customDomainManagerService: CustomDomainManagerService,
private readonly fileCorePictureService: FileCorePictureService,
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
private readonly messageQueueService: MessageQueueService,
@InjectDataSource()
@@ -214,8 +216,10 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
);
}
let updatedWorkspace: WorkspaceEntity;
try {
return await this.workspaceRepository.save({
updatedWorkspace = await this.workspaceRepository.save({
...workspace,
...payload,
});
@@ -230,6 +234,15 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
}
throw error;
}
if (payload.logo === null && isDefined(workspace.logoFileId)) {
await this.fileCorePictureService.deleteCorePicture({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
});
}
return updatedWorkspace;
}
async activateWorkspace(
@@ -13,6 +13,7 @@ import {
JoinColumn,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
@@ -26,6 +27,7 @@ import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/applica
import { ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { PostgresCredentialsEntity } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
@@ -72,10 +74,22 @@ export class WorkspaceEntity {
@Column({ nullable: true })
displayName?: string;
//deprecated
@Field({ nullable: true })
@Column({ nullable: true })
logo?: string;
@Field(() => UUIDScalarType, { nullable: true })
@Column({ nullable: true, type: 'uuid' })
logoFileId: string | null;
@OneToOne(() => FileEntity, {
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'logoFileId' })
logoFile: Relation<FileEntity>;
@Field({ nullable: true })
@Column({ nullable: true })
inviteHash?: string;
@@ -16,9 +16,10 @@ import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { fromFlatApplicationToApplicationDto } from 'src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util';
import { BillingEntitlementDTO } from 'src/engine/core-modules/billing/dtos/billing-entitlement.dto';
import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
@@ -32,6 +33,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
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 { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { FileService } from 'src/engine/core-modules/file/services/file.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';
@@ -71,7 +73,6 @@ import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util';
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { getRequest } from 'src/utils/extract-request';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
const OriginHeader = createParamDecorator(
@@ -96,6 +97,7 @@ export class WorkspaceResolver {
private readonly twentyConfigService: TwentyConfigService,
private readonly fileUploadService: FileUploadService,
private readonly fileService: FileService,
private readonly fileUrlService: FileUrlService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly featureFlagService: FeatureFlagService,
private readonly roleService: RoleService,
@@ -159,7 +161,7 @@ export class WorkspaceResolver {
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
)
async uploadWorkspaceLogo(
async uploadWorkspaceLogoLegacy(
@AuthWorkspace() { id }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
@@ -301,6 +303,23 @@ export class WorkspaceResolver {
@ResolveField(() => String)
async logo(@Parent() workspace: WorkspaceEntity): Promise<string> {
if (
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
workspace.id,
)
) {
if (!isDefined(workspace.logoFileId)) {
return '';
}
return this.fileUrlService.signFileByIdUrl({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
fileFolder: FileFolder.CorePicture,
});
}
if (workspace.logo) {
try {
return this.fileService.signFileUrl({