diff --git a/apps/server/src/infra/preview-generator/interface/index.ts b/apps/server/src/infra/preview-generator/interface/index.ts index 45799160cd5..e2b9cf3f7c2 100644 --- a/apps/server/src/infra/preview-generator/interface/index.ts +++ b/apps/server/src/infra/preview-generator/interface/index.ts @@ -1 +1,2 @@ export * from './preview'; +export * from './preview-input-mime-types.enum'; diff --git a/apps/server/src/modules/files-storage/interface/preview-input-mime-types.enum.ts b/apps/server/src/infra/preview-generator/interface/preview-input-mime-types.enum.ts similarity index 88% rename from apps/server/src/modules/files-storage/interface/preview-input-mime-types.enum.ts rename to apps/server/src/infra/preview-generator/interface/preview-input-mime-types.enum.ts index 495096c8359..20c24b65fd1 100644 --- a/apps/server/src/modules/files-storage/interface/preview-input-mime-types.enum.ts +++ b/apps/server/src/infra/preview-generator/interface/preview-input-mime-types.enum.ts @@ -8,4 +8,5 @@ export enum PreviewInputMimeTypes { IMAGE_HEIF = 'image/heif', IMAGE_TIFF = 'image/tiff', IMAGE_WEBP = 'image/webp', + APPLICATION_PDF = 'application/pdf', } diff --git a/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts b/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts index 016c261b122..203e4aa9b56 100644 --- a/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.service.spec.ts @@ -1,14 +1,23 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { Test, TestingModule } from '@nestjs/testing'; import { GetFile, S3ClientAdapter } from '@infra/s3-client'; +import { UnprocessableEntityException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@src/core/logger'; import { Readable } from 'node:stream'; import { PreviewGeneratorService } from './preview-generator.service'; const streamMock = jest.fn(); const resizeMock = jest.fn(); +const coalesceMock = jest.fn(); +const selectFrameMock = jest.fn(); const imageMagickMock = () => { - return { stream: streamMock, resize: resizeMock, data: Readable.from('text') }; + return { + stream: streamMock, + resize: resizeMock, + selectFrame: selectFrameMock, + coalesce: coalesceMock, + data: Readable.from('text'), + }; }; jest.mock('gm', () => { return { @@ -16,13 +25,13 @@ jest.mock('gm', () => { }; }); -const createFile = (contentRange?: string): GetFile => { +const createFile = (contentRange?: string, contentType?: string): GetFile => { const text = 'testText'; const readable = Readable.from(text); const fileResponse = { data: readable, - contentType: 'image/jpeg', + contentType, contentLength: text.length, contentRange, etag: 'testTag', @@ -68,76 +77,206 @@ describe('PreviewGeneratorService', () => { }); describe('generatePreview', () => { - const setup = (width = 500) => { - const params = { - originFilePath: 'file/test.jpeg', - previewFilePath: 'preview/text.webp', - previewOptions: { - format: 'webp', - width, - }, - }; - const originFile = createFile(); - s3ClientAdapter.get.mockResolvedValueOnce(originFile); - - const data = Readable.from('text'); - streamMock.mockReturnValueOnce(data); - - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; - - return { params, originFile, expectedFileData }; - }; - describe('WHEN download of original and preview file is successful', () => { - it('should call storageClient get method with originFilePath', async () => { - const { params } = setup(); + describe('WHEN preview is possible', () => { + describe('WHEN mime type is jpeg', () => { + const setup = (width = 500) => { + const params = { + originFilePath: 'file/test.jpeg', + previewFilePath: 'preview/text.webp', + previewOptions: { + format: 'webp', + width, + }, + }; + const originFile = createFile(undefined, 'image/jpeg'); + s3ClientAdapter.get.mockResolvedValueOnce(originFile); + + const data = Readable.from('text'); + streamMock.mockReturnValueOnce(data); + + const expectedFileData = { + data, + mimeType: params.previewOptions.format, + }; + + return { params, originFile, expectedFileData }; + }; + + it('should call storageClient get method with originFilePath', async () => { + const { params } = setup(); + + await service.generatePreview(params); + + expect(s3ClientAdapter.get).toBeCalledWith(params.originFilePath); + }); + + it('should call imagemagicks resize method', async () => { + const { params } = setup(); + + await service.generatePreview(params); + + expect(resizeMock).toHaveBeenCalledWith(params.previewOptions.width, undefined, '>'); + expect(resizeMock).toHaveBeenCalledTimes(1); + }); + + it('should call imagemagicks stream method', async () => { + const { params } = setup(); + + await service.generatePreview(params); + + expect(streamMock).toHaveBeenCalledWith(params.previewOptions.format); + expect(streamMock).toHaveBeenCalledTimes(1); + }); + + it('should call S3ClientAdapters create method', async () => { + const { params, expectedFileData } = setup(); + + await service.generatePreview(params); + + expect(s3ClientAdapter.create).toHaveBeenCalledWith(params.previewFilePath, expectedFileData); + expect(s3ClientAdapter.create).toHaveBeenCalledTimes(1); + }); + + it('should should return values', async () => { + const { params } = setup(); + const expectedValue = { previewFilePath: params.previewFilePath, status: true }; + + const result = await service.generatePreview(params); + + expect(result).toEqual(expectedValue); + }); + }); + + describe('WHEN mime type is pdf', () => { + const setup = (width = 500) => { + const params = { + originFilePath: 'file/test.pdf', + previewFilePath: 'preview/text.webp', + previewOptions: { + format: 'webp', + width, + }, + }; + const originFile = createFile(undefined, 'application/pdf'); + s3ClientAdapter.get.mockResolvedValueOnce(originFile); + + const data = Readable.from('text'); + streamMock.mockReturnValueOnce(data); + + const expectedFileData = { + data, + mimeType: params.previewOptions.format, + }; + + return { params, originFile, expectedFileData }; + }; + + it('should call imagemagicks selectFrameMock method', async () => { + const { params } = setup(); + + await service.generatePreview(params); + + expect(selectFrameMock).toHaveBeenCalledWith(0); + expect(resizeMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('WHEN mime type is gif', () => { + const setup = (width = 500) => { + const params = { + originFilePath: 'file/test.gif', + previewFilePath: 'preview/text.webp', + previewOptions: { + format: 'webp', + width, + }, + }; + const originFile = createFile(undefined, 'image/gif'); + s3ClientAdapter.get.mockResolvedValueOnce(originFile); - await service.generatePreview(params); + const data = Readable.from('text'); + streamMock.mockReturnValueOnce(data); - expect(s3ClientAdapter.get).toBeCalledWith(params.originFilePath); - }); + const expectedFileData = { + data, + mimeType: params.previewOptions.format, + }; - it('should call imagemagicks resize method', async () => { - const { params } = setup(); + return { params, originFile, expectedFileData }; + }; - await service.generatePreview(params); + it('should call imagemagicks coalesce method', async () => { + const { params } = setup(); - expect(resizeMock).toHaveBeenCalledWith(params.previewOptions.width, undefined, '>'); - expect(resizeMock).toHaveBeenCalledTimes(1); - }); - - it('should call imagemagicks stream method', async () => { - const { params } = setup(); - - await service.generatePreview(params); + await service.generatePreview(params); - expect(streamMock).toHaveBeenCalledWith(params.previewOptions.format); - expect(streamMock).toHaveBeenCalledTimes(1); + expect(coalesceMock).toHaveBeenCalledTimes(1); + expect(resizeMock).toHaveBeenCalledTimes(1); + }); + }); }); - it('should call S3ClientAdapters create method', async () => { - const { params, expectedFileData } = setup(); - - await service.generatePreview(params); - - expect(s3ClientAdapter.create).toHaveBeenCalledWith(params.previewFilePath, expectedFileData); - expect(s3ClientAdapter.create).toHaveBeenCalledTimes(1); - }); - - it('should should return values', async () => { - const { params } = setup(); - const expectedValue = { previewFilePath: params.previewFilePath, status: true }; - - const result = await service.generatePreview(params); - - expect(result).toEqual(expectedValue); + describe('WHEN preview is not possible', () => { + const setup = (mimeType?: string, width = 500) => { + const params = { + originFilePath: 'file/test.jpeg', + previewFilePath: 'preview/text.webp', + previewOptions: { + format: 'webp', + width, + }, + }; + const originFile = createFile(undefined, mimeType); + s3ClientAdapter.get.mockResolvedValueOnce(originFile); + + return { params, originFile }; + }; + + describe('WHEN mimeType is undefined', () => { + it('should throw UnprocessableEntityException', async () => { + const { params } = setup(); + + const error = new UnprocessableEntityException(); + await expect(service.generatePreview(params)).rejects.toThrowError(error); + }); + }); + + describe('WHEN mimeType is text/plain ', () => { + it('should throw UnprocessableEntityException', async () => { + const { params } = setup('text/plain'); + + const error = new UnprocessableEntityException(); + await expect(service.generatePreview(params)).rejects.toThrowError(error); + }); + }); }); }); describe('WHEN previewParams.width not set', () => { + const setup = (width = 500) => { + const params = { + originFilePath: 'file/test.jpeg', + previewFilePath: 'preview/text.webp', + previewOptions: { + format: 'webp', + width, + }, + }; + const originFile = createFile(undefined, 'image/jpeg'); + s3ClientAdapter.get.mockResolvedValueOnce(originFile); + + const data = Readable.from('text'); + streamMock.mockReturnValueOnce(data); + + const expectedFileData = { + data, + mimeType: params.previewOptions.format, + }; + + return { params, originFile, expectedFileData }; + }; + it('should not call imagemagicks resize method', async () => { const { params } = setup(0); diff --git a/apps/server/src/infra/preview-generator/preview-generator.service.ts b/apps/server/src/infra/preview-generator/preview-generator.service.ts index 35b52d5e174..5fd9fc8fb5e 100644 --- a/apps/server/src/infra/preview-generator/preview-generator.service.ts +++ b/apps/server/src/infra/preview-generator/preview-generator.service.ts @@ -1,9 +1,9 @@ import { GetFile, S3ClientAdapter } from '@infra/s3-client'; -import { Injectable } from '@nestjs/common'; +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { Logger } from '@src/core/logger'; import { subClass } from 'gm'; import { PassThrough } from 'stream'; -import { PreviewFileOptions, PreviewOptions, PreviewResponseMessage } from './interface'; +import { PreviewFileOptions, PreviewInputMimeTypes, PreviewOptions, PreviewResponseMessage } from './interface'; import { PreviewActionsLoggable } from './loggable/preview-actions.loggable'; import { PreviewGeneratorBuilder } from './preview-generator.builder'; @@ -20,6 +20,9 @@ export class PreviewGeneratorService { const { originFilePath, previewFilePath, previewOptions } = params; const original = await this.downloadOriginFile(originFilePath); + + this.checkIfPreviewPossible(original, params); + const preview = this.resizeAndConvert(original, previewOptions); const file = PreviewGeneratorBuilder.buildFile(preview, params.previewOptions); @@ -34,6 +37,16 @@ export class PreviewGeneratorService { }; } + private checkIfPreviewPossible(original: GetFile, params: PreviewFileOptions): void | UnprocessableEntityException { + const isPreviewPossible = + original.contentType && Object.values(PreviewInputMimeTypes).includes(original.contentType); + + if (!isPreviewPossible) { + this.logger.warning(new PreviewActionsLoggable('PreviewGeneratorService.previewNotPossible', params)); + throw new UnprocessableEntityException(); + } + } + private async downloadOriginFile(pathToFile: string): Promise { const file = await this.storageClient.get(pathToFile); @@ -45,6 +58,14 @@ export class PreviewGeneratorService { const preview = this.imageMagick(original.data); + if (original.contentType === PreviewInputMimeTypes.APPLICATION_PDF) { + preview.selectFrame(0); + } + + if (original.contentType === PreviewInputMimeTypes.IMAGE_GIF) { + preview.coalesce(); + } + if (width) { preview.resize(width, undefined, '>'); } diff --git a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts index 4b0688f0a0d..544d492fe65 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.visitor.spec.ts @@ -1,9 +1,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { FileRecordParentType } from '@infra/rabbitmq'; import { EntityManager } from '@mikro-orm/mongodb'; import { FileDto, FilesStorageClientAdapterService } from '@modules/files-storage-client'; import { ContextExternalToolService } from '@modules/tool/context-external-tool/service'; import { Test, TestingModule } from '@nestjs/testing'; -import { FileRecordParentType } from '@infra/rabbitmq'; import { columnBoardFactory, columnFactory, @@ -171,6 +171,14 @@ describe(RecursiveDeleteVisitor.name, () => { expect(em.remove).toHaveBeenCalledWith(em.getReference(linkElement.constructor, linkElement.id)); expect(em.remove).toHaveBeenCalledWith(em.getReference(childLinkElement.constructor, childLinkElement.id)); }); + + it('should call deleteFilesOfParent', async () => { + const { linkElement } = setup(); + + await service.visitLinkElementAsync(linkElement); + + expect(filesStorageClientAdapterService.deleteFilesOfParent).toHaveBeenCalledWith(linkElement.id); + }); }); describe('visitSubmissionContainerElementAsync', () => { diff --git a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts index 0a1b08e663a..6c8301f6b6f 100644 --- a/apps/server/src/modules/board/repo/recursive-delete.vistor.ts +++ b/apps/server/src/modules/board/repo/recursive-delete.vistor.ts @@ -49,6 +49,7 @@ export class RecursiveDeleteVisitor implements BoardCompositeVisitorAsync { } async visitLinkElementAsync(linkElement: LinkElement): Promise { + await this.filesStorageClientAdapterService.deleteFilesOfParent(linkElement.id); this.deleteNode(linkElement); await this.visitChildrenAsync(linkElement); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.spec.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.spec.ts new file mode 100644 index 00000000000..bb0a7fda403 --- /dev/null +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.spec.ts @@ -0,0 +1,109 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { LinkElement } from '@shared/domain'; +import { linkElementFactory, setupEntities } from '@shared/testing'; +import { CopyFileDto } from '@src/modules/files-storage-client/dto'; + +import { RecursiveCopyVisitor } from './recursive-copy.visitor'; +import { SchoolSpecificFileCopyServiceFactory } from './school-specific-file-copy-service.factory'; +import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; + +describe(RecursiveCopyVisitor.name, () => { + let module: TestingModule; + let fileCopyServiceFactory: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + { + provide: SchoolSpecificFileCopyServiceFactory, + useValue: createMock(), + }, + RecursiveCopyVisitor, + ], + }).compile(); + + fileCopyServiceFactory = module.get(SchoolSpecificFileCopyServiceFactory); + + await setupEntities(); + }); + + describe('visitLinkElementAsync', () => { + const setup = (options: { withFileCopy: boolean } = { withFileCopy: false }) => { + const fileCopyServiceMock = createMock(); + fileCopyServiceFactory.build.mockReturnValue(fileCopyServiceMock); + const sourceFileId = 'abe223e22134'; + const imageUrl = `https://abc.de/file/${sourceFileId}`; + + let newFileId: string | undefined; + let copyResultMock: CopyFileDto[] = []; + if (options?.withFileCopy) { + newFileId = 'bbbbbbb123'; + copyResultMock = [ + { + sourceId: sourceFileId, + id: newFileId, + name: 'myfile.jpg', + }, + ]; + } + + fileCopyServiceMock.copyFilesOfParent.mockResolvedValueOnce(copyResultMock); + return { fileCopyServiceMock, imageUrl, newFileId }; + }; + + describe('when copying a LinkElement without preview url', () => { + it('should not call fileCopyService', async () => { + const { fileCopyServiceMock } = setup(); + + const linkElement = linkElementFactory.build(); + const visitor = new RecursiveCopyVisitor(fileCopyServiceMock); + + await visitor.visitLinkElementAsync(linkElement); + + expect(fileCopyServiceMock.copyFilesOfParent).not.toHaveBeenCalled(); + }); + }); + + describe('when copying a LinkElement with preview image', () => { + it('should call fileCopyService', async () => { + const { fileCopyServiceMock, imageUrl } = setup({ withFileCopy: true }); + + const linkElement = linkElementFactory.build({ imageUrl }); + const visitor = new RecursiveCopyVisitor(fileCopyServiceMock); + + await visitor.visitLinkElementAsync(linkElement); + + expect(fileCopyServiceMock.copyFilesOfParent).toHaveBeenCalledWith( + expect.objectContaining({ sourceParentId: linkElement.id }) + ); + }); + + it('should replace fileId in imageUrl', async () => { + const { fileCopyServiceMock, imageUrl, newFileId } = setup({ withFileCopy: true }); + + const linkElement = linkElementFactory.build({ imageUrl }); + const visitor = new RecursiveCopyVisitor(fileCopyServiceMock); + + await visitor.visitLinkElementAsync(linkElement); + const copy = visitor.copyMap.get(linkElement.id) as LinkElement; + + expect(copy.imageUrl).toEqual(expect.stringContaining(newFileId as string)); + }); + }); + + describe('when copying a LinkElement with an unmatched image url', () => { + it('should remove the imageUrl', async () => { + const { fileCopyServiceMock } = setup({ withFileCopy: true }); + + const linkElement = linkElementFactory.build({ imageUrl: `https://abc.de/file/unknown-file-id` }); + const visitor = new RecursiveCopyVisitor(fileCopyServiceMock); + + await visitor.visitLinkElementAsync(linkElement); + const copy = visitor.copyMap.get(linkElement.id) as LinkElement; + + expect(copy.imageUrl).toEqual(''); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts index 137f189319c..03cdfb15b69 100644 --- a/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts +++ b/apps/server/src/modules/board/service/board-do-copy-service/recursive-copy.visitor.ts @@ -1,3 +1,5 @@ +import { FileRecordParentType } from '@infra/rabbitmq'; +import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { AnyBoardDo, BoardCompositeVisitorAsync, @@ -12,8 +14,6 @@ import { SubmissionItem, } from '@shared/domain'; import { LinkElement } from '@shared/domain/domainobject/board/link-element.do'; -import { FileRecordParentType } from '@infra/rabbitmq'; -import { CopyElementType, CopyStatus, CopyStatusEnum } from '@modules/copy-helper'; import { ObjectId } from 'bson'; import { SchoolSpecificFileCopyService } from './school-specific-file-copy.interface'; @@ -133,11 +133,38 @@ export class RecursiveCopyVisitor implements BoardCompositeVisitorAsync { createdAt: new Date(), updatedAt: new Date(), }); - this.resultMap.set(original.id, { + + const result: CopyStatus = { copyEntity: copy, type: CopyElementType.LINK_ELEMENT, status: CopyStatusEnum.SUCCESS, - }); + }; + + if (original.imageUrl) { + const fileCopy = await this.fileCopyService.copyFilesOfParent({ + sourceParentId: original.id, + targetParentId: copy.id, + parentType: FileRecordParentType.BoardNode, + }); + fileCopy.forEach((copyFileDto) => { + if (copyFileDto.id) { + if (copy.imageUrl.includes(copyFileDto.sourceId)) { + copy.imageUrl = copy.imageUrl.replace(copyFileDto.sourceId, copyFileDto.id); + } else { + copy.imageUrl = ''; + } + } + }); + const fileCopyStatus = fileCopy.map((copyFileDto) => { + return { + type: CopyElementType.FILE, + status: copyFileDto.id ? CopyStatusEnum.SUCCESS : CopyStatusEnum.FAIL, + title: copyFileDto.name ?? `(old fileid: ${copyFileDto.sourceId})`, + }; + }); + result.elements = fileCopyStatus; + } + this.resultMap.set(original.id, result); this.copyMap.set(original.id, copy); return Promise.resolve(); diff --git a/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts b/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts index a3d28c5bdec..7be042c0640 100644 --- a/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts +++ b/apps/server/src/modules/files-storage/entity/filerecord.entity.spec.ts @@ -1,8 +1,8 @@ +import { PreviewInputMimeTypes } from '@infra/preview-generator'; import { ObjectId } from '@mikro-orm/mongodb'; import { BadRequestException } from '@nestjs/common'; import { fileRecordFactory, setupEntities } from '@shared/testing'; import { ErrorType } from '../error'; -import { PreviewInputMimeTypes } from '../interface'; import { FileRecord, FileRecordParentType, diff --git a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts index 5f7c01ac2d8..e0957b355bb 100644 --- a/apps/server/src/modules/files-storage/entity/filerecord.entity.ts +++ b/apps/server/src/modules/files-storage/entity/filerecord.entity.ts @@ -1,3 +1,4 @@ +import { PreviewInputMimeTypes } from '@infra/preview-generator'; import { Embeddable, Embedded, Entity, Enum, Index, Property } from '@mikro-orm/core'; import { ObjectId } from '@mikro-orm/mongodb'; import { BadRequestException } from '@nestjs/common'; @@ -5,7 +6,6 @@ import { BaseEntityWithTimestamps, EntityId } from '@shared/domain'; import path from 'path'; import { v4 as uuid } from 'uuid'; import { ErrorType } from '../error'; -import { PreviewInputMimeTypes } from '../interface/preview-input-mime-types.enum'; export enum ScanStatus { PENDING = 'pending', diff --git a/apps/server/src/modules/files-storage/interface/index.ts b/apps/server/src/modules/files-storage/interface/index.ts index 4938ae20233..6ec2a5720f9 100644 --- a/apps/server/src/modules/files-storage/interface/index.ts +++ b/apps/server/src/modules/files-storage/interface/index.ts @@ -1,4 +1,3 @@ export * from './interfaces'; -export * from './preview-input-mime-types.enum'; export * from './preview-output-mime-types.enum'; export * from './preview-width.enum';