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
@@ -18,7 +18,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 { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@@ -48,7 +48,7 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
private readonly filesFieldService: FilesFieldService,
private readonly fileUrlService: FileUrlService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
@@ -237,7 +237,10 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus
const props = (block.props as Record<string, unknown>) || {};
const url = props.url as string | undefined;
return isDefined(url) && !isDefined(extractFileIdFromUrl(url));
return (
isDefined(url) &&
!isDefined(extractFileIdFromUrl(url, FileFolder.FilesField))
);
});
if (!needsMigration) {
@@ -259,7 +262,7 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus
if (
!isDefined(url) ||
isDefined(extractFileIdFromUrl(url)) ||
isDefined(extractFileIdFromUrl(url, FileFolder.FilesField)) ||
!url.includes('/files/attachment/')
) {
enrichedBlocknote.push(block);
@@ -292,9 +295,10 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus
);
hasChanges = true;
const signedUrl = this.filesFieldService.signFileUrl({
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.FilesField,
});
enrichedBlocknote.push({
@@ -0,0 +1,214 @@
import { Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { FileFolder } from 'twenty-shared/types';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { DataSource, In, Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.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 { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import {
WorkflowVersionStatus,
WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
type WorkflowFile = {
id: string;
path?: string;
name: string;
size: number;
type: string;
createdAt: string;
};
type SendEmailStep = {
id: string;
type: WorkflowActionType.SEND_EMAIL;
settings: {
input: {
files?: WorkflowFile[];
[key: string]: unknown;
};
[key: string]: unknown;
};
[key: string]: unknown;
};
@Command({
name: 'upgrade:1-18:migrate-workflow-send-email-attachments',
description:
'Migrate workflow send email attachments to FileFolder.Workflow and update payload paths',
})
export class MigrateWorkflowSendEmailAttachmentsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(
MigrateWorkflowSendEmailAttachmentsCommand.name,
);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly featureFlagService: FeatureFlagService,
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const isMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_OTHER_FILE_MIGRATED,
workspaceId,
);
if (isMigrated) {
this.logger.log(
`Workflow attachments migration already completed for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting workflow send email attachments migration for workspace ${workspaceId}`,
);
const workflowVersionRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const workflowVersions = await workflowVersionRepository.find({
select: ['id', 'steps'],
where: {
status: In([
WorkflowVersionStatus.DRAFT,
WorkflowVersionStatus.ACTIVE,
WorkflowVersionStatus.DEACTIVATED,
]),
},
});
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const fileRepository = this.coreDataSource.getRepository(FileEntity);
for (const workflowVersion of workflowVersions) {
const steps = workflowVersion.steps;
if (!isNonEmptyArray(steps)) {
continue;
}
for (const step of steps) {
if (step.type !== WorkflowActionType.SEND_EMAIL) {
continue;
}
const sendEmailStep = step as SendEmailStep;
const files = sendEmailStep.settings?.input?.files;
if (!isNonEmptyArray(files)) {
continue;
}
for (const file of files) {
const fileEntity = await fileRepository.findOne({
where: {
id: file.id,
workspaceId,
},
});
if (!isDefined(fileEntity)) {
this.logger.warn(
`File ${file.id} not found for workflow version ${workflowVersion.id}, skipping`,
);
continue;
}
if (fileEntity.path.startsWith(FileFolder.Workflow)) {
this.logger.log(
`File ${file.id} already in Workflow folder, skipping copy`,
);
continue;
}
const newResourcePath = `${fileEntity.id}${isNonEmptyString(file.type) ? `.${file.type}` : ''}`;
const newPath = `${FileFolder.Workflow}/${newResourcePath}`;
if (!isDryRun) {
try {
await this.fileStorageService.copyLegacy({
from: {
folderPath: `workspace-${workspaceId}`,
filename: fileEntity.path,
},
to: {
folderPath: `${workspaceId}/${workspaceCustomFlatApplication.universalIdentifier}`,
filename: newPath,
},
});
} catch (error) {
this.logger.error(
`Failed to migrate file ${fileEntity.id} in workspace ${workspaceId}: ${error.message}`,
);
continue;
}
} else {
this.logger.log(
`[DRY RUN] Would migrate file ${fileEntity.id} from ${fileEntity.path} to ${newPath}`,
);
}
await fileRepository.update(
{ id: fileEntity.id },
{
path: newPath,
applicationId: workspaceCustomFlatApplication.id,
settings: {
isTemporaryFile: true,
toDelete: false,
},
},
);
}
}
}
if (!isDryRun) {
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_OTHER_FILE_MIGRATED],
workspaceId,
);
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Completed workflow send email attachments migration for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,327 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FileFolder } from 'twenty-shared/types';
import {
extractFolderPathFilenameAndTypeOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { And, DataSource, IsNull, Like, Not, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
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 { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Command({
name: 'upgrade:1-18:migrate-workspace-pictures',
description:
'Migrate workspace logos and workspace member avatars to file records',
})
export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly featureFlagService: FeatureFlagService,
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
private readonly fileUrlService: FileUrlService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const isMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
workspaceId,
);
if (isMigrated) {
this.logger.log(
`Workspace pictures migration already completed for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting workspace pictures migration for workspace ${workspaceId}`,
);
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const fileRepository = this.coreDataSource.getRepository(FileEntity);
await this.migrateWorkspaceLogo({
workspaceId,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
});
await this.migrateWorkspaceMemberAvatars({
workspaceId,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
});
if (!isDryRun) {
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_CORE_PICTURE_MIGRATED],
workspaceId,
);
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Completed workspace pictures migration for workspace ${workspaceId}`,
);
}
private async migrateWorkspaceLogo({
workspaceId,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
}: {
workspaceId: string;
isDryRun: boolean;
workspaceCustomFlatApplication: FlatApplication;
fileRepository: Repository<FileEntity>;
}): Promise<void> {
const workspace = await this.workspaceRepository.findOne({
where: {
id: workspaceId,
logo: Not(IsNull()),
logoFileId: IsNull(),
},
});
if (!workspace || !isNonEmptyString(workspace.logo)) {
this.logger.log(
`No workspace logo to migrate for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Migrating workspace logo for workspace ${workspaceId}: ${workspace.logo}`,
);
try {
const { type: fileExtension } = extractFolderPathFilenameAndTypeOrThrow(
workspace.logo,
);
const fileId = v4();
const newFilename = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`;
const newResourcePath = `${FileFolder.CorePicture}/${newFilename}`;
if (!isDryRun) {
await this.fileStorageService.copyLegacy({
from: {
folderPath: `workspace-${workspaceId}`,
filename: workspace.logo,
},
to: {
folderPath: `${workspaceId}/${workspaceCustomFlatApplication.universalIdentifier}`,
filename: newResourcePath,
},
});
const fileEntity = fileRepository.create({
id: fileId,
path: newResourcePath,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
size: -1,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
await fileRepository.save(fileEntity);
await this.workspaceRepository.update(
{ id: workspaceId },
{ logoFileId: fileId },
);
}
this.logger.log(
`Migrated workspace logo for workspace ${workspaceId} (${workspace.logo} -> ${newResourcePath})`,
);
} catch (error) {
this.logger.error(
`Failed to migrate workspace logo for workspace ${workspaceId}: ${error.message}`,
);
throw error;
}
}
private async migrateWorkspaceMemberAvatars({
workspaceId,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
}: {
workspaceId: string;
isDryRun: boolean;
workspaceCustomFlatApplication: FlatApplication;
fileRepository: Repository<FileEntity>;
}): Promise<void> {
const { flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
]);
const workspaceMemberObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier:
STANDARD_OBJECTS.workspaceMember.universalIdentifier,
});
if (!isDefined(workspaceMemberObjectMetadata)) {
this.logger.warn(
`Workspace member object metadata not found for workspace ${workspaceId}, skipping member avatar migration`,
);
return;
}
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMembers = await workspaceMemberRepository.find({
where: {
avatarUrl: And(Not(IsNull()), Not(Like(`%${FileFolder.CorePicture}%`))),
},
select: ['id', 'avatarUrl'],
});
if (workspaceMembers.length === 0) {
this.logger.log(
`No workspace member avatars to migrate for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Found ${workspaceMembers.length} workspace member avatar(s) to migrate in workspace ${workspaceId}`,
);
for (const workspaceMember of workspaceMembers) {
if (!isNonEmptyString(workspaceMember.avatarUrl)) {
this.logger.warn(
`Skipping workspace member ${workspaceMember.id} - invalid avatarUrl`,
);
continue;
}
try {
const { type: fileExtension } = extractFolderPathFilenameAndTypeOrThrow(
workspaceMember.avatarUrl,
);
const fileId = v4();
const newFilename = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`;
const newResourcePath = `${FileFolder.CorePicture}/${newFilename}`;
if (!isDryRun) {
await this.fileStorageService.copyLegacy({
from: {
folderPath: `workspace-${workspaceId}`,
filename: workspaceMember.avatarUrl,
},
to: {
folderPath: `${workspaceId}/${workspaceCustomFlatApplication.universalIdentifier}`,
filename: newResourcePath,
},
});
const fileEntity = fileRepository.create({
id: fileId,
path: newResourcePath,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
size: -1,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
await fileRepository.save(fileEntity);
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.CorePicture,
});
await workspaceMemberRepository.update(
{ id: workspaceMember.id },
{
avatarUrl: signedUrl,
},
);
}
this.logger.log(
`Migrated workspace member avatar ${workspaceMember.id}`,
);
} catch (error) {
this.logger.error(
`Failed to migrate workspace member avatar ${workspaceMember.id} in workspace ${workspaceId}: ${error.message}`,
);
throw error;
}
}
if (!isDryRun) {
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_CORE_PICTURE_MIGRATED],
workspaceId,
);
}
}
}
@@ -8,12 +8,13 @@ import { MigrateActivityRichTextAttachmentFileIdsCommand } from 'src/database/co
import { MigrateAttachmentFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command';
import { MigrateFavoritesToNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-favorites-to-navigation-menu-items.command';
import { MigratePersonAvatarFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command';
import { MigrateWorkflowSendEmailAttachmentsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workflow-send-email-attachments.command';
import { MigrateWorkspacePicturesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.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 { FilesFieldModule } from 'src/engine/core-modules/file/files-field/files-field.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
@@ -21,11 +22,12 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Module({
imports: [
@@ -35,18 +37,18 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso
PersonWorkspaceEntity,
FileEntity,
AttachmentWorkspaceEntity,
WorkspaceMemberWorkspaceEntity,
ObjectMetadataEntity,
FieldMetadataEntity,
]),
DataSourceModule,
FeatureFlagModule,
FileStorageModule.forRoot(),
WorkspaceCacheModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataVersionModule,
FieldMetadataModule,
ApplicationModule,
FilesFieldModule,
FileModule,
UserWorkspaceModule,
WorkspaceMigrationModule,
],
@@ -55,18 +57,22 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso
MigrateFavoritesToNavigationMenuItemsCommand,
MigrateAttachmentFilesCommand,
BackfillFileSizeAndMimeTypeCommand,
MigrateWorkspacePicturesCommand,
MigrateActivityRichTextAttachmentFileIdsCommand,
BackfillMessageChannelThrottleRetryAfterCommand,
BackfillStandardViewsAndFieldMetadataCommand,
MigrateWorkflowSendEmailAttachmentsCommand,
],
exports: [
MigratePersonAvatarFilesCommand,
MigrateFavoritesToNavigationMenuItemsCommand,
MigrateAttachmentFilesCommand,
BackfillFileSizeAndMimeTypeCommand,
MigrateActivityRichTextAttachmentFileIdsCommand,
BackfillMessageChannelThrottleRetryAfterCommand,
BackfillStandardViewsAndFieldMetadataCommand,
MigrateWorkspacePicturesCommand,
BackfillFileSizeAndMimeTypeCommand,
MigrateWorkflowSendEmailAttachmentsCommand,
],
})
export class V1_18_UpgradeVersionCommandModule {}
@@ -25,6 +25,8 @@ import { MigrateActivityRichTextAttachmentFileIdsCommand } from 'src/database/co
import { MigrateAttachmentFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command';
import { MigrateFavoritesToNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-favorites-to-navigation-menu-items.command';
import { MigratePersonAvatarFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command';
import { MigrateWorkflowSendEmailAttachmentsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workflow-send-email-attachments.command';
import { MigrateWorkspacePicturesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@@ -63,6 +65,8 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly migrateActivityRichTextAttachmentFileIdsCommand: MigrateActivityRichTextAttachmentFileIdsCommand,
protected readonly backfillMessageChannelThrottleRetryAfterCommand: BackfillMessageChannelThrottleRetryAfterCommand,
protected readonly backfillStandardViewsAndFieldMetadataCommand: BackfillStandardViewsAndFieldMetadataCommand,
protected readonly migrateWorkspacePicturesCommand: MigrateWorkspacePicturesCommand,
protected readonly migrateWorkflowSendEmailAttachmentsCommand: MigrateWorkflowSendEmailAttachmentsCommand,
) {
super(
workspaceRepository,
@@ -92,6 +96,8 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.migratePersonAvatarFilesCommand,
this.migrateAttachmentFilesCommand,
this.migrateActivityRichTextAttachmentFileIdsCommand,
this.migrateWorkspacePicturesCommand,
this.migrateWorkflowSendEmailAttachmentsCommand,
this.backfillFileSizeAndMimeTypeCommand,
this.backfillMessageChannelThrottleRetryAfterCommand,
this.backfillStandardViewsAndFieldMetadataCommand,
@@ -0,0 +1,31 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddLogoFileIdColumnOnWorkspaceTable1771323022170
implements MigrationInterface
{
name = 'AddLogoFileIdColumnOnWorkspaceTable1771323022170';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "logoFileId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD CONSTRAINT "UQ_282123b2f32e927b6003311e33a" UNIQUE ("logoFileId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD CONSTRAINT "FK_282123b2f32e927b6003311e33a" FOREIGN KEY ("logoFileId") REFERENCES "core"."file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP CONSTRAINT "FK_282123b2f32e927b6003311e33a"`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP CONSTRAINT "UQ_282123b2f32e927b6003311e33a"`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "logoFileId"`,
);
}
}
@@ -16,7 +16,7 @@ import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/works
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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.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 { 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';
@@ -42,7 +42,7 @@ export class CommonResultGettersService {
constructor(
private readonly fileService: FileService,
private readonly filesFieldService: FilesFieldService,
private readonly fileUrlService: FileUrlService,
private readonly featureFlagService: FeatureFlagService,
) {
this.initializeObjectHandlers();
@@ -55,7 +55,11 @@ export class CommonResultGettersService {
['person', new PersonQueryResultGetterHandler(this.fileService)],
[
'workspaceMember',
new WorkspaceMemberQueryResultGetterHandler(this.fileService),
new WorkspaceMemberQueryResultGetterHandler(
this.fileService,
this.featureFlagService,
this.fileUrlService,
),
],
]);
}
@@ -67,13 +71,13 @@ export class CommonResultGettersService {
>([
[
FieldMetadataType.FILES,
new FilesFieldQueryResultGetterHandler(this.filesFieldService),
new FilesFieldQueryResultGetterHandler(this.fileUrlService),
],
[
FieldMetadataType.RICH_TEXT_V2,
new RichTextV2FieldQueryResultGetterHandler(
this.fileService,
this.filesFieldService,
this.fileUrlService,
this.featureFlagService,
),
],
@@ -2,7 +2,7 @@ import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import { RichTextV2FieldQueryResultGetterHandler } from 'src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler';
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service';
import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { type FileService } from 'src/engine/core-modules/file/services/file.service';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -24,9 +24,9 @@ const mockFileService = {
signFileUrl: jest.fn().mockReturnValue('signed-path'),
} as unknown as FileService;
const mockFilesFieldService = {
const mockFileUrlService = {
signFileUrl: jest.fn().mockReturnValue('signed-path'),
} as unknown as FilesFieldService;
} as unknown as FileUrlService;
const mockFeatureFlagService = {
isFeatureEnabled: jest.fn().mockReturnValue(true),
@@ -39,7 +39,7 @@ describe('RichTextV2FieldQueryResultGetterHandler', () => {
process.env.SERVER_URL = 'https://my-domain.twenty.com';
handler = new RichTextV2FieldQueryResultGetterHandler(
mockFileService,
mockFilesFieldService,
mockFileUrlService,
mockFeatureFlagService,
);
});
@@ -1,16 +1,20 @@
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import {
FieldMetadataType,
FileFolder,
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 FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.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) {}
constructor(private readonly fileUrlService: FileUrlService) {}
async handle(
record: ObjectRecord,
@@ -35,9 +39,10 @@ export class FilesFieldQueryResultGetterHandler
const signedFilesFieldValue: SignedFileOutput[] = [];
for (const file of filesFieldValue) {
const url = this.filesFieldService.signFileUrl({
const url = this.fileUrlService.signFileByIdUrl({
fileId: file.fileId,
workspaceId,
fileFolder: FileFolder.FilesField,
});
signedFilesFieldValue.push({
@@ -1,11 +1,15 @@
import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types';
import {
FieldMetadataType,
FileFolder,
type ObjectRecord,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service';
import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { type FileService } from 'src/engine/core-modules/file/services/file.service';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -34,7 +38,7 @@ export class RichTextV2FieldQueryResultGetterHandler
{
constructor(
private readonly fileService: FileService,
private readonly filesFieldService: FilesFieldService,
private readonly fileUrlService: FileUrlService,
private readonly featureFlagService: FeatureFlagService,
) {}
@@ -92,15 +96,19 @@ export class RichTextV2FieldQueryResultGetterHandler
): RichTextBlock[] => {
return blocknoteBlocks.map((block: RichTextBlock) => {
if (isFilesFieldMigrated && isDefined(block.props?.url)) {
const fileIdFromUrl = extractFileIdFromUrl(block.props.url);
const fileIdFromUrl = extractFileIdFromUrl(
block.props.url,
FileFolder.FilesField,
);
if (!isDefined(fileIdFromUrl)) {
return block;
}
const url = this.filesFieldService.signFileUrl({
const url = this.fileUrlService.signFileByIdUrl({
fileId: fileIdFromUrl,
workspaceId,
fileFolder: FileFolder.FilesField,
});
return {
@@ -13,7 +13,6 @@ 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';
@@ -35,7 +34,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
UserRoleModule,
ApiKeyModule,
FileModule,
FilesFieldModule,
ViewModule,
ViewFilterModule,
ViewFilterGroupModule,
@@ -1,12 +1,23 @@
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { type FileService } from 'src/engine/core-modules/file/services/file.service';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export class WorkspaceMemberQueryResultGetterHandler
implements QueryResultGetterHandlerInterface
{
constructor(private readonly fileService: FileService) {}
constructor(
private readonly fileService: FileService,
private readonly featureFlagService: FeatureFlagService,
private readonly fileUrlService: FileUrlService,
) {}
async handle(
workspaceMember: WorkspaceMemberWorkspaceEntity,
@@ -16,6 +27,33 @@ export class WorkspaceMemberQueryResultGetterHandler
return workspaceMember;
}
if (
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
workspaceId,
)
) {
const fileId = extractFileIdFromUrl(
workspaceMember.avatarUrl,
FileFolder.CorePicture,
);
if (!isDefined(fileId)) {
return workspaceMember;
}
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.CorePicture,
});
return {
...workspaceMember,
avatarUrl: signedUrl,
};
}
const signedPath = this.fileService.signFileUrl({
url: workspaceMember.avatarUrl,
workspaceId,
@@ -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({
@@ -4,6 +4,7 @@ import { Args, Context, Mutation, Parent, ResolveField } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
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 { ForbiddenError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@@ -12,7 +13,6 @@ import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.typ
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
@@ -245,6 +245,8 @@ describe('WorkspaceEntityManager', () => {
IS_MARKETPLACE_ENABLED: false,
IS_FILES_FIELD_MIGRATED: false,
IS_DRAFT_EMAIL_ENABLED: false,
IS_CORE_PICTURE_MIGRATED: false,
IS_OTHER_FILE_MIGRATED: false,
},
userWorkspaceRoleMap: {},
eventEmitterService: {
@@ -6,6 +6,7 @@ import { FavoriteFolderModule } from 'src/modules/favorite-folder/favorite-folde
import { FavoriteModule } from 'src/modules/favorite/favorite.module';
import { MessagingModule } from 'src/modules/messaging/messaging.module';
import { WorkflowModule } from 'src/modules/workflow/workflow.module';
import { WorkspaceMemberModule } from 'src/modules/workspace-member/workspace-member.module';
@Module({
imports: [
@@ -15,6 +16,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
WorkflowModule,
FavoriteFolderModule,
FavoriteModule,
WorkspaceMemberModule,
],
providers: [],
exports: [],
@@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import {
ObjectRecordDeleteEvent,
ObjectRecordDestroyEvent,
ObjectRecordUpdateEvent,
} from 'twenty-shared/database-events';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
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 { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class WorkspaceMemberAvatarFileDeletionListener {
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly fileCorePictureService: FileCorePictureService,
) {}
@OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.UPDATED)
async handleUpdate(
payload: WorkspaceEventBatch<
ObjectRecordUpdateEvent<WorkspaceMemberWorkspaceEntity>
>,
) {
if (
!(await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
payload.workspaceId,
))
) {
return;
}
const fileIdsToDelete = this.getFileIdsToDeleteFromUpdateEvent(payload);
this.deleteCorePictures(fileIdsToDelete, payload.workspaceId);
}
@OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DESTROYED)
@OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DELETED)
async handleDestroyOrDeleteEvent(
payload: WorkspaceEventBatch<
| ObjectRecordDestroyEvent<WorkspaceMemberWorkspaceEntity>
| ObjectRecordDeleteEvent<WorkspaceMemberWorkspaceEntity>
>,
) {
const fileIdsToDelete =
this.getFileIdsToDeleteFromDestroyOrDeleteEvent(payload);
await this.deleteCorePictures(fileIdsToDelete, payload.workspaceId);
}
private async deleteCorePictures(
fileIds: string[],
workspaceId: string,
): Promise<void> {
for (const fileId of fileIds) {
await this.fileCorePictureService.deleteCorePicture({
workspaceId,
fileId,
});
}
}
private getFileIdsToDeleteFromUpdateEvent(
payload: WorkspaceEventBatch<
ObjectRecordUpdateEvent<WorkspaceMemberWorkspaceEntity>
>,
): string[] {
return payload.events
.map((event) => {
const beforeAvatarUrl = event.properties.before.avatarUrl;
if (!isDefined(beforeAvatarUrl)) {
return undefined;
}
const beforeFileId = extractFileIdFromUrl(
beforeAvatarUrl,
FileFolder.CorePicture,
);
const afterFileId = extractFileIdFromUrl(
event.properties.after.avatarUrl ?? '',
FileFolder.CorePicture,
);
return beforeFileId !== afterFileId ? beforeFileId : undefined;
})
.filter(isDefined);
}
private getFileIdsToDeleteFromDestroyOrDeleteEvent(
payload:
| WorkspaceEventBatch<
ObjectRecordDestroyEvent<WorkspaceMemberWorkspaceEntity>
>
| WorkspaceEventBatch<
ObjectRecordDeleteEvent<WorkspaceMemberWorkspaceEntity>
>,
): string[] {
return payload.events
.map((event) =>
isDefined(event.properties.before.avatarUrl)
? extractFileIdFromUrl(
event.properties.before.avatarUrl,
FileFolder.CorePicture,
)
: undefined,
)
.filter(isDefined);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceMemberAvatarFileDeletionListener } from 'src/modules/workspace-member/listeners/workspace-member-avatar-file-deletion.listener';
@Module({
imports: [TwentyORMModule, FeatureFlagModule, FileModule],
providers: [WorkspaceMemberAvatarFileDeletionListener],
})
export class WorkspaceMemberModule {}