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:
@@ -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;
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+13
-8
@@ -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
-2
@@ -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;
|
||||
|
||||
+29
@@ -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 {}
|
||||
+66
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+259
@@ -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}`;
|
||||
}
|
||||
}
|
||||
+14
@@ -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 {}
|
||||
+44
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+72
@@ -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
-7
@@ -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 {}
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
|
||||
+14
-89
@@ -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}`;
|
||||
}
|
||||
}
|
||||
+57
@@ -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);
|
||||
});
|
||||
});
|
||||
+7
-3
@@ -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;
|
||||
};
|
||||
|
||||
+29
-14
@@ -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;
|
||||
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+32
-6
@@ -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],
|
||||
}),
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+13
-1
@@ -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();
|
||||
|
||||
|
||||
+107
-2
@@ -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,
|
||||
|
||||
+2
@@ -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: {},
|
||||
|
||||
+14
-1
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user