From f48b9a01e2ab110bbec0169e7ca8a27a8ffd2224 Mon Sep 17 00:00:00 2001 From: Max Bischof <106820326+bischofmax@users.noreply.github.com> Date: Wed, 22 Nov 2023 13:52:21 +0100 Subject: [PATCH] BC-5583 - Display pdf preview (#4516) --- .../preview-generator/interface/index.ts | 1 + .../preview-input-mime-types.enum.ts | 1 + .../preview-generator.service.spec.ts | 202 +++++++++++++----- .../preview-generator.service.ts | 21 +- .../entity/filerecord.entity.spec.ts | 2 +- .../files-storage/entity/filerecord.entity.ts | 2 +- .../modules/files-storage/interface/index.ts | 1 - 7 files changed, 173 insertions(+), 57 deletions(-) rename apps/server/src/{modules/files-storage => infra/preview-generator}/interface/preview-input-mime-types.enum.ts (88%) 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..5b3a397fa1a 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,16 @@ 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 selectFrameMock = jest.fn(); const imageMagickMock = () => { - return { stream: streamMock, resize: resizeMock, data: Readable.from('text') }; + return { stream: streamMock, resize: resizeMock, selectFrame: selectFrameMock, data: Readable.from('text') }; }; jest.mock('gm', () => { return { @@ -16,13 +18,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 +70,172 @@ 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); + describe('WHEN download of original and preview file is successful', () => { + 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 data = Readable.from('text'); + streamMock.mockReturnValueOnce(data); - const expectedFileData = { - data, - mimeType: params.previewOptions.format, - }; + const expectedFileData = { + data, + mimeType: params.previewOptions.format, + }; - return { params, originFile, expectedFileData }; - }; + 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(); + it('should call storageClient get method with originFilePath', async () => { + const { params } = setup(); - await service.generatePreview(params); + await service.generatePreview(params); - expect(s3ClientAdapter.get).toBeCalledWith(params.originFilePath); - }); + expect(s3ClientAdapter.get).toBeCalledWith(params.originFilePath); + }); - it('should call imagemagicks resize method', async () => { - const { params } = setup(); + it('should call imagemagicks resize method', async () => { + const { params } = setup(); - await service.generatePreview(params); + await service.generatePreview(params); - expect(resizeMock).toHaveBeenCalledWith(params.previewOptions.width, undefined, '>'); - expect(resizeMock).toHaveBeenCalledTimes(1); - }); + expect(resizeMock).toHaveBeenCalledWith(params.previewOptions.width, undefined, '>'); + expect(resizeMock).toHaveBeenCalledTimes(1); + }); - it('should call imagemagicks stream method', async () => { - const { params } = setup(); + 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(streamMock).toHaveBeenCalledWith(params.previewOptions.format); + expect(streamMock).toHaveBeenCalledTimes(1); + }); - it('should call S3ClientAdapters create method', async () => { - const { params, expectedFileData } = setup(); + it('should call S3ClientAdapters create method', async () => { + const { params, expectedFileData } = setup(); - await service.generatePreview(params); + await service.generatePreview(params); - expect(s3ClientAdapter.create).toHaveBeenCalledWith(params.previewFilePath, expectedFileData); - expect(s3ClientAdapter.create).toHaveBeenCalledTimes(1); - }); + 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); - it('should should return values', async () => { - const { params } = setup(); - const expectedValue = { previewFilePath: params.previewFilePath, status: true }; + const expectedFileData = { + data, + mimeType: params.previewOptions.format, + }; - const result = await service.generatePreview(params); + return { params, originFile, expectedFileData }; + }; - expect(result).toEqual(expectedValue); + it('should call imagemagicks resize method', async () => { + const { params } = setup(); + + await service.generatePreview(params); + + expect(selectFrameMock).toHaveBeenCalledWith(0); + expect(resizeMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + 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..e3e75d8eef0 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,10 @@ export class PreviewGeneratorService { const preview = this.imageMagick(original.data); + if (original.contentType === PreviewInputMimeTypes.APPLICATION_PDF) { + preview.selectFrame(0); + } + if (width) { preview.resize(width, undefined, '>'); } 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';