diff --git a/README.md b/README.md index 8188cf01..fa22088a 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ $ npm install -g @internxt/cli $ internxt COMMAND running command... $ internxt (--version) -@internxt/cli/1.6.4 win32-x64 node-v24.3.0 +@internxt/cli/1.6.5 win32-x64 node-v24.15.0 $ internxt --help [COMMAND] USAGE $ internxt COMMAND @@ -139,7 +139,7 @@ EXAMPLES $ internxt add-cert ``` -_See code: [src/commands/add-cert.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/add-cert.ts)_ +_See code: [src/commands/add-cert.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/add-cert.ts)_ ## `internxt add cert` @@ -191,7 +191,7 @@ EXAMPLES $ internxt autocomplete --refresh-cache ``` -_See code: [@oclif/plugin-autocomplete](https://github.com/oclif/plugin-autocomplete/blob/v3.2.41/src/commands/autocomplete/index.ts)_ +_See code: [@oclif/plugin-autocomplete](https://github.com/oclif/plugin-autocomplete/blob/v3.2.50/src/commands/autocomplete/index.ts)_ ## `internxt config` @@ -220,7 +220,7 @@ EXAMPLES $ internxt config ``` -_See code: [src/commands/config.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/config.ts)_ +_See code: [src/commands/config.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/config.ts)_ ## `internxt create-folder` @@ -254,7 +254,7 @@ EXAMPLES $ internxt create-folder ``` -_See code: [src/commands/create-folder.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/create-folder.ts)_ +_See code: [src/commands/create-folder.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/create-folder.ts)_ ## `internxt create folder` @@ -318,7 +318,7 @@ EXAMPLES $ internxt delete-permanently-file ``` -_See code: [src/commands/delete-permanently-file.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/delete-permanently-file.ts)_ +_See code: [src/commands/delete-permanently-file.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/delete-permanently-file.ts)_ ## `internxt delete-permanently-folder` @@ -350,7 +350,7 @@ EXAMPLES $ internxt delete-permanently-folder ``` -_See code: [src/commands/delete-permanently-folder.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/delete-permanently-folder.ts)_ +_See code: [src/commands/delete-permanently-folder.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/delete-permanently-folder.ts)_ ## `internxt delete permanently file` @@ -445,7 +445,7 @@ EXAMPLES $ internxt download-file ``` -_See code: [src/commands/download-file.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/download-file.ts)_ +_See code: [src/commands/download-file.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/download-file.ts)_ ## `internxt download file` @@ -508,7 +508,7 @@ EXAMPLES $ internxt list ``` -_See code: [src/commands/list.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/list.ts)_ +_See code: [src/commands/list.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/list.ts)_ ## `internxt login` @@ -536,7 +536,7 @@ EXAMPLES $ internxt login ``` -_See code: [src/commands/login.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/login.ts)_ +_See code: [src/commands/login.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/login.ts)_ ## `internxt login-legacy` @@ -570,7 +570,7 @@ EXAMPLES $ internxt login-legacy ``` -_See code: [src/commands/login-legacy.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/login-legacy.ts)_ +_See code: [src/commands/login-legacy.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/login-legacy.ts)_ ## `internxt logout` @@ -590,7 +590,7 @@ EXAMPLES $ internxt logout ``` -_See code: [src/commands/logout.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/logout.ts)_ +_See code: [src/commands/logout.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/logout.ts)_ ## `internxt logs` @@ -610,7 +610,7 @@ EXAMPLES $ internxt logs ``` -_See code: [src/commands/logs.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/logs.ts)_ +_See code: [src/commands/logs.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/logs.ts)_ ## `internxt move-file` @@ -644,7 +644,7 @@ EXAMPLES $ internxt move-file ``` -_See code: [src/commands/move-file.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/move-file.ts)_ +_See code: [src/commands/move-file.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/move-file.ts)_ ## `internxt move-folder` @@ -678,7 +678,7 @@ EXAMPLES $ internxt move-folder ``` -_See code: [src/commands/move-folder.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/move-folder.ts)_ +_See code: [src/commands/move-folder.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/move-folder.ts)_ ## `internxt move file` @@ -775,7 +775,7 @@ EXAMPLES $ internxt rename-file ``` -_See code: [src/commands/rename-file.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/rename-file.ts)_ +_See code: [src/commands/rename-file.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/rename-file.ts)_ ## `internxt rename-folder` @@ -808,7 +808,7 @@ EXAMPLES $ internxt rename-folder ``` -_See code: [src/commands/rename-folder.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/rename-folder.ts)_ +_See code: [src/commands/rename-folder.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/rename-folder.ts)_ ## `internxt rename file` @@ -902,7 +902,7 @@ EXAMPLES $ internxt trash-clear ``` -_See code: [src/commands/trash-clear.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/trash-clear.ts)_ +_See code: [src/commands/trash-clear.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/trash-clear.ts)_ ## `internxt trash-file` @@ -934,7 +934,7 @@ EXAMPLES $ internxt trash-file ``` -_See code: [src/commands/trash-file.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/trash-file.ts)_ +_See code: [src/commands/trash-file.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/trash-file.ts)_ ## `internxt trash-folder` @@ -966,7 +966,7 @@ EXAMPLES $ internxt trash-folder ``` -_See code: [src/commands/trash-folder.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/trash-folder.ts)_ +_See code: [src/commands/trash-folder.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/trash-folder.ts)_ ## `internxt trash-list` @@ -992,7 +992,7 @@ EXAMPLES $ internxt trash-list ``` -_See code: [src/commands/trash-list.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/trash-list.ts)_ +_See code: [src/commands/trash-list.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/trash-list.ts)_ ## `internxt trash-restore-file` @@ -1025,7 +1025,7 @@ EXAMPLES $ internxt trash-restore-file ``` -_See code: [src/commands/trash-restore-file.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/trash-restore-file.ts)_ +_See code: [src/commands/trash-restore-file.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/trash-restore-file.ts)_ ## `internxt trash-restore-folder` @@ -1058,7 +1058,7 @@ EXAMPLES $ internxt trash-restore-folder ``` -_See code: [src/commands/trash-restore-folder.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/trash-restore-folder.ts)_ +_See code: [src/commands/trash-restore-folder.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/trash-restore-folder.ts)_ ## `internxt trash clear` @@ -1267,7 +1267,7 @@ EXAMPLES $ internxt upload-file ``` -_See code: [src/commands/upload-file.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/upload-file.ts)_ +_See code: [src/commands/upload-file.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/upload-file.ts)_ ## `internxt upload-folder` @@ -1300,7 +1300,7 @@ EXAMPLES $ internxt upload-folder ``` -_See code: [src/commands/upload-folder.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/upload-folder.ts)_ +_See code: [src/commands/upload-folder.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/upload-folder.ts)_ ## `internxt upload file` @@ -1388,7 +1388,7 @@ EXAMPLES $ internxt webdav status ``` -_See code: [src/commands/webdav.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/webdav.ts)_ +_See code: [src/commands/webdav.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/webdav.ts)_ ## `internxt webdav-config` @@ -1427,7 +1427,7 @@ EXAMPLES $ internxt webdav-config ``` -_See code: [src/commands/webdav-config.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/webdav-config.ts)_ +_See code: [src/commands/webdav-config.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/webdav-config.ts)_ ## `internxt whoami` @@ -1447,7 +1447,7 @@ EXAMPLES $ internxt whoami ``` -_See code: [src/commands/whoami.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/whoami.ts)_ +_See code: [src/commands/whoami.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/whoami.ts)_ ## `internxt workspaces-list` @@ -1479,7 +1479,7 @@ EXAMPLES $ internxt workspaces-list ``` -_See code: [src/commands/workspaces-list.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/workspaces-list.ts)_ +_See code: [src/commands/workspaces-list.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/workspaces-list.ts)_ ## `internxt workspaces-unset` @@ -1509,7 +1509,7 @@ EXAMPLES $ internxt workspaces-unset ``` -_See code: [src/commands/workspaces-unset.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/workspaces-unset.ts)_ +_See code: [src/commands/workspaces-unset.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/workspaces-unset.ts)_ ## `internxt workspaces-use` @@ -1545,7 +1545,7 @@ EXAMPLES $ internxt workspaces-use ``` -_See code: [src/commands/workspaces-use.ts](https://github.com/internxt/cli/blob/v1.6.4/src/commands/workspaces-use.ts)_ +_See code: [src/commands/workspaces-use.ts](https://github.com/internxt/cli/blob/v1.6.5/src/commands/workspaces-use.ts)_ ## `internxt workspaces list` diff --git a/src/commands/upload-file.ts b/src/commands/upload-file.ts index 8f2e103e..e6974664 100644 --- a/src/commands/upload-file.ts +++ b/src/commands/upload-file.ts @@ -8,11 +8,10 @@ import { DriveFileService } from '../services/drive/drive-file.service'; import { MissingCredentialsError, NotValidFileError } from '../types/command.types'; import { ValidationService } from '../services/validation.service'; import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types'; -import { BufferStream } from '../utils/stream.utils'; -import { Readable } from 'node:stream'; -import { ThumbnailUtils } from '../utils/thumbnail.utils'; import { ThumbnailService } from '../services/thumbnail.service'; import { AuthService } from '../services/auth.service'; +import { UploadUtils } from '../utils/upload.utils'; +import { BufferStream } from '../utils/stream.utils'; export default class UploadFile extends Command { static readonly args = {}; @@ -82,19 +81,14 @@ export default class UploadFile extends Command { progressBar?.start(100, 0); let fileId: string | undefined; - let bufferStream: BufferStream | undefined; - const isThumbnailable = ThumbnailUtils.isFileThumbnailable(fileType); + let thumbnailStream: BufferStream | undefined; const fileSize = stats.size ?? 0; if (fileSize > 0) { // Upload file to the Network const readStream = createReadStream(filePath); - let fileStream: Readable = readStream; - - if (isThumbnailable) { - bufferStream = new BufferStream(); - fileStream = readStream.pipe(bufferStream); - } + const preparedStreams = UploadUtils.prepareUploadStreams(readStream, fileType); + thumbnailStream = preparedStreams.thumbnailStream; const progressCallback = (progress: number) => { progressBar?.update(progress * 100 * 0.99); @@ -103,7 +97,7 @@ export default class UploadFile extends Command { const abortable = new AbortController(); fileId = await networkFacade.uploadFile({ - from: fileStream, + from: preparedStreams.fileStream, size: fileSize, bucketId: bucket, progressCallback, @@ -133,32 +127,23 @@ export default class UploadFile extends Command { timings.driveUpload = driveUploadTimer.stop(); const thumbnailTimer = CLIUtils.timer(); - if (fileSize > 0 && isThumbnailable && bufferStream) { - await ThumbnailService.instance.tryUploadThumbnail({ - bufferStream, - fileType, - bucket, - fileUuid: createdDriveFile.uuid, - networkFacade, - size: fileSize, - }); - } + await ThumbnailService.instance.tryUploadThumbnail({ + bufferStream: thumbnailStream, + fileType, + bucket, + fileUuid: createdDriveFile.uuid, + networkFacade, + size: fileSize, + }); timings.thumbnailUpload = thumbnailTimer.stop(); progressBar?.update(100); progressBar?.stop(); - const totalTime = Object.values(timings).reduce((sum, time) => sum + time, 0); - const throughputMBps = CLIUtils.calculateThroughputMBps(stats.size, timings.networkUpload); + const { totalTime, timingBreakdown } = UploadUtils.getTimings(stats.size, timings); if (flags['debug']) { - CLIUtils.log( - reporter, - '[PUT] Timing breakdown:\n' + - `Network upload: ${CLIUtils.formatDuration(timings.networkUpload)} (${throughputMBps.toFixed(2)} MB/s)\n` + - `Drive upload: ${CLIUtils.formatDuration(timings.driveUpload)}\n` + - `Thumbnail: ${CLIUtils.formatDuration(timings.thumbnailUpload)}\n`, - ); + CLIUtils.log(reporter, timingBreakdown); } const workspace = await AuthService.instance.getCurrentWorkspace(); const workspaceId = workspace?.workspaceData.workspace.id; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 1da23337..01320c12 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -95,6 +95,8 @@ export class AuthService { throw new ExpiredCredentialsError(); } + let credentialsChanged = false; + if (tokenDetails.expiration.refreshRequired) { try { loginCreds = await this.refreshUserToken( @@ -102,6 +104,7 @@ export class AuthService { loginCreds.user.mnemonic, loginCreds.user.keys.ecc.privateKey, ); + credentialsChanged = true; } catch (error) { await ConfigService.instance.clearUser(); throw error; @@ -109,10 +112,16 @@ export class AuthService { } const workspaceCreds = await this.refreshWorkspaceCredentials(loginCreds); + if (workspaceCreds !== loginCreds.workspace) { + credentialsChanged = true; + } loginCreds.workspace = workspaceCreds; SdkManager.init({ token: loginCreds.token, workspaceToken: workspaceCreds?.workspaceCredentials.token }); - await ConfigService.instance.saveUser(loginCreds); + + if (credentialsChanged) { + await ConfigService.instance.saveUser(loginCreds); + } return loginCreds; }; diff --git a/src/services/config.service.ts b/src/services/config.service.ts index 2ba07442..5ee5a61c 100644 --- a/src/services/config.service.ts +++ b/src/services/config.service.ts @@ -41,12 +41,13 @@ export class ConfigService { * @async **/ public saveUser = async (loginCredentials: LoginCredentials): Promise => { + CacheService.instance.set(CacheService.AUTH_CACHE_KEY, loginCredentials); await this.ensureInternxtCliDataDirExists(); const credentialsString = JSON.stringify(loginCredentials); const encryptedCredentials = CryptoService.instance.encryptText(credentialsString); - await fs.writeFile(CREDENTIALS_FILE, encryptedCredentials, 'utf8'); - - CacheService.instance.set(CacheService.AUTH_CACHE_KEY, loginCredentials); + const tempPath = CREDENTIALS_FILE + '.tmp'; + await fs.writeFile(tempPath, encryptedCredentials, 'utf8'); + await fs.rename(tempPath, CREDENTIALS_FILE); }; /** diff --git a/src/services/network/network-facade.service.ts b/src/services/network/network-facade.service.ts index 449af7b5..51b70bf3 100644 --- a/src/services/network/network-facade.service.ts +++ b/src/services/network/network-facade.service.ts @@ -10,10 +10,7 @@ import { CryptoService } from '../crypto.service'; import { DownloadService } from './download.service'; import { ValidationService } from '../validation.service'; import { RangeOptions } from '../../utils/network.utils'; -import { UsageService } from '../usage.service'; -import { FormatUtils } from '../../utils/format.utils'; - -const FORTY_GIGABYTES = 40 * 1024 * 1024 * 1024; +import { UploadUtils } from '../../utils/upload.utils'; export class NetworkFacade { private readonly cryptoLib: Network.Crypto; @@ -138,16 +135,7 @@ export class NetworkFacade { progressCallback: (progress: number) => void; abortSignal?: AbortSignal; }): Promise => { - const limits = await UsageService.instance.fetchLimits(); - if (limits?.maxUploadFileSize && size > limits.maxUploadFileSize) { - const formattedSize = FormatUtils.humanFileSize(size); - const formattedLimit = FormatUtils.humanFileSize(limits.maxUploadFileSize); - throw new Error(`File is too big (${formattedSize} exceeds account upload limit of ${formattedLimit})`); - } - - if (size > FORTY_GIGABYTES) { - throw new Error('File is too big (more than 40 GB)'); - } + await UploadUtils.checkUploadSizeLimits(size); return this.environment.upload(bucketId, { source: from, diff --git a/src/services/thumbnail.service.ts b/src/services/thumbnail.service.ts index e0f870a7..d815b592 100644 --- a/src/services/thumbnail.service.ts +++ b/src/services/thumbnail.service.ts @@ -94,7 +94,7 @@ export class ThumbnailService { }) => { try { const thumbnailBuffer = bufferStream?.getBuffer(); - if (thumbnailBuffer) { + if (thumbnailBuffer && size > 0) { await AsyncUtils.withTimeout( ThumbnailService.instance.uploadThumbnail(thumbnailBuffer, fileType, bucket, fileUuid, networkFacade, size), ThumbnailService.MAX_THUMBNAIL_TIMEOUT, diff --git a/src/utils/cli.utils.ts b/src/utils/cli.utils.ts index cabe256d..e40d754a 100644 --- a/src/utils/cli.utils.ts +++ b/src/utils/cli.utils.ts @@ -11,6 +11,8 @@ import { ConfigService } from '../services/config.service'; import { NetworkFacade } from '../services/network/network-facade.service'; import { AuthService } from '../services/auth.service'; import { NetworkCredentials, NetworkOptions } from '../types/network.types'; +import { AppError } from '@internxt/sdk'; +import { AxiosResponseError } from '@internxt/sdk/dist/shared/types/errors'; export type LogReporter = (message: string) => void; @@ -251,24 +253,40 @@ export class CLIUtils { command, jsonFlag, }: { - error: Error; + error: Error | AppError | AxiosResponseError; command?: string; logReporter: LogReporter; jsonFlag?: boolean; }) => { - let message; - if ('message' in error && error.message.trim().length > 0) { - message = error.message; - } else { - message = JSON.stringify(error); + let message: string | undefined; + let requestId: string | undefined; + if ('requestId' in error) { + requestId = error.requestId; + } else if ('xRequestId' in error) { + requestId = error.xRequestId; + } + + if ('data' in error) { + const errorData = error.data as { message?: string }; + if (errorData.message && errorData.message.trim().length > 0) { + message = errorData.message; + } + } + + if (!message) { + if ('message' in error && error.message.trim().length > 0) { + message = error.message; + } else { + message = JSON.stringify(error); + } } CLIUtils.failed(jsonFlag); if (jsonFlag) { - CLIUtils.consoleLog(JSON.stringify({ success: false, message })); + CLIUtils.consoleLog(JSON.stringify({ success: false, message, requestId })); } else { - ErrorUtils.report(error, { command }); - CLIUtils.error(logReporter, message); + ErrorUtils.report(error, { command, requestId }); + CLIUtils.error(logReporter, message + (requestId ? ` (requestId: ${requestId})` : '')); } }; diff --git a/src/utils/upload.utils.ts b/src/utils/upload.utils.ts new file mode 100644 index 00000000..c42a2f36 --- /dev/null +++ b/src/utils/upload.utils.ts @@ -0,0 +1,61 @@ +import { UsageService } from '../services/usage.service'; +import { FormatUtils } from './format.utils'; +import { Readable } from 'node:stream'; +import { BufferStream } from './stream.utils'; +import { ThumbnailUtils } from './thumbnail.utils'; +import { CLIUtils } from './cli.utils'; + +const HUNDRED_GIGABYTES = 100 * 1024 * 1024 * 1024; + +export class UploadUtils { + static readonly checkUploadSizeLimits = async (size: number): Promise => { + const limits = await UsageService.instance.fetchLimits(); + if (limits?.maxUploadFileSize && size > limits.maxUploadFileSize) { + const formattedSize = FormatUtils.humanFileSize(size); + const formattedLimit = FormatUtils.humanFileSize(limits.maxUploadFileSize); + throw new Error(`File is too big (${formattedSize} exceeds account upload limit of ${formattedLimit})`); + } + + if (size > HUNDRED_GIGABYTES) { + throw new Error('File is too big (more than 100 GB)'); + } + }; + + static readonly prepareUploadStreams = ( + readable: Readable, + fileType: string, + ): { + fileStream: Readable; + thumbnailStream: BufferStream | undefined; + isThumbnailable: boolean; + } => { + const isThumbnailable = ThumbnailUtils.isFileThumbnailable(fileType); + if (!isThumbnailable) { + return { fileStream: readable, thumbnailStream: undefined, isThumbnailable }; + } + const bufferStream = new BufferStream(); + const fileStream = readable.pipe(bufferStream); + return { fileStream, thumbnailStream: bufferStream, isThumbnailable }; + }; + + static readonly getTimings = ( + size: number, + timings: { + networkUpload: number; + driveUpload: number; + thumbnailUpload: number; + }, + ): { + totalTime: number; + throughputMBps: number; + timingBreakdown: string; + } => { + const totalTime = Object.values(timings).reduce((sum, time) => sum + time, 0); + const throughputMBps = CLIUtils.calculateThroughputMBps(size, timings.networkUpload); + const timingBreakdown = + `Network upload: ${CLIUtils.formatDuration(timings.networkUpload)} (${throughputMBps.toFixed(2)} MB/s)\n` + + `Drive upload: ${CLIUtils.formatDuration(timings.driveUpload)}\n` + + `Thumbnail: ${CLIUtils.formatDuration(timings.thumbnailUpload)}\n`; + return { totalTime, throughputMBps, timingBreakdown }; + }; +} diff --git a/src/webdav/handlers/PUT.handler.ts b/src/webdav/handlers/PUT.handler.ts index a822fc63..3bcf4910 100644 --- a/src/webdav/handlers/PUT.handler.ts +++ b/src/webdav/handlers/PUT.handler.ts @@ -7,12 +7,10 @@ import { WebDavUtils } from '../../utils/webdav.utils'; import { webdavLogger } from '../../utils/logger.utils'; import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types'; import { CLIUtils } from '../../utils/cli.utils'; -import { BufferStream } from '../../utils/stream.utils'; -import { Readable } from 'node:stream'; import { WebDavFolderService } from '../../services/webdav/webdav-folder.service'; -import { ThumbnailUtils } from '../../utils/thumbnail.utils'; import { ThumbnailService } from '../../services/thumbnail.service'; import { FormatUtils } from '../../utils/format.utils'; +import { UploadUtils } from '../../utils/upload.utils'; export class PUTRequestHandler implements WebDavMethodHandler { handle = async (req: Request, res: Response) => { @@ -21,6 +19,8 @@ export class PUTRequestHandler implements WebDavMethodHandler { contentLength = 0; } + await UploadUtils.checkUploadSizeLimits(contentLength); + const resource = await WebDavUtils.getRequestedResource(req.url); webdavLogger.info(`[PUT] Request received for file at ${resource.url}`); webdavLogger.info( @@ -56,16 +56,9 @@ export class PUTRequestHandler implements WebDavMethodHandler { } const { user } = await AuthService.instance.getAuthDetails(); - const fileType = resource.path.ext.replace('.', ''); - let bufferStream: BufferStream | undefined; - let fileStream: Readable = req; - const isThumbnailable = ThumbnailUtils.isFileThumbnailable(fileType); - if (isThumbnailable) { - bufferStream = new BufferStream(); - fileStream = req.pipe(bufferStream); - } + const { fileStream, thumbnailStream } = UploadUtils.prepareUploadStreams(req, fileType); const { networkFacade, bucket } = await CLIUtils.prepareNetwork(user); @@ -118,27 +111,22 @@ export class PUTRequestHandler implements WebDavMethodHandler { timings.driveUpload = driveTimer.stop(); const thumbnailTimer = CLIUtils.timer(); - if (contentLength > 0 && isThumbnailable && bufferStream) { - await ThumbnailService.instance.tryUploadThumbnail({ - fileUuid: file.uuid, - bufferStream, - fileType, - bucket, - networkFacade, - size: contentLength, - }); - } + await ThumbnailService.instance.tryUploadThumbnail({ + fileUuid: file.uuid, + bufferStream: thumbnailStream, + fileType, + bucket, + networkFacade, + size: contentLength, + }); timings.thumbnailUpload = thumbnailTimer.stop(); - const totalTime = Object.values(timings).reduce((sum, time) => sum + time, 0); - const throughputMBps = CLIUtils.calculateThroughputMBps(contentLength, timings.networkUpload); + const { totalTime, timingBreakdown } = UploadUtils.getTimings(contentLength, timings); webdavLogger.info( `[PUT] ✅ File uploaded in ${CLIUtils.formatDuration(totalTime)} to Internxt Drive\n` + '[PUT] Timing breakdown:\n' + - `Network upload: ${CLIUtils.formatDuration(timings.networkUpload)} (${throughputMBps.toFixed(2)} MB/s)\n` + - `Drive upload: ${CLIUtils.formatDuration(timings.driveUpload)}\n` + - `Thumbnail: ${CLIUtils.formatDuration(timings.thumbnailUpload)}\n`, + timingBreakdown, ); webdavLogger.info( diff --git a/src/webdav/middewares/errors.middleware.ts b/src/webdav/middewares/errors.middleware.ts index 5ce536cd..acd60191 100644 --- a/src/webdav/middewares/errors.middleware.ts +++ b/src/webdav/middewares/errors.middleware.ts @@ -26,5 +26,7 @@ export const ErrorHandlingMiddleware: ErrorRequestHandler = (err, req, res, _) = statusCode = err.statusCode; } + res.set('Content-Type', 'application/xml; charset="utf-8"'); res.status(statusCode).send(errorBodyXML); + req.destroy(); }; diff --git a/test/services/config.service.test.ts b/test/services/config.service.test.ts index 48af710a..f0375cfc 100644 --- a/test/services/config.service.test.ts +++ b/test/services/config.service.test.ts @@ -42,7 +42,7 @@ describe('Config service', () => { vi.spyOn(CacheService.instance, 'set').mockImplementation(() => {}); }); - it('When an env property is requested, then the get method return its value', async () => { + it('should return the value when an env property is requested', async () => { const envKey = 'APP_CRYPTO_SECRET'; const envValue = crypto.randomBytes(8).toString('hex'); process.env[envKey] = envValue; @@ -51,7 +51,7 @@ describe('Config service', () => { expect(newEnvValue).to.be.equal(envValue); }); - it('When an env property that do not have value is requested, then an error is thrown', async () => { + it('should throw an error when an env property has no value', async () => { const envKey = 'APP_CRYPTO_SECRET'; process.env = {}; @@ -64,20 +64,23 @@ describe('Config service', () => { } }); - it('When user credentials are saved, then they are written encrypted to a file', async () => { + it('should write credentials encrypted to a temp file and rename atomically when saveUser is called', async () => { const userCredentials: LoginCredentials = UserCredentialsFixture; const stringCredentials = JSON.stringify(userCredentials); const encryptedUserCredentials = CryptoService.instance.encryptText(stringCredentials); + const tempPath = CREDENTIALS_FILE + '.tmp'; const configServiceStub = vi.spyOn(CryptoService.instance, 'encryptText').mockReturnValue(encryptedUserCredentials); - const fsStub = vi.spyOn(fs, 'writeFile').mockResolvedValue(); + const writeFileStub = vi.spyOn(fs, 'writeFile').mockResolvedValue(); + const renameStub = vi.spyOn(fs, 'rename').mockResolvedValue(); await ConfigService.instance.saveUser(userCredentials); expect(configServiceStub).toHaveBeenCalledWith(stringCredentials); - expect(fsStub).toHaveBeenCalledWith(CREDENTIALS_FILE, encryptedUserCredentials, 'utf8'); + expect(writeFileStub).toHaveBeenCalledWith(tempPath, encryptedUserCredentials, 'utf8'); + expect(renameStub).toHaveBeenCalledWith(tempPath, CREDENTIALS_FILE); }); - it('When user credentials are read, then they are read and decrypted from a file', async () => { + it('should read and decrypt credentials from file when readUser is called', async () => { const userCredentials: LoginCredentials = UserCredentialsFixture; const stringCredentials = JSON.stringify(userCredentials); const encryptedUserCredentials = CryptoService.instance.encryptText(stringCredentials); @@ -91,7 +94,7 @@ describe('Config service', () => { expect(configServiceStub).toHaveBeenCalledWith(encryptedUserCredentials); }); - it('When user credentials are read but they dont exist, then they are not returned', async () => { + it('should return undefined when credentials file does not exist', async () => { const fsStub = vi.spyOn(fs, 'readFile').mockRejectedValue(new Error()); const configServiceStub = vi.spyOn(CryptoService.instance, 'decryptText'); @@ -101,7 +104,7 @@ describe('Config service', () => { expect(configServiceStub).not.toHaveBeenCalled(); }); - it('When user credentials are cleared, then they are cleared from file', async () => { + it('should clear credentials from file when clearUser is called', async () => { const writeFileStub = vi.spyOn(fs, 'writeFile').mockResolvedValue(); const readFileStub = vi.spyOn(fs, 'readFile').mockResolvedValue(''); const existFileStub = vi @@ -144,7 +147,7 @@ describe('Config service', () => { expect(writeFileStub).not.toHaveBeenCalled(); }); - it('When webdav certs directory is required to exist, then it is created', async () => { + it('should create the webdav certs directory when it does not exist', async () => { vi.spyOn(fs, 'access').mockRejectedValue(new Error()); const stubMkdir = vi.spyOn(fs, 'mkdir').mockResolvedValue(''); @@ -154,7 +157,7 @@ describe('Config service', () => { expect(stubMkdir).toHaveBeenCalledWith(WEBDAV_SSL_CERTS_DIR); }); - it('When webdav config options are saved, then they are written to a file', async () => { + it('should write webdav config to file when saveWebdavConfig is called', async () => { const webdavConfig: WebdavConfig = getWebdavConfigMock(); const stringConfig = JSON.stringify(webdavConfig); @@ -164,7 +167,7 @@ describe('Config service', () => { expect(fsStub).toHaveBeenCalledWith(WEBDAV_CONFIGS_FILE, stringConfig, 'utf8'); }); - it('When webdav config options are read and exist, then they are read from a file', async () => { + it('should read webdav config from file when readWebdavConfig is called', async () => { const webdavConfig: WebdavConfig = getWebdavConfigMock(); const stringConfig = JSON.stringify(webdavConfig); @@ -175,7 +178,7 @@ describe('Config service', () => { expect(fsStub).toHaveBeenCalledWith(WEBDAV_CONFIGS_FILE, 'utf8'); }); - it('When webdav config options are read but not exist, then they are returned from defaults', async () => { + it('should return default config when webdav config file is empty', async () => { const fsStub = vi.spyOn(fs, 'readFile').mockResolvedValue(''); const webdavConfigResult = await ConfigService.instance.readWebdavConfig(); @@ -183,7 +186,7 @@ describe('Config service', () => { expect(fsStub).toHaveBeenCalledWith(WEBDAV_CONFIGS_FILE, 'utf8'); }); - it('When webdav config options are read but an error is thrown, then they are returned from defaults', async () => { + it('should return default config when readWebdavConfig throws an error', async () => { const fsStub = vi.spyOn(fs, 'readFile').mockRejectedValue(new Error()); const webdavConfigResult = await ConfigService.instance.readWebdavConfig(); diff --git a/test/services/network/network-facade.service.test.ts b/test/services/network/network-facade.service.test.ts index 95ab255d..18cc2eee 100644 --- a/test/services/network/network-facade.service.test.ts +++ b/test/services/network/network-facade.service.test.ts @@ -87,7 +87,7 @@ describe('Network Facade Service', () => { expect(mockEnvironment.upload).not.toHaveBeenCalled(); }); - it('Should throw an error when a file exceeds 40 GB', async () => { + it('Should throw an error when a file exceeds 100 GB', async () => { const mockEnvironment = { upload: vi.fn(), }; @@ -106,16 +106,16 @@ describe('Network Facade Service', () => { await expect(() => sut.uploadFile({ from: readStream, - size: 41 * 1024 * 1024 * 1024, + size: 101 * 1024 * 1024 * 1024, bucketId: 'bucket-id', progressCallback: vi.fn(), }), - ).rejects.toThrow('File is too big (more than 40 GB)'); + ).rejects.toThrow('File is too big (more than 100 GB)'); expect(mockEnvironment.upload).not.toHaveBeenCalled(); }); - it('Should enforce API limit over 40 GB hard cap when maxUploadFileSize is smaller', async () => { + it('Should enforce API limit over 100 GB hard cap when maxUploadFileSize is smaller', async () => { const mockEnvironment = { upload: vi.fn(), }; diff --git a/test/utils/upload.utils.test.ts b/test/utils/upload.utils.test.ts new file mode 100644 index 00000000..44feab98 --- /dev/null +++ b/test/utils/upload.utils.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Readable } from 'node:stream'; +import { UploadUtils } from '../../src/utils/upload.utils'; +import { UsageService } from '../../src/services/usage.service'; +import { ThumbnailUtils } from '../../src/utils/thumbnail.utils'; +import { CLIUtils } from '../../src/utils/cli.utils'; +import { FormatUtils } from '../../src/utils/format.utils'; +import { BufferStream } from '../../src/utils/stream.utils'; + +describe('UploadUtils', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('checkUploadSizeLimits', () => { + it('should not throw when fetchLimits returns no maxUploadFileSize and size is under 100GB', async () => { + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue(undefined as never); + + await expect(UploadUtils.checkUploadSizeLimits(50 * 1024 * 1024 * 1024)).resolves.toBeUndefined(); + }); + + it('should not throw when fetchLimits returns null', async () => { + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue(null as never); + + await expect(UploadUtils.checkUploadSizeLimits(50 * 1024 * 1024 * 1024)).resolves.toBeUndefined(); + }); + + it('should not throw when size is below the account upload limit', async () => { + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue({ + maxUploadFileSize: 100 * 1024 * 1024 * 1024, + versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }); + + await expect(UploadUtils.checkUploadSizeLimits(50 * 1024 * 1024 * 1024)).resolves.toBeUndefined(); + }); + + it('should throw when size exceeds the account upload limit', async () => { + const limitBytes = 10 * 1024 * 1024 * 1024; + const sizeBytes = 15 * 1024 * 1024 * 1024; + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue({ + maxUploadFileSize: limitBytes, + versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }); + + await expect(UploadUtils.checkUploadSizeLimits(sizeBytes)).rejects.toThrow( + `File is too big (${FormatUtils.humanFileSize(sizeBytes)} exceeds account ` + + `upload limit of ${FormatUtils.humanFileSize(limitBytes)})`, + ); + }); + + it('should not throw when size equals the account upload limit', async () => { + const limitBytes = 10 * 1024 * 1024 * 1024; + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue({ + maxUploadFileSize: limitBytes, + versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }); + + await expect(UploadUtils.checkUploadSizeLimits(limitBytes)).resolves.toBeUndefined(); + }); + + it('should throw when size exceeds 100GB even if no account limit is set', async () => { + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue(undefined as never); + + await expect(UploadUtils.checkUploadSizeLimits(101 * 1024 * 1024 * 1024)).rejects.toThrow( + 'File is too big (more than 100 GB)', + ); + }); + + it('should throw when size > 100GB', async () => { + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue({ + maxUploadFileSize: 200 * 1024 * 1024 * 1024, + versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }); + + await expect(UploadUtils.checkUploadSizeLimits(150 * 1024 * 1024 * 1024)).rejects.toThrow( + 'File is too big (more than 100 GB)', + ); + }); + + it('should use the account limit when it is below 100GB', async () => { + const limitBytes = 50 * 1024 * 1024 * 1024; + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue({ + maxUploadFileSize: limitBytes, + versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }); + + await expect(UploadUtils.checkUploadSizeLimits(75 * 1024 * 1024 * 1024)).rejects.toThrow( + `File is too big (${FormatUtils.humanFileSize(75 * 1024 * 1024 * 1024)} exceeds account ` + + `upload limit of ${FormatUtils.humanFileSize(limitBytes)})`, + ); + }); + }); + + describe('prepareUploadStreams', () => { + it('should return the original stream and no thumbnail when file type is not thumbnailable', () => { + vi.spyOn(ThumbnailUtils, 'isFileThumbnailable').mockReturnValue(false); + const readable = Readable.from(['test']); + + const result = UploadUtils.prepareUploadStreams(readable, 'pdf'); + + expect(result.fileStream).toBe(readable); + expect(result.thumbnailStream).toBeUndefined(); + expect(result.isThumbnailable).toBe(false); + }); + + it('should return a piped stream and a BufferStream when file type is thumbnailable', () => { + vi.spyOn(ThumbnailUtils, 'isFileThumbnailable').mockReturnValue(true); + const readable = Readable.from(['test-data']); + + const result = UploadUtils.prepareUploadStreams(readable, 'jpg'); + + expect(result.fileStream).not.toBe(readable); + expect(result.fileStream).toBeInstanceOf(Readable); + expect(result.thumbnailStream).toBeInstanceOf(BufferStream); + expect(result.isThumbnailable).toBe(true); + }); + }); + + describe('getTimings', () => { + it('should calculate total time as the sum of all timings', () => { + vi.spyOn(CLIUtils, 'calculateThroughputMBps').mockReturnValue(5); + vi.spyOn(CLIUtils, 'formatDuration').mockReturnValue('00:00:01.000'); + + const result = UploadUtils.getTimings(1024 * 1024 * 10, { + networkUpload: 1000, + driveUpload: 2000, + thumbnailUpload: 500, + }); + + expect(result.totalTime).toBe(3500); + }); + + it('should call calculateThroughputMBps with size and networkUpload time', () => { + const calculateThroughputSpy = vi.spyOn(CLIUtils, 'calculateThroughputMBps').mockReturnValue(2.5); + vi.spyOn(CLIUtils, 'formatDuration').mockReturnValue('00:00:01.000'); + + UploadUtils.getTimings(1024 * 1024 * 5, { + networkUpload: 2000, + driveUpload: 1000, + thumbnailUpload: 500, + }); + + expect(calculateThroughputSpy).toHaveBeenCalledWith(1024 * 1024 * 5, 2000); + }); + + it('should return throughputMBps from calculateThroughputMBps', () => { + vi.spyOn(CLIUtils, 'calculateThroughputMBps').mockReturnValue(12.34); + vi.spyOn(CLIUtils, 'formatDuration').mockReturnValue('00:00:01.000'); + + const result = UploadUtils.getTimings(1024 * 1024 * 10, { + networkUpload: 1000, + driveUpload: 500, + thumbnailUpload: 200, + }); + + expect(result.throughputMBps).toBe(12.34); + }); + + it('should build a timing breakdown string with formatted durations', () => { + vi.spyOn(CLIUtils, 'calculateThroughputMBps').mockReturnValue(10); + vi.spyOn(CLIUtils, 'formatDuration').mockImplementation((ms: number) => `formatted-${ms}`); + + const result = UploadUtils.getTimings(1024 * 1024 * 10, { + networkUpload: 2000, + driveUpload: 1500, + thumbnailUpload: 700, + }); + + expect(result.timingBreakdown).toBe( + 'Network upload: formatted-2000 (10.00 MB/s)\n' + + 'Drive upload: formatted-1500\n' + + 'Thumbnail: formatted-700\n', + ); + }); + + it('should handle all-zero timings', () => { + vi.spyOn(CLIUtils, 'calculateThroughputMBps').mockReturnValue(0); + vi.spyOn(CLIUtils, 'formatDuration').mockReturnValue('00:00:00.000'); + + const result = UploadUtils.getTimings(0, { + networkUpload: 0, + driveUpload: 0, + thumbnailUpload: 0, + }); + + expect(result.totalTime).toBe(0); + expect(result.throughputMBps).toBe(0); + }); + }); +}); diff --git a/test/webdav/handlers/PUT.handler.test.ts b/test/webdav/handlers/PUT.handler.test.ts index a49e2068..f4c6c5d5 100644 --- a/test/webdav/handlers/PUT.handler.test.ts +++ b/test/webdav/handlers/PUT.handler.test.ts @@ -16,6 +16,7 @@ import { WebDavUtils } from '../../../src/utils/webdav.utils'; import { newDriveFile, newFolderItem } from '../../fixtures/drive.fixture'; import { UserCredentialsFixture } from '../../fixtures/login.fixture'; import { CLIUtils } from '../../../src/utils/cli.utils'; +import { UsageService } from '../../../src/services/usage.service'; describe('PUT request handler', () => { let networkFacade: NetworkFacade; @@ -24,6 +25,10 @@ describe('PUT request handler', () => { beforeEach(() => { networkFacade = getNetworkFacadeMock(); vi.spyOn(CLIUtils, 'prepareNetwork').mockResolvedValue(getNetworkOptionsMock({ networkFacade })); + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue({ + maxUploadFileSize: null, + versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }); sut = new PUTRequestHandler(); });