diff --git a/src/apps/drive/dependency-injection/virtual-drive/registerFilesServices.ts b/src/apps/drive/dependency-injection/virtual-drive/registerFilesServices.ts index a5bced061d..5bdfea887a 100644 --- a/src/apps/drive/dependency-injection/virtual-drive/registerFilesServices.ts +++ b/src/apps/drive/dependency-injection/virtual-drive/registerFilesServices.ts @@ -68,5 +68,15 @@ export async function registerFilesServices(builder: ContainerBuilder): Promise< builder.registerAndUse(FilesByPartialSearcher); // Event Handlers - builder.registerAndUse(CreateFileOnTemporalFileUploaded).addTag('event-handler'); + builder + .register(CreateFileOnTemporalFileUploaded) + .useFactory((c) => { + return new CreateFileOnTemporalFileUploaded( + c.get(FileCreator), + c.get(FileOverrider), + c.get(Environment), + user.bucket, + ); + }) + .addTag('event-handler'); } diff --git a/src/backend/features/thumbnails/generate-thumbnail.test.ts b/src/backend/features/thumbnails/generate-thumbnail.test.ts new file mode 100644 index 0000000000..c8b3668db1 --- /dev/null +++ b/src/backend/features/thumbnails/generate-thumbnail.test.ts @@ -0,0 +1,54 @@ +import * as nativeImageModule from 'electron'; +import { generateThumbnail } from './generate-thumbnail'; +import { partialSpyOn } from 'tests/vitest/utils.helper'; +import { THUMBNAIL_SIZE } from './thumbnail.constants'; + +describe('generate-thumbnail', () => { + const createFromBufferMock = partialSpyOn(nativeImageModule.nativeImage, 'createFromBuffer'); + + it('returns error when nativeImage cannot decode the buffer', () => { + createFromBufferMock.mockReturnValue({ isEmpty: () => true }); + const { error } = generateThumbnail(Buffer.from('not-an-image')); + expect(error).toBeInstanceOf(Error); + }); + + it('resizes image larger than THUMBNAIL_SIZE', () => { + const imageWidth = 1024; + const imageHeight = 768; + const pngBuffer = Buffer.from('png'); + const resizeMock = vi.fn().mockReturnValue({ toPNG: () => pngBuffer }); + + createFromBufferMock.mockReturnValue({ + isEmpty: () => false, + getSize: () => ({ width: imageWidth, height: imageHeight }), + resize: resizeMock, + }); + + const { data, error } = generateThumbnail(Buffer.from('large-image')); + + expect(error).toBeUndefined(); + const scale = Math.min(THUMBNAIL_SIZE / imageWidth, THUMBNAIL_SIZE / imageHeight, 1); + expect(resizeMock).toBeCalledWith({ + width: Math.round(imageWidth * scale), + height: Math.round(imageHeight * scale), + quality: 'good', + }); + expect(data).toBe(pngBuffer); + }); + + it('does not upscale image smaller than THUMBNAIL_SIZE', () => { + const imageWidth = 100; + const imageHeight = 80; + const pngBuffer = Buffer.from('png'); + const resizeMock = vi.fn().mockReturnValue({ toPNG: () => pngBuffer }); + + createFromBufferMock.mockReturnValue({ + isEmpty: () => false, + getSize: () => ({ width: imageWidth, height: imageHeight }), + resize: resizeMock, + }); + generateThumbnail(Buffer.from('small-image')); + + expect(resizeMock).toBeCalledWith({ width: imageWidth, height: imageHeight, quality: 'good' }); + }); +}); diff --git a/src/backend/features/thumbnails/generate-thumbnail.ts b/src/backend/features/thumbnails/generate-thumbnail.ts new file mode 100644 index 0000000000..56e9fb364c --- /dev/null +++ b/src/backend/features/thumbnails/generate-thumbnail.ts @@ -0,0 +1,20 @@ +import { nativeImage } from 'electron'; +import { Result } from '../../../context/shared/domain/Result'; +import { THUMBNAIL_SIZE } from './thumbnail.constants'; + +export function generateThumbnail(fileBuffer: Buffer): Result { + const image = nativeImage.createFromBuffer(fileBuffer); + + if (image.isEmpty()) { + return { error: new Error('Failed to load image from buffer') }; + } + + const { width, height } = image.getSize(); + const scale = Math.min(THUMBNAIL_SIZE / width, THUMBNAIL_SIZE / height, 1); + const newWidth = Math.round(width * scale); + const newHeight = Math.round(height * scale); + + const resized = image.resize({ width: newWidth, height: newHeight, quality: 'good' }); + + return { data: resized.toPNG() }; +} diff --git a/src/backend/features/thumbnails/thumbnail.constants.ts b/src/backend/features/thumbnails/thumbnail.constants.ts new file mode 100644 index 0000000000..33a0440833 --- /dev/null +++ b/src/backend/features/thumbnails/thumbnail.constants.ts @@ -0,0 +1,2 @@ +export const THUMBNAIL_SIZE = 256; +export const UPLOAD_TIMEOUT_MS = 30_000; diff --git a/src/backend/features/thumbnails/thumbnail.extensions.ts b/src/backend/features/thumbnails/thumbnail.extensions.ts new file mode 100644 index 0000000000..c29ebb38b3 --- /dev/null +++ b/src/backend/features/thumbnails/thumbnail.extensions.ts @@ -0,0 +1,11 @@ +/** + * v2.5.4 + * Alexis Mora + * As per Electron docs, only PNG and JPEG are officially supported + * https://www.electronjs.org/docs/latest/api/native-image#supported-formats + */ +export const THUMBNAIL_SUPPORTED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png']); + +export function canGenerateThumbnail(extension: string): boolean { + return THUMBNAIL_SUPPORTED_EXTENSIONS.has(extension.toLowerCase()); +} diff --git a/src/backend/features/thumbnails/upload-and-create-thumbnail.test.ts b/src/backend/features/thumbnails/upload-and-create-thumbnail.test.ts new file mode 100644 index 0000000000..c655fcdbba --- /dev/null +++ b/src/backend/features/thumbnails/upload-and-create-thumbnail.test.ts @@ -0,0 +1,60 @@ +import * as uploadThumbnailToBucketModule from './upload-thumbnail-to-bucket'; +import * as createThumbnailModule from '../../../infra/drive-server/services/files/services/create-thumbnail'; +import { uploadAndCreateThumbnail } from './upload-and-create-thumbnail'; +import { call, partialSpyOn } from 'tests/vitest/utils.helper'; +import { THUMBNAIL_SIZE } from './thumbnail.constants'; +import { DriveServerError } from '../../../infra/drive-server/drive-server.error'; +import { Environment } from '@internxt/inxt-js'; + +describe('upload-and-create-thumbnail', () => { + const uploadThumbnailToBucketMock = partialSpyOn(uploadThumbnailToBucketModule, 'uploadThumbnailToBucket'); + const createThumbnailMock = partialSpyOn(createThumbnailModule, 'createThumbnail'); + + const environment = {} as Environment; + const bucket = 'test-bucket'; + const fileUuid = 'file-uuid'; + const thumbnailBuffer = Buffer.from('thumbnail-data'); + + it('should return error when upload to bucket fails', async () => { + const uploadError = new Error('Upload failed'); + uploadThumbnailToBucketMock.mockResolvedValue({ error: uploadError }); + + const { error } = await uploadAndCreateThumbnail({ thumbnailBuffer, fileUuid, environment, bucket }); + + expect(error).toBe(uploadError); + expect(createThumbnailMock).not.toHaveBeenCalled(); + }); + + it('should call createThumbnail with correct params after successful bucket upload', async () => { + const contentsId = 'contents-id-123'; + const thumbnailDto = { id: 1, type: 'png' }; + + uploadThumbnailToBucketMock.mockResolvedValue({ data: contentsId }); + createThumbnailMock.mockResolvedValue({ data: thumbnailDto }); + + const { data, error } = await uploadAndCreateThumbnail({ thumbnailBuffer, fileUuid, environment, bucket }); + + expect(error).toBeUndefined(); + expect(data).toStrictEqual(thumbnailDto); + call(createThumbnailMock).toStrictEqual({ + fileUuid, + type: 'png', + size: thumbnailBuffer.length, + maxWidth: THUMBNAIL_SIZE, + maxHeight: THUMBNAIL_SIZE, + bucketId: bucket, + bucketFile: contentsId, + encryptVersion: '03-aes', + }); + }); + + it('should return error when createThumbnail fails', async () => { + const createError = new DriveServerError('SERVER_ERROR'); + uploadThumbnailToBucketMock.mockResolvedValue({ data: 'contents-id' }); + createThumbnailMock.mockResolvedValue({ error: createError }); + + const { error } = await uploadAndCreateThumbnail({ thumbnailBuffer, fileUuid, environment, bucket }); + + expect(error).toBe(createError); + }); +}); diff --git a/src/backend/features/thumbnails/upload-and-create-thumbnail.ts b/src/backend/features/thumbnails/upload-and-create-thumbnail.ts new file mode 100644 index 0000000000..9b616ca35c --- /dev/null +++ b/src/backend/features/thumbnails/upload-and-create-thumbnail.ts @@ -0,0 +1,37 @@ +import { Environment } from '@internxt/inxt-js'; +import { Result } from '../../../context/shared/domain/Result'; +import { ThumbnailDto } from '../../../infra/drive-server/out/dto'; +import { createThumbnail } from '../../../infra/drive-server/services/files/services/create-thumbnail'; +import { THUMBNAIL_SIZE } from './thumbnail.constants'; +import { uploadThumbnailToBucket } from './upload-thumbnail-to-bucket'; + +type Props = { + thumbnailBuffer: Buffer; + fileUuid: string; + environment: Environment; + bucket: string; +}; + +export async function uploadAndCreateThumbnail({ + thumbnailBuffer, + fileUuid, + environment, + bucket, +}: Props): Promise> { + const uploaded = await uploadThumbnailToBucket(environment, bucket, thumbnailBuffer); + + if (uploaded.error) { + return { error: uploaded.error }; + } + + return createThumbnail({ + fileUuid, + type: 'png', + size: thumbnailBuffer.length, + maxWidth: THUMBNAIL_SIZE, + maxHeight: THUMBNAIL_SIZE, + bucketId: bucket, + bucketFile: uploaded.data, + encryptVersion: '03-aes', + }); +} diff --git a/src/backend/features/thumbnails/upload-thumbnail-to-bucket.test.ts b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.test.ts new file mode 100644 index 0000000000..c7b70cd97c --- /dev/null +++ b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.test.ts @@ -0,0 +1,67 @@ +import { Environment } from '@internxt/inxt-js'; +import { uploadThumbnailToBucket } from './upload-thumbnail-to-bucket'; +import { UPLOAD_TIMEOUT_MS } from './thumbnail.constants'; +import { UploadOptions } from '@internxt/inxt-js/build/lib/core'; + +function environmentMock(upload: (bucket: string, options: UploadOptions) => void): Environment { + return { upload } as unknown as Environment; +} + +describe('upload-thumbnail-to-bucket', () => { + const bucket = 'test-bucket'; + const buffer = Buffer.from('image-data'); + const clearTimeoutMock = vi.spyOn(global, 'clearTimeout'); + + it('should return data with contentsId on successful upload', async () => { + const contentsId = 'contents-id-123'; + const environment = environmentMock((_bucket, { finishedCallback }) => { + finishedCallback(null, contentsId); + }); + + const { data, error } = await uploadThumbnailToBucket(environment, bucket, buffer); + + expect(error).toBeUndefined(); + expect(data).toBe(contentsId); + expect(clearTimeoutMock).toHaveBeenCalled(); + }); + + it('should return error when finishedCallback receives an error', async () => { + const uploadError = new Error('bucket error'); + const environment = environmentMock((_bucket, { finishedCallback }) => { + finishedCallback(uploadError, null); + }); + + const { error } = await uploadThumbnailToBucket(environment, bucket, buffer); + + expect(error).toBe(uploadError); + expect(clearTimeoutMock).toHaveBeenCalled(); + }); + + it('should return error when finishedCallback has no contentsId', async () => { + const environment = environmentMock((_bucket, { finishedCallback }) => { + finishedCallback(null, null); + }); + + const { error } = await uploadThumbnailToBucket(environment, bucket, buffer); + + expect(error).toBeInstanceOf(Error); + expect(clearTimeoutMock).toHaveBeenCalled(); + }); + + it('should return error when upload times out', async () => { + vi.useFakeTimers(); + + const environment = environmentMock(() => { + // never calls finishedCallback + }); + + const resultPromise = uploadThumbnailToBucket(environment, bucket, buffer); + vi.advanceTimersByTime(UPLOAD_TIMEOUT_MS); + + const { error } = await resultPromise; + + expect(error).toBeInstanceOf(Error); + + vi.useRealTimers(); + }); +}); diff --git a/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts new file mode 100644 index 0000000000..6a952e5eba --- /dev/null +++ b/src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts @@ -0,0 +1,36 @@ +import { Environment } from '@internxt/inxt-js'; +import { Readable } from 'node:stream'; +import { Result } from '../../../context/shared/domain/Result'; +import { UPLOAD_TIMEOUT_MS } from './thumbnail.constants'; + +export function uploadThumbnailToBucket( + environment: Environment, + bucket: string, + buffer: Buffer, +): Promise> { + let timeoutId: ReturnType; + + const upload = new Promise>((resolve) => { + environment.upload(bucket, { + source: Readable.from(buffer), + fileSize: buffer.length, + finishedCallback: (err: Error | null, contentsId: string | null) => { + clearTimeout(timeoutId); + if (err) { + return resolve({ error: err }); + } + if (!contentsId) { + return resolve({ error: new Error('Upload finished but no contentsId returned') }); + } + resolve({ data: contentsId }); + }, + progressCallback: () => {}, + }); + }); + + const timeout = new Promise>((resolve) => { + timeoutId = setTimeout(() => resolve({ error: new Error('Thumbnail bucket upload timed out') }), UPLOAD_TIMEOUT_MS); + }); + + return Promise.race([upload, timeout]); +} diff --git a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts index b6b5d37cd7..85838fd50c 100644 --- a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts +++ b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts @@ -1,5 +1,7 @@ import { Service } from 'diod'; +import { extname } from 'node:path'; import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { canGenerateThumbnail } from '../../../../../backend/features/thumbnails/thumbnail.extensions'; import { TemporalFileRepository } from '../../domain/TemporalFileRepository'; import { TemporalFilePath } from '../../domain/TemporalFilePath'; import { TemporalFileUploaderFactory } from '../../domain/upload/TemporalFileUploaderFactory'; @@ -40,11 +42,15 @@ export class TemporalFileUploader { logger.debug({ msg: `${documentPath.value} uploaded with id ${contentsId}` }); + const ext = extname(document.path.value).replace('.', '').toLowerCase(); + const fileBuffer = canGenerateThumbnail(ext) ? await this.repository.read(documentPath) : undefined; + const contentsUploadedEvent = new TemporalFileUploadedDomainEvent({ aggregateId: contentsId, size: document.size.value, path: document.path.value, replaces: replaces?.contentsId, + fileBuffer, }); await this.eventBus.publish([contentsUploadedEvent]); diff --git a/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts b/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts index 1732f29d71..9264245779 100644 --- a/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts +++ b/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts @@ -6,17 +6,20 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { readonly size: number; readonly path: string; readonly replaces: string | undefined; + readonly fileBuffer: Buffer | undefined; constructor({ aggregateId, size, path, replaces, + fileBuffer, }: { aggregateId: string; size: number; path: string; replaces?: string; + fileBuffer?: Buffer; }) { super({ aggregateId, @@ -26,6 +29,7 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { this.size = size; this.path = path; this.replaces = replaces; + this.fileBuffer = fileBuffer; } toPrimitives() { diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts b/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts index 47055e22ec..d4349f546c 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts @@ -1,45 +1,57 @@ +import { Environment } from '@internxt/inxt-js'; import { CreateFileOnTemporalFileUploaded } from './CreateFileOnTemporalFileUploaded'; import { FileCreatorTestClass } from '../../__test-helpers__/FileCreatorTestClass'; import { FileOverriderTestClass } from '../../__test-helpers__/FileOverriderTestClass'; +import { FileMother } from '../../domain/__test-helpers__/FileMother'; import { OfflineContentsUploadedDomainEventMother } from '../../domain/events/__test-helpers__/OfflineContentsUploadedDomainEventMother'; +import { call } from 'tests/vitest/utils.helper'; describe('Create File On Offline File Uploaded', () => { + const environment = {} as Environment; + const bucket = 'test-bucket'; + it('creates a new file when event replaces field is undefined', async () => { const creator = new FileCreatorTestClass(); const overrider = new FileOverriderTestClass(); + const file = FileMother.noThumbnable(); + creator.mock.mockResolvedValue(file); const uploadedEvent = OfflineContentsUploadedDomainEventMother.doesNotReplace(); - const sut = new CreateFileOnTemporalFileUploaded(creator, overrider); + const sut = new CreateFileOnTemporalFileUploaded(creator, overrider, environment, bucket); await sut.on(uploadedEvent); - expect(creator.mock).toBeCalledWith(uploadedEvent.path, uploadedEvent.aggregateId, uploadedEvent.size); + call(creator.mock).toMatchObject([uploadedEvent.path, uploadedEvent.aggregateId, uploadedEvent.size]); }); - it('does not create a new file when the replaces files is defined', async () => { + it('does not create a new file when the replaces field is defined', async () => { const creator = new FileCreatorTestClass(); const overrider = new FileOverriderTestClass(); + const file = FileMother.noThumbnable(); + overrider.mock.mockResolvedValue(file); const uploadedEvent = OfflineContentsUploadedDomainEventMother.replacesContents(); - const sut = new CreateFileOnTemporalFileUploaded(creator, overrider); + const sut = new CreateFileOnTemporalFileUploaded(creator, overrider, environment, bucket); await sut.on(uploadedEvent); - expect(creator.mock).not.toBeCalled(); + expect(creator.mock).not.toHaveBeenCalled(); }); it('overrides file with contents specified on the event', async () => { const creator = new FileCreatorTestClass(); const overrider = new FileOverriderTestClass(); + const file = FileMother.noThumbnable(); + overrider.mock.mockResolvedValue(file); const uploadedEvent = OfflineContentsUploadedDomainEventMother.replacesContents(); - const sut = new CreateFileOnTemporalFileUploaded(creator, overrider); + const sut = new CreateFileOnTemporalFileUploaded(creator, overrider, environment, bucket); await sut.on(uploadedEvent); - expect(overrider.mock).toBeCalledWith(uploadedEvent.replaces, uploadedEvent.aggregateId, uploadedEvent.size); + call(overrider.mock).toMatchObject([uploadedEvent.replaces, uploadedEvent.aggregateId, uploadedEvent.size]); }); }); diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts index bb0f7b84b0..f409c270c7 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts @@ -1,5 +1,8 @@ +import { Environment } from '@internxt/inxt-js'; import { Service } from 'diod'; import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { generateThumbnail } from '../../../../../backend/features/thumbnails/generate-thumbnail'; +import { uploadAndCreateThumbnail } from '../../../../../backend/features/thumbnails/upload-and-create-thumbnail'; import { TemporalFileUploadedDomainEvent } from '../../../../storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent'; import { DomainEventClass } from '../../../../shared/domain/DomainEvent'; import { DomainEventSubscriber } from '../../../../shared/domain/DomainEventSubscriber'; @@ -11,6 +14,8 @@ export class CreateFileOnTemporalFileUploaded implements DomainEventSubscriber { - if (event.replaces) { - await this.fileOverrider.run(event.replaces, event.aggregateId, event.size); - return; - } + const file = event.replaces + ? await this.fileOverrider.run(event.replaces, event.aggregateId, event.size) + : await this.creator.run(event.path, event.aggregateId, event.size); + + if (event.fileBuffer) { + const generated = generateThumbnail(event.fileBuffer); - await this.creator.run(event.path, event.aggregateId, event.size); + if (generated.error) { + logger.warn({ msg: `Failed to generate thumbnail for ${event.path}`, error: generated.error }); + return; + } + + void uploadAndCreateThumbnail({ + thumbnailBuffer: generated.data, + fileUuid: file.uuid, + environment: this.environment, + bucket: this.bucket, + }).then(({ error }) => { + if (error) { + logger.warn({ msg: `Failed to upload thumbnail for ${event.path}`, error }); + } + }); + } } async on(event: TemporalFileUploadedDomainEvent): Promise { try { this.create(event); } catch (err) { - logger.error({ msg: '[CreateFileOnOfflineFileUploaded] Error creating file:', error: err }); + logger.error({ + msg: '[CreateFileOnOfflineFileUploaded] Error creating file:', + error: err, + }); } } } diff --git a/src/context/virtual-drive/files/application/override/FileOverrider.ts b/src/context/virtual-drive/files/application/override/FileOverrider.ts index 560843fb03..9e3165286d 100644 --- a/src/context/virtual-drive/files/application/override/FileOverrider.ts +++ b/src/context/virtual-drive/files/application/override/FileOverrider.ts @@ -18,7 +18,7 @@ export class FileOverrider { oldContentsId: File['contentsId'], newContentsId: File['contentsId'], newSize: File['size'], - ): Promise { + ): Promise { const file = await this.repository.searchByContentsId(oldContentsId); if (!file) { @@ -36,5 +36,7 @@ export class FileOverrider { await this.repository.update(file); this.eventBus.publish(file.pullDomainEvents()); + + return file; } } diff --git a/vitest.setup.main.ts b/vitest.setup.main.ts index 0fc9963669..50f0c1646b 100644 --- a/vitest.setup.main.ts +++ b/vitest.setup.main.ts @@ -25,6 +25,13 @@ vi.mock('electron', () => ({ decryptString: vi.fn(), encryptString: vi.fn(), }, + nativeImage: { + createFromBuffer: vi.fn().mockReturnValue({ + isEmpty: () => false, + getSize: () => ({ width: 100, height: 100 }), + resize: vi.fn().mockReturnValue({ toPNG: () => Buffer.from('png') }), + }), + } })); // Mock electron-log (depends on electron)