Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
54 changes: 54 additions & 0 deletions src/backend/features/thumbnails/generate-thumbnail.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
20 changes: 20 additions & 0 deletions src/backend/features/thumbnails/generate-thumbnail.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer, Error> {
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() };
}
2 changes: 2 additions & 0 deletions src/backend/features/thumbnails/thumbnail.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const THUMBNAIL_SIZE = 256;
export const UPLOAD_TIMEOUT_MS = 30_000;
11 changes: 11 additions & 0 deletions src/backend/features/thumbnails/thumbnail.extensions.ts
Original file line number Diff line number Diff line change
@@ -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());
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
37 changes: 37 additions & 0 deletions src/backend/features/thumbnails/upload-and-create-thumbnail.ts
Original file line number Diff line number Diff line change
@@ -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<Result<ThumbnailDto, Error>> {
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',
});
}
67 changes: 67 additions & 0 deletions src/backend/features/thumbnails/upload-thumbnail-to-bucket.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
36 changes: 36 additions & 0 deletions src/backend/features/thumbnails/upload-thumbnail-to-bucket.ts
Original file line number Diff line number Diff line change
@@ -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<Result<string, Error>> {
let timeoutId: ReturnType<typeof setTimeout>;

const upload = new Promise<Result<string, Error>>((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<Result<string, Error>>((resolve) => {
timeoutId = setTimeout(() => resolve({ error: new Error('Thumbnail bucket upload timed out') }), UPLOAD_TIMEOUT_MS);
});

return Promise.race([upload, timeout]);
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +29,7 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent {
this.size = size;
this.path = path;
this.replaces = replaces;
this.fileBuffer = fileBuffer;
}

toPrimitives() {
Expand Down
Loading
Loading