Skip to content

Commit 3ef60d6

Browse files
committed
feat(upload): add thumbnail generation for uploaded images
1 parent d4a0124 commit 3ef60d6

6 files changed

Lines changed: 198 additions & 6 deletions

File tree

src/commands/upload-file.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import { ErrorUtils } from '../utils/errors.utils';
1515
import { MissingCredentialsError, NotValidDirectoryError, NotValidFolderUuidError } from '../types/command.types';
1616
import { ValidationService } from '../services/validation.service';
1717
import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types';
18+
import { ThumbnailService } from '../services/thumbnail.service';
19+
import { BufferStream } from '../utils/stream.utils';
20+
import { isFileThumbnailable } from '../utils/thumbnail.utils';
21+
import { Readable } from 'node:stream';
1822

1923
export default class UploadFile extends Command {
2024
static readonly args = {};
@@ -52,6 +56,9 @@ export default class UploadFile extends Command {
5256
throw new Error('The file is empty. Uploading empty files is not allowed.');
5357
}
5458

59+
const fileInfo = path.parse(filePath);
60+
const fileType = fileInfo.ext.replaceAll('.', '');
61+
5562
let destinationFolderUuid = await this.getDestinationFolderUuid(flags['destination'], nonInteractive);
5663
if (destinationFolderUuid.trim().length === 0) {
5764
// destinationFolderUuid is empty from flags&prompt, which means we should use RootFolderUuid
@@ -75,14 +82,22 @@ export default class UploadFile extends Command {
7582
CLIUtils.done();
7683

7784
// 2. Upload file to the Network
78-
const fileStream = createReadStream(filePath);
85+
const readStream = createReadStream(filePath);
7986
const timer = CLIUtils.timer();
8087
const progressBar = CLIUtils.progress({
8188
format: 'Uploading file [{bar}] {percentage}%',
8289
linewrap: true,
8390
});
8491
progressBar.start(100, 0);
8592

93+
let bufferStream: BufferStream | undefined;
94+
let fileStream: Readable = readStream;
95+
const isThumbnailable = isFileThumbnailable(fileType);
96+
if (isThumbnailable) {
97+
bufferStream = new BufferStream();
98+
fileStream = readStream.pipe(bufferStream);
99+
}
100+
86101
const progressCallback = (progress: number) => {
87102
progressBar.update(progress * 0.99);
88103
};
@@ -104,10 +119,9 @@ export default class UploadFile extends Command {
104119
const uploadResult = await uploadPromise;
105120

106121
// 3. Create the file in Drive
107-
const fileInfo = path.parse(filePath);
108122
const createdDriveFile = await DriveFileService.instance.createFile({
109123
plain_name: fileInfo.name,
110-
type: fileInfo.ext.replaceAll('.', ''),
124+
type: fileType,
111125
size: stats.size,
112126
folder_id: destinationFolderUuid,
113127
id: uploadResult.fileId,
@@ -116,6 +130,24 @@ export default class UploadFile extends Command {
116130
name: '',
117131
});
118132

133+
try {
134+
if (isThumbnailable && bufferStream) {
135+
const thumbnailBuffer = bufferStream.getBuffer();
136+
137+
if (thumbnailBuffer) {
138+
await ThumbnailService.instance.uploadThumbnail(
139+
thumbnailBuffer,
140+
user.bucket,
141+
user.mnemonic,
142+
createdDriveFile.id,
143+
networkFacade,
144+
);
145+
}
146+
}
147+
} catch (error) {
148+
ErrorUtils.report(this.error.bind(this), error, { command: this.id });
149+
}
150+
119151
progressBar.update(100);
120152
progressBar.stop();
121153

src/services/drive/drive-file.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,9 @@ export class DriveFileService {
5151
const fileMetadata = await storageClient.getFileByPath(encodeURIComponent(path));
5252
return DriveUtils.driveFileMetaToItem(fileMetadata);
5353
};
54+
55+
public createThumbnail = (payload: StorageTypes.ThumbnailEntry): Promise<StorageTypes.Thumbnail> => {
56+
const storageClient = SdkManager.instance.getStorage(false);
57+
return storageClient.createThumbnailEntry(payload);
58+
};
5459
}

src/services/thumbnail.service.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Readable } from 'node:stream';
2+
import { DriveFileService } from './drive/drive-file.service';
3+
import { StorageTypes } from '@internxt/sdk/dist/drive';
4+
import { NetworkFacade } from './network/network-facade.service';
5+
import { UploadService } from './network/upload.service';
6+
7+
export class ThumbnailService {
8+
public static readonly instance: ThumbnailService = new ThumbnailService();
9+
10+
public static readonly MaxWidth = 300;
11+
public static readonly MaxHeight = 300;
12+
public static readonly Quality = 80;
13+
public static readonly Type = 'png';
14+
15+
public uploadThumbnail = async (
16+
fileContent: Buffer,
17+
userBucket: string,
18+
userMnemonic: string,
19+
file_id: number,
20+
networkFacade: NetworkFacade,
21+
): Promise<StorageTypes.Thumbnail> => {
22+
const size = fileContent.length;
23+
const [thumbnailPromise] = await UploadService.instance.uploadFileStream(
24+
Readable.from(fileContent),
25+
userBucket,
26+
userMnemonic,
27+
size,
28+
networkFacade,
29+
() => {},
30+
);
31+
32+
const thumbnailUploadResult = await thumbnailPromise;
33+
34+
const createdThumbnailFile = await DriveFileService.instance.createThumbnail({
35+
file_id: file_id,
36+
max_width: ThumbnailService.MaxWidth,
37+
max_height: ThumbnailService.MaxHeight,
38+
type: ThumbnailService.Type,
39+
size: size,
40+
bucket_id: userBucket,
41+
bucket_file: thumbnailUploadResult.fileId,
42+
encrypt_version: StorageTypes.EncryptionVersion.Aes03,
43+
});
44+
return createdThumbnailFile;
45+
};
46+
}

src/utils/stream.utils.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ReadStream, WriteStream } from 'node:fs';
2-
import { Readable, Transform, TransformCallback } from 'node:stream';
2+
import { Readable, Transform, TransformCallback, TransformOptions } from 'node:stream';
33

44
export class StreamUtils {
55
static readStreamToReadableStream(readStream: ReadStream): ReadableStream<Uint8Array> {
@@ -130,3 +130,30 @@ export class ProgressTransform extends Transform {
130130
callback(null);
131131
}
132132
}
133+
134+
export class BufferStream extends Transform {
135+
public buffer: Buffer | null;
136+
137+
constructor(opts?: TransformOptions) {
138+
super(opts);
139+
this.buffer = null;
140+
}
141+
142+
_transform(chunk: Buffer, _: BufferEncoding, callback: TransformCallback) {
143+
const currentBuffer = this.buffer ?? Buffer.alloc(0);
144+
this.buffer = Buffer.concat([currentBuffer, chunk]);
145+
callback(null, chunk);
146+
}
147+
148+
_flush(callback: TransformCallback) {
149+
callback();
150+
}
151+
152+
reset() {
153+
this.buffer = null;
154+
}
155+
156+
getBuffer(): Buffer | null {
157+
return this.buffer;
158+
}
159+
}

src/utils/thumbnail.utils.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import sharp from 'sharp';
2+
3+
export const ThumbnailConfig = {
4+
MaxWidth: 300,
5+
MaxHeight: 300,
6+
Quality: 100,
7+
Type: 'png',
8+
} as const;
9+
10+
export const getThumbnailFromImageBuffer = (buffer: Buffer): Promise<Buffer> => {
11+
return sharp(buffer)
12+
.resize({
13+
height: ThumbnailConfig.MaxHeight,
14+
width: ThumbnailConfig.MaxWidth,
15+
fit: 'inside',
16+
})
17+
.png({
18+
quality: ThumbnailConfig.Quality,
19+
})
20+
.toBuffer();
21+
};
22+
23+
type FileExtensionMap = Record<string, string[]>;
24+
const imageExtensions: FileExtensionMap = {
25+
tiff: ['tif', 'tiff'],
26+
bmp: ['bmp'],
27+
heic: ['heic'],
28+
jpg: ['jpg', 'jpeg'],
29+
gif: ['gif'],
30+
png: ['png'],
31+
eps: ['eps'],
32+
raw: ['raw', 'cr2', 'nef', 'orf', 'sr2'],
33+
webp: ['webp'],
34+
};
35+
const thumbnailableImageExtension: string[] = [
36+
...imageExtensions['jpg'],
37+
...imageExtensions['png'],
38+
...imageExtensions['webp'],
39+
...imageExtensions['gif'],
40+
...imageExtensions['tiff'],
41+
];
42+
const pdfExtensions: FileExtensionMap = {
43+
pdf: ['pdf'],
44+
};
45+
const thumbnailablePdfExtension: string[] = pdfExtensions['pdf'];
46+
const thumbnailableExtension: string[] = [...thumbnailableImageExtension];
47+
48+
export const isFileThumbnailable = (fileType: string) => {
49+
return fileType.trim().length > 0 && thumbnailableExtension.includes(fileType);
50+
};

src/webdav/handlers/PUT.handler.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import { TrashService } from '../../services/drive/trash.service';
1313
import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types';
1414
import { CLIUtils } from '../../utils/cli.utils';
1515
import { UploadService } from '../../services/network/upload.service';
16+
import { BufferStream } from '../../utils/stream.utils';
17+
import { Readable } from 'node:stream';
18+
import { isFileThumbnailable } from '../../utils/thumbnail.utils';
19+
import { ThumbnailService } from '../../services/thumbnail.service';
1620

1721
export class PUTRequestHandler implements WebDavMethodHandler {
1822
constructor(
@@ -70,14 +74,24 @@ export class PUTRequestHandler implements WebDavMethodHandler {
7074

7175
const { user } = await authService.getAuthDetails();
7276

77+
const fileType = resource.path.ext.replace('.', '');
78+
7379
const timer = CLIUtils.timer();
7480

81+
let bufferStream: BufferStream | undefined;
82+
let fileStream: Readable = req;
83+
const isThumbnailable = isFileThumbnailable(fileType);
84+
if (isThumbnailable) {
85+
bufferStream = new BufferStream();
86+
fileStream = req.pipe(bufferStream);
87+
}
88+
7589
const progressCallback = (progress: number) => {
7690
webdavLogger.info(`[PUT] Upload progress for file ${resource.name}: ${progress}%`);
7791
};
7892

7993
const [uploadPromise, abortable] = await UploadService.instance.uploadFileStream(
80-
req,
94+
fileStream,
8195
user.bucket,
8296
user.mnemonic,
8397
contentLength,
@@ -100,7 +114,7 @@ export class PUTRequestHandler implements WebDavMethodHandler {
100114

101115
const file = await DriveFileService.instance.createFile({
102116
plain_name: resource.path.name,
103-
type: resource.path.ext.replace('.', ''),
117+
type: fileType,
104118
size: contentLength,
105119
folder_id: parentFolderItem.uuid,
106120
id: uploadResult.fileId,
@@ -109,6 +123,24 @@ export class PUTRequestHandler implements WebDavMethodHandler {
109123
name: '',
110124
});
111125

126+
try {
127+
if (isThumbnailable && bufferStream) {
128+
const thumbnailBuffer = bufferStream.getBuffer();
129+
130+
if (thumbnailBuffer) {
131+
await ThumbnailService.instance.uploadThumbnail(
132+
thumbnailBuffer,
133+
user.bucket,
134+
user.mnemonic,
135+
file.id,
136+
networkFacade,
137+
);
138+
}
139+
}
140+
} catch (error) {
141+
webdavLogger.info(`[PUT] ❌ File thumbnail upload failed ${(error as Error).message}`);
142+
}
143+
112144
const uploadTime = timer.stop();
113145
webdavLogger.info(`[PUT] ✅ File uploaded in ${uploadTime}ms to Internxt Drive`);
114146

0 commit comments

Comments
 (0)