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/WEBDAV.md b/WEBDAV.md index cbbe112f..0636ebce 100644 --- a/WEBDAV.md +++ b/WEBDAV.md @@ -46,6 +46,8 @@ Find below the methods that are supported in the latest version of the Internxt | MKCOL | ✅ | | COPY | ❌ | | MOVE | ✅ | +| LOCK | ✅ | +| UNLOCK | ✅ | ## Requisites 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/database/drive-file/drive-file.repository.ts b/src/services/database/drive-file/drive-file.repository.ts index 6768e190..841ff568 100644 --- a/src/services/database/drive-file/drive-file.repository.ts +++ b/src/services/database/drive-file/drive-file.repository.ts @@ -15,6 +15,19 @@ export class FileRepository { for (let i = 0; i < files.length; i += DatabaseUtils.CREATE_BATCH_SIZE) { const chunk = files.slice(i, i + DatabaseUtils.CREATE_BATCH_SIZE); + const seenKeys = new Set(); + for (const f of chunk) { + seenKeys.add(`${f.folderUuid}::${f.name}::${f.type ?? ''}`); + } + for (const key of seenKeys) { + const parts = key.split('::'); + const folderUuid = parts[0]; + const name = parts[1]; + const type = parts[2] || null; + const typeCondition = type ?? IsNull(); + await this.fileRepository.delete({ folderUuid, name, type: typeCondition }); + } + await this.fileRepository.upsert(chunk, { conflictPaths: ['uuid'] }); } @@ -48,6 +61,18 @@ export class FileRepository { } }; + public getByUuid = async (uuid: string): Promise => { + try { + const file = await this.fileRepository.findOneBy({ uuid }); + if (!file) { + return; + } + return DriveFile.build(file); + } catch (error) { + ErrorUtils.report(error, { getByUuid: uuid }); + } + }; + public getByParentUuidNameAndType = async ( parentUuid: string, name: string, @@ -55,11 +80,15 @@ export class FileRepository { ): Promise => { try { const typeCondition = type ?? IsNull(); - const file = await this.fileRepository.findOneBy({ folderUuid: parentUuid, name, type: typeCondition }); - if (!file) { + const files = await this.fileRepository.find({ + where: { folderUuid: parentUuid, name, type: typeCondition }, + order: { createdAt: 'DESC' }, + take: 1, + }); + if (files.length === 0) { return; } - return DriveFile.build(file); + return DriveFile.build(files[0]); } catch (error) { ErrorUtils.report(error, { getByParentuuidAndName: { parentUuid, name, type } }); } diff --git a/src/services/database/drive-folder/drive-folder.repository.ts b/src/services/database/drive-folder/drive-folder.repository.ts index 7f83a351..d09e134b 100644 --- a/src/services/database/drive-folder/drive-folder.repository.ts +++ b/src/services/database/drive-folder/drive-folder.repository.ts @@ -35,11 +35,15 @@ export class FolderRepository { public getByParentUuidAndName = async (parentUuid: string, name: string): Promise => { try { - const folder = await this.folderRepository.findOneBy({ parentUuid, name }); - if (!folder) { + const folders = await this.folderRepository.find({ + where: { parentUuid, name }, + order: { createdAt: 'DESC' }, + take: 1, + }); + if (folders.length === 0) { return; } - return DriveFolder.build(folder); + return DriveFolder.build(folders[0]); } catch (error) { ErrorUtils.report(error, { getByParentuuidAndName: { parentUuid, name } }); } @@ -66,19 +70,32 @@ export class FolderRepository { } }; - public createOrUpdate = async (files: DriveFolderModel[]) => { - if (files.length === 0) return; + public createOrUpdate = async (folders: DriveFolderModel[]) => { + if (folders.length === 0) return; try { - for (let i = 0; i < files.length; i += DatabaseUtils.CREATE_BATCH_SIZE) { - const chunk = files.slice(i, i + DatabaseUtils.CREATE_BATCH_SIZE); + for (let i = 0; i < folders.length; i += DatabaseUtils.CREATE_BATCH_SIZE) { + const chunk = folders.slice(i, i + DatabaseUtils.CREATE_BATCH_SIZE); + + const seenKeys = new Set(); + for (const f of chunk) { + if (f.parentUuid) { + seenKeys.add(`${f.parentUuid}::${f.name}`); + } + } + for (const key of seenKeys) { + const separatorIndex = key.indexOf('::'); + const parentUuid = key.slice(0, separatorIndex); + const name = key.slice(separatorIndex + 2); + await this.folderRepository.delete({ parentUuid, name }); + } await this.folderRepository.upsert(chunk, { conflictPaths: ['uuid'] }); } - return files.map((file) => DriveFolder.build(file)); + return folders.map((file) => DriveFolder.build(file)); } catch (error) { - ErrorUtils.report(error, { createOrUpdate: files }); + ErrorUtils.report(error, { createOrUpdate: folders }); } }; diff --git a/src/services/drive/drive-file.service.ts b/src/services/drive/drive-file.service.ts index 825ab3fd..90f009ae 100644 --- a/src/services/drive/drive-file.service.ts +++ b/src/services/drive/drive-file.service.ts @@ -125,7 +125,7 @@ export class DriveFileService { return file; } } catch { - logger.error('File not found when getting file by path on local DB', { path }); + logger.warn('File not found when getting file by path on local DB', { path }); } } diff --git a/src/services/drive/drive-folder.service.ts b/src/services/drive/drive-folder.service.ts index 810f697f..cc6457de 100644 --- a/src/services/drive/drive-folder.service.ts +++ b/src/services/drive/drive-folder.service.ts @@ -257,7 +257,7 @@ export class DriveFolderService { return folder; } } catch { - logger.error('Folder not found when getting folder by path on local DB', { path, rootFolderUuid }); + logger.warn('Folder not found when getting folder by path on local DB', { path, rootFolderUuid }); } } return this.getByPath(path, rootFolderUuid); 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..abbf352d --- /dev/null +++ b/src/utils/upload.utils.ts @@ -0,0 +1,62 @@ +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 TEN_GIGABYTES = 10 * 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 > TEN_GIGABYTES) { + //Default limit if limits are not set from backend + throw new Error('File is too big (more than 10 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/LOCK.handler.ts b/src/webdav/handlers/LOCK.handler.ts new file mode 100644 index 00000000..7802d519 --- /dev/null +++ b/src/webdav/handlers/LOCK.handler.ts @@ -0,0 +1,59 @@ +import { Request, Response } from 'express'; +import { WebDavMethodHandler } from '../../types/webdav.types'; +import { randomUUID } from 'node:crypto'; +import { webdavLogger } from '../../utils/logger.utils'; +import { XMLUtils } from '../../utils/xml.utils'; + +const LOCK_TIMEOUT_SECONDS = 300; + +export class LOCKRequestHandler implements WebDavMethodHandler { + handle = async (req: Request, res: Response) => { + const lockToken = `opaquelocktoken:${randomUUID()}`; + + webdavLogger.info(`[LOCK] Granted lock token ${lockToken} for ${req.url}`); + + const depth = req.headers['depth'] ?? '0'; + const timeout = req.headers['timeout'] ?? `Second-${LOCK_TIMEOUT_SECONDS}`; + + let owner: Record | undefined; + try { + const parsed = XMLUtils.toJSON(req.body, { ignoreAttributes: false }); + const maybeOwner = parsed?.['D:lockinfo']?.['D:owner']; + if (maybeOwner != null) { + owner = { [XMLUtils.addDefaultNamespace('owner')]: maybeOwner }; + } + } catch { + // no owner in body, ignore + } + + const lockDiscoveryXml = XMLUtils.toWebDavXML( + { + [XMLUtils.addDefaultNamespace('lockdiscovery')]: { + [XMLUtils.addDefaultNamespace('activelock')]: { + [XMLUtils.addDefaultNamespace('locktype')]: { + [XMLUtils.addDefaultNamespace('write')]: '', + }, + [XMLUtils.addDefaultNamespace('lockscope')]: { + [XMLUtils.addDefaultNamespace('exclusive')]: '', + }, + [XMLUtils.addDefaultNamespace('depth')]: depth, + [XMLUtils.addDefaultNamespace('timeout')]: timeout, + [XMLUtils.addDefaultNamespace('locktoken')]: { + [XMLUtils.addDefaultNamespace('href')]: lockToken, + }, + [XMLUtils.addDefaultNamespace('lockroot')]: { + [XMLUtils.addDefaultNamespace('href')]: req.url, + }, + ...owner, + }, + }, + }, + { suppressEmptyNode: true }, + 'prop', + ); + + res.set('Lock-Token', `<${lockToken}>`); + res.set('Content-Type', 'application/xml; charset="utf-8"'); + res.status(200).send(lockDiscoveryXml); + }; +} diff --git a/src/webdav/handlers/OPTIONS.handler.ts b/src/webdav/handlers/OPTIONS.handler.ts index d9943398..ffb4ee9a 100644 --- a/src/webdav/handlers/OPTIONS.handler.ts +++ b/src/webdav/handlers/OPTIONS.handler.ts @@ -11,21 +11,21 @@ export class OPTIONSRequestHandler implements WebDavMethodHandler { if (resource.url === '/' || resource.url === '') { // Root Folder - const allowedMethods = 'DELETE, GET, HEAD, MKCOL, MOVE, OPTIONS, PROPFIND, PUT'; + const allowedMethods = 'DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PROPFIND, PUT, UNLOCK'; webdavLogger.info(`[OPTIONS] Returning Allowed Options: ${allowedMethods}`); - res.header('Allow', 'DELETE, GET, HEAD, MKCOL, MOVE, OPTIONS, PROPFIND, PUT'); + res.header('Allow', allowedMethods); res.header('DAV', '1, 2, ordered-collections'); res.status(200).send(); } else if (resource.url.endsWith('/')) { // Children Folder - const allowedMethods = 'DELETE, HEAD, MKCOL, MOVE, OPTIONS, PROPFIND'; + const allowedMethods = 'DELETE, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PROPFIND, UNLOCK'; webdavLogger.info(`[OPTIONS] Returning Allowed Options: ${allowedMethods}`); res.header('Allow', allowedMethods); res.header('DAV', '1, 2, ordered-collections'); res.status(200).send(); } else { // Children File - const allowedMethods = 'DELETE, GET, HEAD, MOVE, OPTIONS, PROPFIND, PUT'; + const allowedMethods = 'DELETE, GET, HEAD, LOCK, MOVE, OPTIONS, PROPFIND, PUT, UNLOCK'; webdavLogger.info(`[OPTIONS] Returning Allowed Options: ${allowedMethods}`); res.header('Allow', allowedMethods); res.header('DAV', '1, 2, ordered-collections'); diff --git a/src/webdav/handlers/PUT.handler.ts b/src/webdav/handlers/PUT.handler.ts index a822fc63..dac4c521 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( @@ -37,6 +37,8 @@ export class PUTRequestHandler implements WebDavMethodHandler { (await WebDavFolderService.instance.getDriveFolderItemFromPath(resource.parentPath)) ?? (await WebDavFolderService.instance.createParentPathOrThrow(resource.parentPath)); + let isReplacement = false; + // If the file already exists, the WebDAV specification states that 'PUT /…/file' should replace it. // http://www.webdav.org/specs/rfc4918.html#put-resources const driveFileItem = await WebDavUtils.getDriveItemFromResource(resource); @@ -45,6 +47,7 @@ export class PUTRequestHandler implements WebDavMethodHandler { webdavLogger.info('[PUT] ❌ A folder exists on the cloud with the same name.'); throw new ConflictError('A folder exists on the cloud with the same name'); } + isReplacement = true; webdavLogger.info( `[PUT] File '${resource.name}' already exists in '${resource.path.dir}', it will be replaced...`, ); @@ -56,16 +59,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,33 +114,30 @@ 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, ); + const statusCode = isReplacement ? 204 : 201; webdavLogger.info( - `[PUT] [RESPONSE-201] ${resource.url} - Returning 201 Created after ${CLIUtils.formatDuration(totalTime)}`, + `[PUT] [RESPONSE-${statusCode}] ${resource.url} - Returning ${statusCode} ` + + `after ${CLIUtils.formatDuration(totalTime)}`, ); - res.status(201).send(); + res.status(statusCode).send(); }; } diff --git a/src/webdav/handlers/UNLOCK.handler.ts b/src/webdav/handlers/UNLOCK.handler.ts new file mode 100644 index 00000000..4f183ca9 --- /dev/null +++ b/src/webdav/handlers/UNLOCK.handler.ts @@ -0,0 +1,13 @@ +import { Request, Response } from 'express'; +import { WebDavMethodHandler } from '../../types/webdav.types'; +import { webdavLogger } from '../../utils/logger.utils'; + +export class UNLOCKRequestHandler implements WebDavMethodHandler { + handle = async (req: Request, res: Response) => { + const lockToken = req.headers['lock-token'] ?? 'unknown'; + + webdavLogger.info(`[UNLOCK] Released lock ${lockToken} for ${req.url}`); + + res.status(204).send(); + }; +} diff --git a/src/webdav/index.ts b/src/webdav/index.ts index d5ce2396..b5fabadf 100644 --- a/src/webdav/index.ts +++ b/src/webdav/index.ts @@ -15,7 +15,6 @@ const init = async () => { await ConfigService.instance.ensureInternxtLogsDirExists(); await DatabaseService.instance.initialize(); - await DatabaseService.instance.clear(); const { token, workspace } = await AuthService.instance.getAuthDetails(); SdkManager.init({ token, workspaceToken: workspace?.workspaceCredentials.token }); 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/src/webdav/middewares/request-logger.middleware.ts b/src/webdav/middewares/request-logger.middleware.ts index 43c31cd9..4639389f 100644 --- a/src/webdav/middewares/request-logger.middleware.ts +++ b/src/webdav/middewares/request-logger.middleware.ts @@ -6,16 +6,22 @@ type RequestLoggerConfig = { methods?: string[]; }; export const RequestLoggerMiddleware = (config: RequestLoggerConfig): RequestHandler => { - return (req, _, next) => { + return (req, res, next) => { if (!config.enable) return next(); if (config.methods && !config.methods.includes(req.method)) return next(); - webdavLogger.info( - 'WebDav request received\n' + - `Method: ${req.method}\n` + - `URL: ${req.url}\n` + - `Body: ${JSON.stringify(req.body)}\n` + - `Headers: ${JSON.stringify(req.headers)}`, - ); + + const start = Date.now(); + const contentLength = req.headers['content-length'] ?? '0'; + + webdavLogger.info(`[${req.method}] ${req.url} - Start (${contentLength}B)`); + + const originalEnd = res.end.bind(res); + res.end = function (...args: Parameters): ReturnType { + const duration = Date.now() - start; + webdavLogger.info(`[${req.method}] ${req.url} - ${res.statusCode} (${duration}ms)`); + return originalEnd(...args); + } as typeof originalEnd; + next(); }; }; diff --git a/src/webdav/webdav-server.ts b/src/webdav/webdav-server.ts index 5fd110d5..f67af166 100644 --- a/src/webdav/webdav-server.ts +++ b/src/webdav/webdav-server.ts @@ -20,6 +20,8 @@ import { DELETERequestHandler } from './handlers/DELETE.handler'; import { PROPPATCHRequestHandler } from './handlers/PROPPATCH.handler'; import { MOVERequestHandler } from './handlers/MOVE.handler'; import { COPYRequestHandler } from './handlers/COPY.handler'; +import { LOCKRequestHandler } from './handlers/LOCK.handler'; +import { UNLOCKRequestHandler } from './handlers/UNLOCK.handler'; import { MkcolMiddleware } from './middewares/mkcol.middleware'; import { WebdavConfig } from '../types/command.types'; import { WebDAVAuthMiddleware } from './middewares/webdav-auth.middleware'; @@ -51,6 +53,8 @@ export class WebDavServer { this.app.propfind(serverListenPath, asyncHandler(new PROPFINDRequestHandler().handle)); this.app.put(serverListenPath, asyncHandler(new PUTRequestHandler().handle)); + this.app.lock(serverListenPath, asyncHandler(new LOCKRequestHandler().handle)); + this.app.unlock(serverListenPath, asyncHandler(new UNLOCKRequestHandler().handle)); this.app.mkcol(serverListenPath, asyncHandler(new MKCOLRequestHandler().handle)); this.app.delete(serverListenPath, asyncHandler(new DELETERequestHandler().handle)); 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..403333bd 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 10 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: 11 * 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 10 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 10 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..f93b192d --- /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 10GB', async () => { + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue(undefined as never); + + await expect(UploadUtils.checkUploadSizeLimits(5 * 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(5 * 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: 8 * 1024 * 1024 * 1024, + versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }); + + await expect(UploadUtils.checkUploadSizeLimits(5 * 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 10GB even if no account limit is set', async () => { + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue(undefined as never); + + await expect(UploadUtils.checkUploadSizeLimits(11 * 1024 * 1024 * 1024)).rejects.toThrow( + 'File is too big (more than 10 GB)', + ); + }); + + it('should throw when size > 10GB', async () => { + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue({ + maxUploadFileSize: 20 * 1024 * 1024 * 1024, + versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }); + + await expect(UploadUtils.checkUploadSizeLimits(15 * 1024 * 1024 * 1024)).rejects.toThrow( + 'File is too big (more than 10 GB)', + ); + }); + + it('should use the account limit when it is below the default limit', async () => { + const limitBytes = 8 * 1024 * 1024 * 1024; + vi.spyOn(UsageService.instance, 'fetchLimits').mockResolvedValue({ + maxUploadFileSize: limitBytes, + versioning: { enabled: false, maxFileSize: 0, retentionDays: 0, maxVersions: 0 }, + }); + + await expect(UploadUtils.checkUploadSizeLimits(9 * 1024 * 1024 * 1024)).rejects.toThrow( + `File is too big (${FormatUtils.humanFileSize(9 * 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/LOCK.handler.test.ts b/test/webdav/handlers/LOCK.handler.test.ts new file mode 100644 index 00000000..28e50b9c --- /dev/null +++ b/test/webdav/handlers/LOCK.handler.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LOCKRequestHandler } from '../../../src/webdav/handlers/LOCK.handler'; +import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../fixtures/webdav.fixture'; + +describe('LOCK request handler', () => { + it('should return 200 with a valid lock token header and content type', async () => { + const requestHandler = new LOCKRequestHandler(); + + const request = createWebDavRequestFixture({ + method: 'LOCK', + url: '/file.txt', + }); + const response = createWebDavResponseFixture({ + status: vi.fn().mockReturnThis(), + }); + const setSpy = vi.spyOn(response, 'set'); + const sendSpy = vi.spyOn(response, 'send'); + + await requestHandler.handle(request, response); + + expect(response.status).toHaveBeenCalledWith(200); + expect(setSpy).toHaveBeenCalledWith('Lock-Token', expect.stringMatching(/^ c[0] === 'Lock-Token')?.[1] as string; + const token = headerToken.slice(1, -1); + const expectedXml = + '' + + '0' + + `Second-300${token}` + + '/file.txt' + + ''; + expect(sendSpy.mock.calls[0]?.[0]).toBe(expectedXml); + }); + + it('should include default depth 0 and timeout in the XML response body', async () => { + const requestHandler = new LOCKRequestHandler(); + + const request = createWebDavRequestFixture({ + method: 'LOCK', + url: '/file.txt', + }); + const response = createWebDavResponseFixture({}); + const setSpy = vi.spyOn(response, 'set'); + const sendSpy = vi.spyOn(response, 'send'); + + await requestHandler.handle(request, response); + + const headerToken = setSpy.mock.calls.find((c) => c[0] === 'Lock-Token')?.[1] as string; + const token = headerToken.slice(1, -1); + const expectedXml = + '' + + '0' + + `Second-300${token}` + + '/file.txt' + + ''; + expect(sendSpy.mock.calls[0]?.[0]).toBe(expectedXml); + }); + + it('should use the depth and timeout from the request headers', async () => { + const requestHandler = new LOCKRequestHandler(); + + const request = createWebDavRequestFixture({ + method: 'LOCK', + url: '/folder/', + headers: { + depth: 'Infinity', + timeout: 'Second-600', + }, + }); + const response = createWebDavResponseFixture({}); + const setSpy = vi.spyOn(response, 'set'); + const sendSpy = vi.spyOn(response, 'send'); + + await requestHandler.handle(request, response); + + const headerToken = setSpy.mock.calls.find((c) => c[0] === 'Lock-Token')?.[1] as string; + const token = headerToken.slice(1, -1); + const expectedXml = + '' + + 'Infinity' + + `Second-600${token}` + + '/folder/' + + ''; + expect(sendSpy.mock.calls[0]?.[0]).toBe(expectedXml); + }); + + it('should generate a unique lock token on each request', async () => { + const requestHandler = new LOCKRequestHandler(); + + const request = createWebDavRequestFixture({ + method: 'LOCK', + url: '/file.txt', + }); + const response1 = createWebDavResponseFixture({}); + const response2 = createWebDavResponseFixture({}); + const setSpy1 = vi.spyOn(response1, 'set'); + const setSpy2 = vi.spyOn(response2, 'set'); + + await requestHandler.handle(request, response1); + await requestHandler.handle(request, response2); + + const token1 = setSpy1.mock.calls.find((c) => c[0] === 'Lock-Token')?.[1]; + const token2 = setSpy2.mock.calls.find((c) => c[0] === 'Lock-Token')?.[1]; + + expect(token1).not.toBe(token2); + }); + + it('should echo back the owner from the request body', async () => { + const requestHandler = new LOCKRequestHandler(); + + const request = createWebDavRequestFixture({ + method: 'LOCK', + url: '/file.txt', + body: + '' + + 'http://example.com/users/test' + + '', + }); + const response = createWebDavResponseFixture({}); + const setSpy = vi.spyOn(response, 'set'); + const sendSpy = vi.spyOn(response, 'send'); + + await requestHandler.handle(request, response); + + const headerToken = setSpy.mock.calls.find((c) => c[0] === 'Lock-Token')?.[1] as string; + const token = headerToken.slice(1, -1); + const expectedXml = + '' + + '0' + + `Second-300${token}` + + '/file.txt' + + 'http://example.com/users/test' + + ''; + expect(sendSpy.mock.calls[0]?.[0]).toBe(expectedXml); + }); + + it('should handle request body without owner gracefully', async () => { + const requestHandler = new LOCKRequestHandler(); + + const request = createWebDavRequestFixture({ + method: 'LOCK', + url: '/file.txt', + body: 'invalid xml', + }); + const response = createWebDavResponseFixture({}); + const setSpy = vi.spyOn(response, 'set'); + const sendSpy = vi.spyOn(response, 'send'); + + await requestHandler.handle(request, response); + + const headerToken = setSpy.mock.calls.find((c) => c[0] === 'Lock-Token')?.[1] as string; + const token = headerToken.slice(1, -1); + const expectedXml = + '' + + '0' + + `Second-300${token}` + + '/file.txt' + + ''; + expect(sendSpy.mock.calls[0]?.[0]).toBe(expectedXml); + }); + + it('should return valid DAV: XML structure', async () => { + const requestHandler = new LOCKRequestHandler(); + + const request = createWebDavRequestFixture({ + method: 'LOCK', + url: '/file.txt', + }); + const response = createWebDavResponseFixture({}); + const setSpy = vi.spyOn(response, 'set'); + const sendSpy = vi.spyOn(response, 'send'); + + await requestHandler.handle(request, response); + + const headerToken = setSpy.mock.calls.find((c) => c[0] === 'Lock-Token')?.[1] as string; + const token = headerToken.slice(1, -1); + const expectedXml = + '' + + '0' + + `Second-300${token}` + + '/file.txt' + + ''; + expect(sendSpy.mock.calls[0]?.[0]).toBe(expectedXml); + }); +}); diff --git a/test/webdav/handlers/OPTIONS.handler.test.ts b/test/webdav/handlers/OPTIONS.handler.test.ts index 2fd47875..174c67da 100644 --- a/test/webdav/handlers/OPTIONS.handler.test.ts +++ b/test/webdav/handlers/OPTIONS.handler.test.ts @@ -20,7 +20,8 @@ describe('OPTIONS request handler', () => { await requestHandler.handle(request, response); expect(response.status).toHaveBeenCalledWith(200); - expect(response.header).toHaveBeenCalledWith('Allow', 'DELETE, GET, HEAD, MKCOL, MOVE, OPTIONS, PROPFIND, PUT'); + const allowHeader = 'DELETE, GET, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PROPFIND, PUT, UNLOCK'; + expect(response.header).toHaveBeenCalledWith('Allow', allowHeader); expect(response.header).toHaveBeenCalledWith('DAV', '1, 2, ordered-collections'); }); @@ -40,7 +41,8 @@ describe('OPTIONS request handler', () => { await requestHandler.handle(request, response); expect(response.status).toHaveBeenCalledWith(200); - expect(response.header).toHaveBeenCalledWith('Allow', 'DELETE, HEAD, MKCOL, MOVE, OPTIONS, PROPFIND'); + const folderAllow = 'DELETE, HEAD, LOCK, MKCOL, MOVE, OPTIONS, PROPFIND, UNLOCK'; + expect(response.header).toHaveBeenCalledWith('Allow', folderAllow); expect(response.header).toHaveBeenCalledWith('DAV', '1, 2, ordered-collections'); }); @@ -60,7 +62,8 @@ describe('OPTIONS request handler', () => { await requestHandler.handle(request, response); expect(response.status).toHaveBeenCalledWith(200); - expect(response.header).toHaveBeenCalledWith('Allow', 'DELETE, GET, HEAD, MOVE, OPTIONS, PROPFIND, PUT'); + const fileAllow = 'DELETE, GET, HEAD, LOCK, MOVE, OPTIONS, PROPFIND, PUT, UNLOCK'; + expect(response.header).toHaveBeenCalledWith('Allow', fileAllow); expect(response.header).toHaveBeenCalledWith('DAV', '1, 2, ordered-collections'); }); }); diff --git a/test/webdav/handlers/PUT.handler.test.ts b/test/webdav/handlers/PUT.handler.test.ts index a49e2068..2f722305 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(); }); @@ -171,7 +176,7 @@ describe('PUT request handler', () => { .mockResolvedValue(fileFixture.toItem()); await sut.handle(request, response); - expect(response.status).toHaveBeenCalledWith(201); + expect(response.status).toHaveBeenCalledWith(204); expect(getRequestedResourceStub).toHaveBeenCalledTimes(2); expect(getAndSearchItemFromResourceStub).toHaveBeenCalledOnce(); expect(getDriveFolderFromResourceStub).toHaveBeenCalledOnce(); diff --git a/test/webdav/handlers/UNLOCK.handler.test.ts b/test/webdav/handlers/UNLOCK.handler.test.ts new file mode 100644 index 00000000..227cc01a --- /dev/null +++ b/test/webdav/handlers/UNLOCK.handler.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; +import { UNLOCKRequestHandler } from '../../../src/webdav/handlers/UNLOCK.handler'; +import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../fixtures/webdav.fixture'; + +describe('UNLOCK request handler', () => { + it('should return 204 when a valid lock token is provided', async () => { + const requestHandler = new UNLOCKRequestHandler(); + + const request = createWebDavRequestFixture({ + method: 'UNLOCK', + url: '/file.txt', + headers: { + 'lock-token': '', + }, + }); + const response = createWebDavResponseFixture({ + status: vi.fn().mockReturnValue({ send: vi.fn() }), + }); + + await requestHandler.handle(request, response); + + expect(response.status).toHaveBeenCalledWith(204); + }); + + it('should return 204 even when no lock token header is provided', async () => { + const requestHandler = new UNLOCKRequestHandler(); + + const request = createWebDavRequestFixture({ + method: 'UNLOCK', + url: '/file.txt', + }); + const response = createWebDavResponseFixture({ + status: vi.fn().mockReturnValue({ send: vi.fn() }), + }); + + await requestHandler.handle(request, response); + + expect(response.status).toHaveBeenCalledWith(204); + }); +}); diff --git a/test/webdav/middlewares/request-logger.middleware.test.ts b/test/webdav/middlewares/request-logger.middleware.test.ts index b7e0dbf0..282e87d2 100644 --- a/test/webdav/middlewares/request-logger.middleware.test.ts +++ b/test/webdav/middlewares/request-logger.middleware.test.ts @@ -16,7 +16,7 @@ describe('Request logger middleware', () => { middleware(req, createWebDavResponseFixture({}), next); expect(infoStub).toHaveBeenCalledOnce(); - expect(infoStub).toHaveBeenCalledWith(expect.stringContaining('WebDav request received')); + expect(infoStub).toHaveBeenCalledWith(expect.stringContaining('[PROPFIND] /path - Start')); expect(next).toHaveBeenCalledOnce(); });