From 1ec1563824bc423489768debc00491b86d54e5b2 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Mon, 28 Oct 2024 16:38:58 +0100 Subject: [PATCH 01/26] added folderUuid and parentUuid properties --- .../20241018114828-add-parent-column.ts | 25 +++++++++++++++++++ .../drive-file/drive-file.attributes.ts | 1 + .../database/drive-file/drive-file.domain.ts | 4 +++ .../database/drive-file/drive-file.model.ts | 3 +++ .../drive-folder/drive-folder.attributes.ts | 1 + .../drive-folder/drive-folder.domain.ts | 15 ++++++++++- .../drive-folder/drive-folder.model.ts | 4 +++ src/services/drive/drive-file.service.ts | 1 + src/types/drive.types.ts | 3 ++- src/utils/drive.utils.ts | 3 ++- test/fixtures/drive.fixture.ts | 3 +++ 11 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 src/database/migrations/20241018114828-add-parent-column.ts diff --git a/src/database/migrations/20241018114828-add-parent-column.ts b/src/database/migrations/20241018114828-add-parent-column.ts new file mode 100644 index 00000000..1ec4b99f --- /dev/null +++ b/src/database/migrations/20241018114828-add-parent-column.ts @@ -0,0 +1,25 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface: QueryInterface, Sequelize: typeof DataTypes) { + await queryInterface.addColumn('folders', 'parent_uuid', { + type: Sequelize.UUIDV4, + references: { + model: 'folders', + key: 'uuid', + }, + }); + await queryInterface.addColumn('files', 'folder_uuid', { + type: Sequelize.UUIDV4, + references: { + model: 'files', + key: 'uuid', + }, + }); + }, + async down(queryInterface: QueryInterface) { + await queryInterface.removeColumn('folders', 'parent_uuid'); + await queryInterface.removeColumn('files', 'folder_uuid'); + }, +}; diff --git a/src/services/database/drive-file/drive-file.attributes.ts b/src/services/database/drive-file/drive-file.attributes.ts index cc51a2da..bcdcf132 100644 --- a/src/services/database/drive-file/drive-file.attributes.ts +++ b/src/services/database/drive-file/drive-file.attributes.ts @@ -5,6 +5,7 @@ export interface DriveFileAttributes { uuid: string; fileId: string; folderId: number; + folderUuid: string; bucket: string; relativePath: string; createdAt: Date; diff --git a/src/services/database/drive-file/drive-file.domain.ts b/src/services/database/drive-file/drive-file.domain.ts index 6695bebb..4c393956 100644 --- a/src/services/database/drive-file/drive-file.domain.ts +++ b/src/services/database/drive-file/drive-file.domain.ts @@ -7,6 +7,7 @@ export class DriveFile implements DriveFileAttributes { uuid: string; fileId: string; folderId: number; + folderUuid: string; bucket: string; relativePath: string; createdAt: Date; @@ -21,6 +22,7 @@ export class DriveFile implements DriveFileAttributes { uuid, fileId, folderId, + folderUuid, bucket, relativePath, createdAt, @@ -34,6 +36,7 @@ export class DriveFile implements DriveFileAttributes { this.uuid = uuid; this.fileId = fileId; this.folderId = folderId; + this.folderUuid = folderUuid; this.bucket = bucket; this.relativePath = relativePath; this.createdAt = createdAt; @@ -54,6 +57,7 @@ export class DriveFile implements DriveFileAttributes { uuid: this.uuid, fileId: this.fileId, folderId: this.folderId, + folderUuid: this.folderUuid, bucket: this.bucket, relativePath: this.relativePath, createdAt: this.createdAt, diff --git a/src/services/database/drive-file/drive-file.model.ts b/src/services/database/drive-file/drive-file.model.ts index 856630f9..27e9a7cc 100644 --- a/src/services/database/drive-file/drive-file.model.ts +++ b/src/services/database/drive-file/drive-file.model.ts @@ -39,6 +39,9 @@ export class DriveFileModel extends Model implements DriveFileAttributes { @Column(DataType.INTEGER) declare folderId: number; + @Column(DataType.UUIDV4) + declare folderUuid: string; + @Column(DataType.STRING(24)) declare bucket: string; diff --git a/src/services/database/drive-folder/drive-folder.attributes.ts b/src/services/database/drive-folder/drive-folder.attributes.ts index 8160b271..83f73833 100644 --- a/src/services/database/drive-folder/drive-folder.attributes.ts +++ b/src/services/database/drive-folder/drive-folder.attributes.ts @@ -5,6 +5,7 @@ export interface DriveFolderAttributes { status: 'EXISTS' | 'TRASHED'; relativePath: string; parentId: number | null; + parentUuid: string | null; createdAt: Date; updatedAt: Date; } diff --git a/src/services/database/drive-folder/drive-folder.domain.ts b/src/services/database/drive-folder/drive-folder.domain.ts index 659afb4b..0643aac8 100644 --- a/src/services/database/drive-folder/drive-folder.domain.ts +++ b/src/services/database/drive-folder/drive-folder.domain.ts @@ -6,16 +6,28 @@ export class DriveFolder implements DriveFolderAttributes { uuid: string; relativePath: string; parentId: number | null; + parentUuid: string | null; createdAt: Date; updatedAt: Date; status: DriveFolderAttributes['status']; - constructor({ id, name, uuid, relativePath, parentId, createdAt, updatedAt, status }: DriveFolderAttributes) { + constructor({ + id, + name, + uuid, + relativePath, + parentId, + parentUuid, + createdAt, + updatedAt, + status, + }: DriveFolderAttributes) { this.id = id; this.name = name; this.uuid = uuid; this.relativePath = relativePath; this.parentId = parentId; + this.parentUuid = parentUuid; this.createdAt = createdAt; this.updatedAt = updatedAt; this.status = status; @@ -33,6 +45,7 @@ export class DriveFolder implements DriveFolderAttributes { status: this.status, relativePath: this.relativePath, parentId: this.parentId, + parentUuid: this.parentUuid, createdAt: this.createdAt, updatedAt: this.updatedAt, }; diff --git a/src/services/database/drive-folder/drive-folder.model.ts b/src/services/database/drive-folder/drive-folder.model.ts index fda53bc3..02d76de1 100644 --- a/src/services/database/drive-folder/drive-folder.model.ts +++ b/src/services/database/drive-folder/drive-folder.model.ts @@ -39,6 +39,10 @@ export class DriveFolderModel extends Model implements DriveFolderAttributes { @Column(DataType.INTEGER) declare parentId: number | null; + @AllowNull + @Column(DataType.UUIDV4) + declare parentUuid: string | null; + @Column(DataType.DATE) declare createdAt: Date; diff --git a/src/services/drive/drive-file.service.ts b/src/services/drive/drive-file.service.ts index d6484229..d1d4e4b3 100644 --- a/src/services/drive/drive-file.service.ts +++ b/src/services/drive/drive-file.service.ts @@ -48,6 +48,7 @@ export class DriveFileService { type: payload.type, status: driveFile.status, folderId: driveFile.folderId, + folderUuid: driveFile.folderUuid, }; }; diff --git a/src/types/drive.types.ts b/src/types/drive.types.ts index 67b54070..2ba3bbd6 100644 --- a/src/types/drive.types.ts +++ b/src/types/drive.types.ts @@ -2,7 +2,7 @@ import { DriveFileData, DriveFolderData } from '@internxt/sdk/dist/drive/storage export type DriveFileItem = Pick< DriveFileData, - 'name' | 'bucket' | 'fileId' | 'id' | 'uuid' | 'folderId' | 'status' + 'name' | 'bucket' | 'fileId' | 'id' | 'uuid' | 'folderId' | 'status' | 'folderUuid' > & { encryptedName: string; size: number; @@ -17,4 +17,5 @@ export type DriveFolderItem = Pick): DriveFolde createdAt: new Date(), updatedAt: new Date(), status: 'EXISTS', + parentUuid: randomUUID(), }; return { ...folder, ...attributes }; }; @@ -42,6 +43,7 @@ export const newFileItem = (attributes?: Partial): DriveFileItem size: randomInt(1, 10000), type: fileTypes[randomInt(fileTypes.length)], status: FileStatus.EXISTS, + folderUuid: randomUUID(), }; return { ...file, ...attributes }; }; @@ -66,6 +68,7 @@ export const newFolderMeta = (attributes?: Partial): FolderMeta => { user: null, userId: randomInt(1, 100000), uuid: randomUUID(), + parentUuid: randomUUID(), }; return { ...folder, ...attributes }; }; From 5b6bc454f86ad419eef850d6fa69f594dfbaf719 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 29 Oct 2024 17:47:41 +0100 Subject: [PATCH 02/26] updated and fixed rename operation --- src/commands/rename.ts | 2 +- src/services/drive/drive-file.service.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/rename.ts b/src/commands/rename.ts index d6c8d6de..f0db26c1 100644 --- a/src/commands/rename.ts +++ b/src/commands/rename.ts @@ -68,7 +68,7 @@ export default class Rename extends Command { if (isFolder) { await DriveFolderService.instance.renameFolder({ folderUuid: item.uuid, name: newName }); } else { - await DriveFileService.instance.renameFile({ fileUuid: item.uuid, name: newName }); + await DriveFileService.instance.renameFile(item.uuid, { plainName: newName }); } CLIUtils.success(`${isFolder ? 'Folder' : 'File'} renamed successfully with: ${newName}`); } diff --git a/src/services/drive/drive-file.service.ts b/src/services/drive/drive-file.service.ts index d1d4e4b3..e285a96a 100644 --- a/src/services/drive/drive-file.service.ts +++ b/src/services/drive/drive-file.service.ts @@ -66,8 +66,8 @@ export class DriveFileService { return storageClient.moveFileByUuid(payload); }; - public renameFile = (payload: { fileUuid: string; name: string }): Promise => { + public renameFile = (fileUuid: string, payload: { plainName?: string; type?: string | null }): Promise => { const storageClient = SdkManager.instance.getStorage(true); - return storageClient.updateFileNameWithUUID(payload); + return storageClient.updateFileMetaWithUUID(fileUuid, payload); }; } From 0ac245e6bfb52c37a7797f8a5873efdc454caff6 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 29 Oct 2024 17:55:56 +0100 Subject: [PATCH 03/26] added toItem type conversion utility --- .../database/drive-file/drive-file.domain.ts | 19 +++++++++++++++++++ .../drive-folder/drive-folder.domain.ts | 16 ++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/services/database/drive-file/drive-file.domain.ts b/src/services/database/drive-file/drive-file.domain.ts index 4c393956..3e5339a8 100644 --- a/src/services/database/drive-file/drive-file.domain.ts +++ b/src/services/database/drive-file/drive-file.domain.ts @@ -1,3 +1,4 @@ +import { DriveFileItem } from '../../../types/drive.types'; import { DriveFileAttributes } from './drive-file.attributes'; export class DriveFile implements DriveFileAttributes { @@ -66,4 +67,22 @@ export class DriveFile implements DriveFileAttributes { status: this.status, }; } + + public toItem(): DriveFileItem { + return { + id: this.id, + name: this.name, + type: this.type, + uuid: this.uuid, + fileId: this.fileId, + folderId: this.folderId, + folderUuid: this.folderUuid, + bucket: this.bucket, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + size: this.size, + status: this.status, + encryptedName: '', + }; + } } diff --git a/src/services/database/drive-folder/drive-folder.domain.ts b/src/services/database/drive-folder/drive-folder.domain.ts index 0643aac8..00a74af0 100644 --- a/src/services/database/drive-folder/drive-folder.domain.ts +++ b/src/services/database/drive-folder/drive-folder.domain.ts @@ -1,3 +1,4 @@ +import { DriveFolderItem } from '../../../types/drive.types'; import { DriveFolderAttributes } from './drive-folder.attributes'; export class DriveFolder implements DriveFolderAttributes { @@ -50,4 +51,19 @@ export class DriveFolder implements DriveFolderAttributes { updatedAt: this.updatedAt, }; } + + public toItem(): DriveFolderItem { + return { + id: this.id, + name: this.name, + uuid: this.uuid, + status: this.status, + parentId: this.parentId, + parentUuid: this.parentUuid, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + encryptedName: '', + bucket: null, + }; + } } From 882643be19d37282e42c4b638090c59c10218d37 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 29 Oct 2024 18:06:23 +0100 Subject: [PATCH 04/26] forced database reset on every init --- src/services/database/drive-database-manager.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/database/drive-database-manager.service.ts b/src/services/database/drive-database-manager.service.ts index 45596f76..887512e4 100644 --- a/src/services/database/drive-database-manager.service.ts +++ b/src/services/database/drive-database-manager.service.ts @@ -23,7 +23,7 @@ export class DriveDatabaseManager { ) {} static readonly init = async () => { - await DriveDatabaseManager.sequelize.sync(); + await DriveDatabaseManager.sequelize.sync({ force: true }); }; static readonly clean = async () => { From 8e85bad5b2952efd5fd37bdb79eb3a589fb5be27 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 29 Oct 2024 18:11:28 +0100 Subject: [PATCH 05/26] clean database on every start --- src/commands/webdav.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/commands/webdav.ts b/src/commands/webdav.ts index 6f20d01e..09d631fc 100644 --- a/src/commands/webdav.ts +++ b/src/commands/webdav.ts @@ -5,6 +5,7 @@ import { ConfigService } from '../services/config.service'; import { AnalyticsService } from '../services/analytics.service'; import { AuthService } from '../services/auth.service'; import { DriveDatabaseManager } from '../services/database/drive-database-manager.service'; + export default class Webdav extends Command { static readonly description = 'Enable, disable, restart or get the status of the Internxt CLI WebDav server'; @@ -23,8 +24,10 @@ export default class Webdav extends Command { options: ['enable', 'disable', 'restart', 'status'], }), }; + public async enableWebDav() { CLIUtils.doing('Starting Internxt WebDav server...'); + await DriveDatabaseManager.clean(); await PM2Utils.connect(); await PM2Utils.killWebDavServer(); await PM2Utils.startWebDavServer(); @@ -36,6 +39,9 @@ export default class Webdav extends Command { CLIUtils.success( `Internxt WebDav server started successfully on https://${ConfigService.WEBDAV_LOCAL_URL}:${process.env.WEBDAV_SERVER_PORT}`, ); + ux.log( + `\n[If the above URL is not working, the WebDAV server can be accessed directly via the localhost IP at: https://127.0.0.1:${process.env.WEBDAV_SERVER_PORT} ]\n`, + ); const authDetails = await AuthService.instance.getAuthDetails(); await AnalyticsService.instance.track('WebDAVEnabled', { app: 'internxt-cli', userId: authDetails.user.uuid }); } else { From 6f4f0bf2b947c49397e7d7fc9cf9941eb31b3d07 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 29 Oct 2024 18:13:28 +0100 Subject: [PATCH 06/26] improved webdav resource management via webdav utils --- src/types/webdav.types.ts | 1 + src/utils/drive.utils.ts | 1 + src/utils/webdav.utils.ts | 120 ++++++++++++++++++++++++++------ test/utils/webdav.utils.test.ts | 4 +- 4 files changed, 102 insertions(+), 24 deletions(-) diff --git a/src/types/webdav.types.ts b/src/types/webdav.types.ts index 49f47066..f24fda6f 100644 --- a/src/types/webdav.types.ts +++ b/src/types/webdav.types.ts @@ -14,4 +14,5 @@ export type WebDavRequestedResource = { url: string; name: string; path: ParsedPath; + parentPath: string; }; diff --git a/src/utils/drive.utils.ts b/src/utils/drive.utils.ts index 00e9e379..361071fd 100644 --- a/src/utils/drive.utils.ts +++ b/src/utils/drive.utils.ts @@ -6,6 +6,7 @@ export class DriveUtils { return { uuid: fileMeta.uuid ?? '', status: fileMeta.status, + folderId: fileMeta.folderId ?? fileMeta.folder_id, folderUuid: fileMeta.folderUuid, size: fileMeta.size, encryptedName: fileMeta.name, diff --git a/src/utils/webdav.utils.ts b/src/utils/webdav.utils.ts index 7a72e579..20764f11 100644 --- a/src/utils/webdav.utils.ts +++ b/src/utils/webdav.utils.ts @@ -1,45 +1,52 @@ import { Request } from 'express'; import path from 'path'; import { WebDavRequestedResource } from '../types/webdav.types'; -import { DriveDatabaseManager } from '../services/database/drive-database-manager.service'; -import { DriveFolder } from '../services/database/drive-folder/drive-folder.domain'; import { DriveFile } from '../services/database/drive-file/drive-file.domain'; +import { DriveFolder } from '../services/database/drive-folder/drive-folder.domain'; +import { DriveDatabaseManager } from '../services/database/drive-database-manager.service'; +import { DriveFolderService } from '../services/drive/drive-folder.service'; +import { DriveFileService } from '../services/drive/drive-file.service'; +import { DriveFileItem, DriveFolderItem } from '../types/drive.types'; +import { NotFoundError } from './errors.utils'; +import { webdavLogger } from './logger.utils'; export class WebDavUtils { static joinURL(...pathComponents: string[]): string { return path.posix.join(...pathComponents); } - static getParentPath(fromPath: string): string { - return path.dirname(fromPath); + static removeHostFromURL(completeURL: string) { + // add a temp http schema if its not present + if (!completeURL.startsWith('/') && !/^http[s]?:\/\//i.test(completeURL)) { + completeURL = 'http://' + completeURL; + } + + const parsedUrl = new URL(completeURL); + let url = parsedUrl.href.replace(parsedUrl.origin + '/', ''); + if (!url.startsWith('/')) url = '/'.concat(url); + return url; } - static async getRequestedResource( - req: Request, - driveDatabaseManager: DriveDatabaseManager, - ): Promise { - const decodedUrl = decodeURIComponent(req.url); + static async getRequestedResource(urlObject: string | Request): Promise { + let requestUrl: string; + if (typeof urlObject === 'string') { + requestUrl = urlObject; + } else { + requestUrl = urlObject.url; + } + const decodedUrl = decodeURIComponent(requestUrl); const parsedPath = path.parse(decodedUrl); + const parentPath = `/${path.dirname(decodedUrl).replace(/^\/|\/$/g, '')}/`.replaceAll('//', '/'); - let isFolder = req.url.endsWith('/'); - - if (!isFolder) { - const findDatabaseItem = await driveDatabaseManager.findByRelativePath(decodedUrl); - if (findDatabaseItem) { - if (findDatabaseItem instanceof DriveFile) { - isFolder = false; - } else if (findDatabaseItem instanceof DriveFolder) { - isFolder = true; - } - } - } + const isFolder = requestUrl.endsWith('/'); if (isFolder) { return { - url: decodedUrl, type: 'folder', + url: decodedUrl, name: parsedPath.base, path: parsedPath, + parentPath, }; } else { return { @@ -47,7 +54,76 @@ export class WebDavUtils { url: decodedUrl, name: parsedPath.name, path: parsedPath, + parentPath, }; } } + + static async getDatabaseItemFromResource( + resource: WebDavRequestedResource, + driveDatabaseManager: DriveDatabaseManager, + ): Promise { + let databaseResource: DriveFile | DriveFolder | null = null; + if (resource.type === 'folder') { + databaseResource = await driveDatabaseManager.findFolderByRelativePath(resource.url); + } + if (resource.type === 'file') { + databaseResource = await driveDatabaseManager.findFileByRelativePath(resource.url); + } + return databaseResource?.toItem() ?? null; + } + + static async setDatabaseItem( + type: 'file' | 'folder', + driveItem: DriveFileItem | DriveFolderItem, + driveDatabaseManager: DriveDatabaseManager, + relativePath: string, + ): Promise { + if (type === 'folder') { + return await driveDatabaseManager.createFolder(driveItem as DriveFolderItem, relativePath); + } + if (type === 'file') { + return await driveDatabaseManager.createFile(driveItem as DriveFileItem, relativePath); + } + } + + static async getDriveItemFromResource( + resource: WebDavRequestedResource, + driveFolderService?: DriveFolderService, + driveFileService?: DriveFileService, + ): Promise { + let item: DriveFileItem | DriveFolderItem | undefined = undefined; + if (resource.type === 'folder') { + item = await driveFolderService?.getFolderMetadataByPath(resource.url); + } + if (resource.type === 'file') { + item = await driveFileService?.getFileMetadataByPath(resource.url); + } + return item; + } + + static async getAndSearchItemFromResource({ + resource, + driveDatabaseManager, + driveFolderService, + driveFileService, + }: { + resource: WebDavRequestedResource; + driveDatabaseManager: DriveDatabaseManager; + driveFolderService?: DriveFolderService; + driveFileService?: DriveFileService; + }): Promise { + let databaseItem = await this.getDatabaseItemFromResource(resource, driveDatabaseManager); + + if (!databaseItem) { + webdavLogger.info('Resource not found on local database', { resource }); + const driveItem = await this.getDriveItemFromResource(resource, driveFolderService, driveFileService); + if (!driveItem) { + throw new NotFoundError(`Resource not found on Internxt Drive at ${resource.url}`); + } + databaseItem = driveItem; + await this.setDatabaseItem(resource.type, driveItem, driveDatabaseManager, resource.url); + } + return databaseItem; + } } diff --git a/test/utils/webdav.utils.test.ts b/test/utils/webdav.utils.test.ts index f0578b9b..6305b085 100644 --- a/test/utils/webdav.utils.test.ts +++ b/test/utils/webdav.utils.test.ts @@ -1,7 +1,6 @@ import { expect } from 'chai'; import { WebDavUtils } from '../../src/utils/webdav.utils'; import { createWebDavRequestFixture } from '../fixtures/webdav.fixture'; -import { getDriveDatabaseManager } from '../fixtures/drive-database.fixture'; describe('Webdav utils', () => { it('When a list of path components are given, should generate a correct href', () => { @@ -18,11 +17,12 @@ describe('Webdav utils', () => { const request = createWebDavRequestFixture({ url: '/url/to/folder/', }); - const resource = await WebDavUtils.getRequestedResource(request, getDriveDatabaseManager()); + const resource = await WebDavUtils.getRequestedResource(request); expect(resource).to.deep.equal({ url: '/url/to/folder/', type: 'folder', name: 'folder', + parentPath: '/url/to/', path: { base: 'folder', dir: '/url/to', From f2e7120fd3d7a375896a07574c8d20b97cb2afa7 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Wed, 30 Oct 2024 09:50:12 +0100 Subject: [PATCH 07/26] added get item by path functionality --- .../drive-database-manager.service.ts | 63 +++++++++++-------- src/services/drive/drive-file.service.ts | 6 ++ src/services/drive/drive-folder.service.ts | 13 ++++ test/fixtures/drive.fixture.ts | 11 ++++ 4 files changed, 66 insertions(+), 27 deletions(-) diff --git a/src/services/database/drive-database-manager.service.ts b/src/services/database/drive-database-manager.service.ts index 887512e4..1d2040fd 100644 --- a/src/services/database/drive-database-manager.service.ts +++ b/src/services/database/drive-database-manager.service.ts @@ -4,10 +4,10 @@ import { WebDavUtils } from '../../utils/webdav.utils'; import { ConfigService } from '../config.service'; import { DriveFileRepository } from './drive-file/drive-file.repository'; import { DriveFolderRepository } from './drive-folder/drive-folder.repository'; -import { DriveFile } from './drive-file/drive-file.domain'; -import { DriveFolder } from './drive-folder/drive-folder.domain'; import DriveFileModel from './drive-file/drive-file.model'; import DriveFolderModel from './drive-folder/drive-folder.model'; +import { DriveFile } from './drive-file/drive-file.domain'; +import { DriveFolder } from './drive-folder/drive-folder.domain'; export class DriveDatabaseManager { private static readonly sequelize: Sequelize = new Sequelize({ @@ -31,37 +31,28 @@ export class DriveDatabaseManager { await DriveFolderRepository.clean(); }; - findByRelativePath = async (relativePath: string): Promise => { + findFileByRelativePath = async (relativePath: string): Promise => { const driveFile = await this.driveFileRepository.findByRelativePath(relativePath); + return driveFile ?? null; + }; - if (driveFile) return driveFile; + findFolderByRelativePath = async (relativePath: string): Promise => { + const folderRelativePath = relativePath.endsWith('/') ? relativePath : relativePath.concat('/'); - let folderRelativePath = relativePath; - if (!relativePath.endsWith('/')) folderRelativePath = relativePath.concat('/'); const driveFolder = await this.driveFolderRepository.findByRelativePath(folderRelativePath); - - if (driveFolder) return driveFolder; - - return null; + return driveFolder ?? null; }; - createFolder = async (driveFolder: DriveFolderItem) => { - const relativePath = await this.buildRelativePathForFolder(driveFolder.name, driveFolder.parentId ?? null); - - const existingObject = await this.driveFolderRepository.findById(driveFolder.id); - if (existingObject) await this.driveFolderRepository.deleteById(existingObject.id); + createFolder = async (driveFolder: DriveFolderItem, relativePath: string) => { + await this.deleteFolderById(driveFolder.id); + await this.deleteFolderByPath(relativePath); return await this.driveFolderRepository.createFolder(driveFolder, relativePath); }; - createFile = async (driveFile: DriveFileItem) => { - const relativePath = await this.buildRelativePathForFile( - driveFile.type ? `${driveFile.name}.${driveFile.type}` : driveFile.name, - driveFile.folderId, - ); - - const existingObject = await this.driveFileRepository.findById(driveFile.id); - if (existingObject) await this.driveFileRepository.deleteById(existingObject.id); + createFile = async (driveFile: DriveFileItem, relativePath: string) => { + await this.deleteFileById(driveFile.id); + await this.deleteFileByPath(relativePath); return await this.driveFileRepository.createFile(driveFile, relativePath); }; @@ -90,11 +81,29 @@ export class DriveDatabaseManager { return WebDavUtils.joinURL(parentPath, folderName, '/'); }; - deleteFile = (id: number): Promise => { - return this.driveFileRepository.deleteById(id); + deleteFileById = async (id: number): Promise => { + const existingObject = await this.driveFileRepository.findById(id); + if (existingObject) { + await this.driveFileRepository.deleteById(existingObject.id); + } + }; + + deleteFolderById = async (id: number): Promise => { + const existingObject = await this.driveFolderRepository.findById(id); + if (existingObject) { + await this.driveFolderRepository.deleteById(existingObject.id); + } + }; + + deleteFileByPath = async (relativePath: string): Promise => { + const existingPath = await this.driveFileRepository.findByRelativePath(relativePath); + if (existingPath) await this.driveFileRepository.deleteById(existingPath.id); }; - deleteFolder = (id: number): Promise => { - return this.driveFolderRepository.deleteById(id); + deleteFolderByPath = async (relativePath: string): Promise => { + const existingPath = await this.driveFolderRepository.findByRelativePath(relativePath); + if (existingPath) { + await this.driveFolderRepository.deleteById(existingPath.id); + } }; } diff --git a/src/services/drive/drive-file.service.ts b/src/services/drive/drive-file.service.ts index e285a96a..0721b1aa 100644 --- a/src/services/drive/drive-file.service.ts +++ b/src/services/drive/drive-file.service.ts @@ -70,4 +70,10 @@ export class DriveFileService { const storageClient = SdkManager.instance.getStorage(true); return storageClient.updateFileMetaWithUUID(fileUuid, payload); }; + + public getFileMetadataByPath = async (path: string): Promise => { + const storageClient = SdkManager.instance.getStorage(true); + const fileMetadata = await storageClient.getFileByPath(encodeURIComponent(path)); + return DriveUtils.driveFileMetaToItem(fileMetadata); + }; } diff --git a/src/services/drive/drive-folder.service.ts b/src/services/drive/drive-folder.service.ts index b1a6354b..513bde43 100644 --- a/src/services/drive/drive-folder.service.ts +++ b/src/services/drive/drive-folder.service.ts @@ -85,4 +85,17 @@ export class DriveFolderService { const storageClient = SdkManager.instance.getStorage(true); return storageClient.updateFolderNameWithUUID(payload); }; + + public getFolderMetadataByPath = async (path: string): Promise => { + const storageClient = SdkManager.instance.getStorage(true); + const folderMeta = await storageClient.getFolderByPath(encodeURIComponent(path)); + return DriveUtils.driveFolderMetaToItem({ + ...folderMeta, + createdAt: folderMeta.createdAt ?? folderMeta.created_at, + updatedAt: folderMeta.updatedAt ?? folderMeta.updated_at, + plainName: folderMeta.plainName ?? folderMeta.plain_name, + parentId: folderMeta.parentId ?? folderMeta.parent_id, + parentUuid: folderMeta.parentUuid ?? folderMeta.parent_uuid, + }); + }; } diff --git a/test/fixtures/drive.fixture.ts b/test/fixtures/drive.fixture.ts index a3f50f19..f19eb54e 100644 --- a/test/fixtures/drive.fixture.ts +++ b/test/fixtures/drive.fixture.ts @@ -52,23 +52,34 @@ export const newFolderMeta = (attributes?: Partial): FolderMeta => { const folder: FolderMeta = { bucket: crypto.randomBytes(16).toString('hex'), createdAt: new Date().toString(), + created_at: new Date().toString(), deleted: false, deletedAt: null, + deleted_at: null, encryptVersion: EncryptionVersion.Aes03, + encrypt_version: EncryptionVersion.Aes03, id: randomInt(1, 100000), name: crypto.randomBytes(16).toString('hex'), parent: null, parentId: randomInt(1, 100000), + parent_id: randomInt(1, 100000), plainName: wordlist[randomInt(wordlist.length)], + plain_name: wordlist[randomInt(wordlist.length)], removed: false, removedAt: null, + removed_at: null, size: randomInt(1, 10000), type: 'folder', updatedAt: new Date().toString(), + updated_at: new Date().toString(), user: null, userId: randomInt(1, 100000), + user_id: randomInt(1, 100000), uuid: randomUUID(), parentUuid: randomUUID(), + parent_uuid: randomUUID(), + creation_time: new Date().toString(), + modification_time: new Date().toString(), }; return { ...folder, ...attributes }; }; From ef6bde00eb48efd92e0e3983b298ef1c7755a91e Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Wed, 30 Oct 2024 13:59:33 +0100 Subject: [PATCH 08/26] added move handler for both renaming and moving items --- src/webdav/handlers/MOVE.handler.ts | 98 +++++++++++++++++++++++++++-- src/webdav/webdav-server.ts | 17 ++++- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/src/webdav/handlers/MOVE.handler.ts b/src/webdav/handlers/MOVE.handler.ts index 73910aff..51007ba8 100644 --- a/src/webdav/handlers/MOVE.handler.ts +++ b/src/webdav/handlers/MOVE.handler.ts @@ -1,8 +1,98 @@ +import { Request, Response } from 'express'; +import { DriveDatabaseManager } from '../../services/database/drive-database-manager.service'; +import { DriveFileService } from '../../services/drive/drive-file.service'; +import { DriveFolderService } from '../../services/drive/drive-folder.service'; import { WebDavMethodHandler } from '../../types/webdav.types'; -import { NotImplementedError } from '../../utils/errors.utils'; +import { ConflictError, NotFoundError } from '../../utils/errors.utils'; +import { webdavLogger } from '../../utils/logger.utils'; +import { WebDavUtils } from '../../utils/webdav.utils'; +import { DriveFileItem, DriveFolderItem } from '../../types/drive.types'; export class MOVERequestHandler implements WebDavMethodHandler { - async handle() { - throw new NotImplementedError('MOVE is not implemented yet.'); - } + constructor( + private dependencies: { + driveDatabaseManager: DriveDatabaseManager; + driveFolderService: DriveFolderService; + driveFileService: DriveFileService; + }, + ) {} + + handle = async (req: Request, res: Response) => { + const { driveDatabaseManager, driveFolderService, driveFileService } = this.dependencies; + const resource = await WebDavUtils.getRequestedResource(req); + + webdavLogger.info('[MOVE] Resource found', { resource }); + + const destinationUrl = req.header('destination'); + if (!destinationUrl) { + throw new NotFoundError('Destination folder not received'); + } + const destinationPath = WebDavUtils.removeHostFromURL(destinationUrl); + const destinationResource = await WebDavUtils.getRequestedResource(destinationPath); + + webdavLogger.info('[MOVE] Destination resource found', { destinationResource }); + + const originalDriveItem = await WebDavUtils.getAndSearchItemFromResource({ + resource, + driveDatabaseManager, + driveFolderService, + driveFileService, + }); + + if (destinationResource.path.dir === resource.path.dir) { + // RENAME (the operation is from the same dir) + webdavLogger.info( + `[MOVE] Renaming ${resource.type} with UUID ${originalDriveItem.uuid} to ${destinationResource.name}`, + ); + const newName = destinationResource.name; + + if (resource.type === 'folder') { + const folder = originalDriveItem as DriveFolderItem; + await driveFolderService.renameFolder({ + folderUuid: folder.uuid, + name: newName, + }); + await driveDatabaseManager.createFolder(folder, destinationResource.url); + } else if (resource.type === 'file') { + const newType = destinationResource.path.ext.trim().length > 0 ? destinationResource.path.ext : null; + const file = originalDriveItem as DriveFileItem; + await driveFileService.renameFile(file.uuid, { + plainName: newName, + type: newType, + }); + await driveDatabaseManager.createFile(file, destinationResource.url); + } + } else { + // MOVE (the operation is from different dirs) + webdavLogger.info(`[MOVE] Moving ${resource.type} with UUID ${originalDriveItem.uuid} to ${destinationPath}`); + const destinationFolderResource = await WebDavUtils.getRequestedResource(destinationResource.parentPath); + + const destinationFolderItem = (await WebDavUtils.getAndSearchItemFromResource({ + resource: destinationFolderResource, + driveDatabaseManager, + driveFolderService, + })) as DriveFolderItem; + if (!destinationFolderItem) { + throw new ConflictError(`Destination folder resource not found for path ${destinationResource.parentPath}`); + } + + if (resource.type === 'folder') { + const folder = originalDriveItem as DriveFolderItem; + await driveFolderService.moveFolder({ + folderUuid: folder.uuid, + destinationFolderUuid: destinationFolderItem.uuid, + }); + await driveDatabaseManager.createFolder(folder, destinationPath); + } else if (resource.type === 'file') { + const file = originalDriveItem as DriveFileItem; + await driveFileService.moveFile({ + fileUuid: file.uuid, + destinationFolderUuid: destinationFolderItem.uuid, + }); + await driveDatabaseManager.createFile(file, destinationPath); + } + } + + res.status(204).send(); + }; } diff --git a/src/webdav/webdav-server.ts b/src/webdav/webdav-server.ts index 5e9ba774..788dbec2 100644 --- a/src/webdav/webdav-server.ts +++ b/src/webdav/webdav-server.ts @@ -55,6 +55,7 @@ export class WebDavServer { return new NetworkFacade(networkModule, this.uploadService, this.downloadService, this.cryptoService); } + private registerMiddlewares = async () => { this.app.use(bodyParser.text({ type: ['application/xml', 'text/xml'] })); this.app.use(ErrorHandlingMiddleware); @@ -62,7 +63,7 @@ export class WebDavServer { this.app.use( RequestLoggerMiddleware( { - enable: false, + enable: true, }, AnalyticsService.instance, ), @@ -93,6 +94,7 @@ export class WebDavServer { new PROPFINDRequestHandler( { debug: true }, { + driveFileService: this.driveFileService, driveFolderService: this.driveFolderService, driveDatabaseManager: this.driveDatabaseManager, }, @@ -130,11 +132,22 @@ export class WebDavServer { new DELETERequestHandler({ driveDatabaseManager: this.driveDatabaseManager, trashService: this.trashService, + driveFileService: this.driveFileService, + driveFolderService: this.driveFolderService, }).handle, ), ); this.app.proppatch('*', asyncHandler(new PROPPATCHRequestHandler().handle)); - this.app.move('*', asyncHandler(new MOVERequestHandler().handle)); + this.app.move( + '*', + asyncHandler( + new MOVERequestHandler({ + driveDatabaseManager: this.driveDatabaseManager, + driveFolderService: this.driveFolderService, + driveFileService: this.driveFileService, + }).handle, + ), + ); this.app.copy('*', asyncHandler(new COPYRequestHandler().handle)); }; From 75a20149aba1b36cba2954cfac772efb25378326 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Wed, 30 Oct 2024 19:36:18 +0100 Subject: [PATCH 09/26] fixed and improved DELETE handler and tests --- src/webdav/handlers/DELETE.handler.ts | 40 +++++--- test/webdav/handlers/DELETE.handler.test.ts | 101 +++++++++++++++----- 2 files changed, 101 insertions(+), 40 deletions(-) diff --git a/src/webdav/handlers/DELETE.handler.ts b/src/webdav/handlers/DELETE.handler.ts index 1e2f2da3..c8b7db41 100644 --- a/src/webdav/handlers/DELETE.handler.ts +++ b/src/webdav/handlers/DELETE.handler.ts @@ -1,32 +1,44 @@ import { Request, Response } from 'express'; import { WebDavMethodHandler } from '../../types/webdav.types'; -import { NotFoundError } from '../../utils/errors.utils'; import { WebDavUtils } from '../../utils/webdav.utils'; import { DriveDatabaseManager } from '../../services/database/drive-database-manager.service'; import { TrashService } from '../../services/drive/trash.service'; import { webdavLogger } from '../../utils/logger.utils'; +import { DriveFileService } from '../../services/drive/drive-file.service'; +import { DriveFolderService } from '../../services/drive/drive-folder.service'; export class DELETERequestHandler implements WebDavMethodHandler { - constructor(private dependencies: { driveDatabaseManager: DriveDatabaseManager; trashService: TrashService }) {} + constructor( + private dependencies: { + driveDatabaseManager: DriveDatabaseManager; + trashService: TrashService; + driveFileService: DriveFileService; + driveFolderService: DriveFolderService; + }, + ) {} + handle = async (req: Request, res: Response) => { + const { driveDatabaseManager, driveFileService, driveFolderService, trashService } = this.dependencies; webdavLogger.info('DELETE request received'); - const resource = await WebDavUtils.getRequestedResource(req, this.dependencies.driveDatabaseManager); - webdavLogger.info('Resource found for DELETE request', { resource }); - const databaseItem = await this.dependencies.driveDatabaseManager.findByRelativePath(resource.url); + const resource = await WebDavUtils.getRequestedResource(req); + webdavLogger.info('Resource received for DELETE request', { resource }); - if (!databaseItem) throw new NotFoundError('Resource not found'); + const driveItem = await WebDavUtils.getAndSearchItemFromResource({ + resource, + driveDatabaseManager, + driveFolderService, + driveFileService: driveFileService, + }); - webdavLogger.info(`Trashing ${resource.type} with UUID ${databaseItem.uuid}...`); - await this.dependencies.trashService.trashItems({ - items: [{ type: resource.type, uuid: databaseItem.uuid }], + webdavLogger.info(`Trashing ${resource.type} with UUID ${driveItem.uuid}...`); + await trashService.trashItems({ + items: [{ type: resource.type, uuid: driveItem.uuid }], }); if (resource.type === 'folder') { - await this.dependencies.driveDatabaseManager.deleteFolder(databaseItem.id); - } - - if (resource.type === 'file') { - await this.dependencies.driveDatabaseManager.deleteFile(databaseItem.id); + await driveDatabaseManager.deleteFolderById(driveItem.id); + } else if (resource.type === 'file') { + await driveDatabaseManager.deleteFileById(driveItem.id); } res.status(204).send(); diff --git a/test/webdav/handlers/DELETE.handler.test.ts b/test/webdav/handlers/DELETE.handler.test.ts index c1c4585b..df629ef2 100644 --- a/test/webdav/handlers/DELETE.handler.test.ts +++ b/test/webdav/handlers/DELETE.handler.test.ts @@ -1,72 +1,106 @@ import sinon from 'sinon'; import { DELETERequestHandler } from '../../../src/webdav/handlers/DELETE.handler'; import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../fixtures/webdav.fixture'; -import { - getDriveDatabaseManager, - getDriveFileDatabaseFixture, - getDriveFolderDatabaseFixture, -} from '../../fixtures/drive-database.fixture'; +import { getDriveDatabaseManager } from '../../fixtures/drive-database.fixture'; import { TrashService } from '../../../src/services/drive/trash.service'; import { expect } from 'chai'; import { NotFoundError } from '../../../src/utils/errors.utils'; +import { DriveFileService } from '../../../src/services/drive/drive-file.service'; +import { DriveFolderService } from '../../../src/services/drive/drive-folder.service'; +import { WebDavUtils } from '../../../src/utils/webdav.utils'; +import { newFileItem, newFolderItem } from '../../fixtures/drive.fixture'; +import { WebDavRequestedResource } from '../../../src/types/webdav.types'; +import path from 'path'; describe('DELETE request handler', () => { const sandbox = sinon.createSandbox(); + afterEach(() => { sandbox.restore(); }); + it('When a WebDav client sends a DELETE request, it should reply with a 404 if the item is not found', async () => { const driveDatabaseManager = getDriveDatabaseManager(); const requestHandler = new DELETERequestHandler({ driveDatabaseManager, trashService: TrashService.instance, + driveFileService: DriveFileService.instance, + driveFolderService: DriveFolderService.instance, }); - const request = createWebDavRequestFixture({ method: 'DELETE', url: '/file.txt', }); - - sandbox.stub(driveDatabaseManager, 'findByRelativePath').resolves(null); - const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); + const requestedResource: WebDavRequestedResource = { + name: 'file', + parentPath: '/', + path: path.parse('/file.txt'), + type: 'file', + url: '/file.txt', + }; + + const expectedError = new NotFoundError(`Resource not found on Internxt Drive at ${requestedResource.url}`); + + const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .throws(expectedError); + try { await requestHandler.handle(request, response); expect(true).to.be.false; } catch (error) { expect(error).to.be.instanceOf(NotFoundError); } + expect(getRequestedResourceStub.calledOnce).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; }); + it('When a WebDav client sends a DELETE request for a file, it should reply with a 204 if the item is deleted correctly', async () => { const driveDatabaseManager = getDriveDatabaseManager(); const trashService = TrashService.instance; const requestHandler = new DELETERequestHandler({ driveDatabaseManager, trashService, + driveFileService: DriveFileService.instance, + driveFolderService: DriveFolderService.instance, }); - const request = createWebDavRequestFixture({ method: 'DELETE', url: '/file.txt', }); - - const driveFileDatabaseObject = getDriveFileDatabaseFixture({}); - - sandbox.stub(driveDatabaseManager, 'findByRelativePath').resolves(driveFileDatabaseObject); - const deleteFileStub = sandbox.stub(driveDatabaseManager, 'deleteFile').resolves(); - const trashItemsStub = sandbox.stub(trashService, 'trashItems').resolves(); - const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); + const mockFile = newFileItem(); + const requestedResource: WebDavRequestedResource = { + name: 'file', + parentPath: '/', + path: path.parse('/file.txt'), + type: 'file', + url: '/file.txt', + }; + + const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .resolves(mockFile); + const trashItemsStub = sandbox.stub(trashService, 'trashItems').resolves(); + const deleteFileStub = sandbox.stub(driveDatabaseManager, 'deleteFileById').resolves(); + const deleteFolderStub = sandbox.stub(driveDatabaseManager, 'deleteFolderById').rejects(); + await requestHandler.handle(request, response); expect(response.status.calledWith(204)).to.be.true; + expect(getRequestedResourceStub.calledOnce).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; + expect(trashItemsStub.calledWith({ items: [{ type: 'file', uuid: mockFile.uuid }] })).to.be.true; expect(deleteFileStub.calledOnce).to.be.true; - expect(trashItemsStub.calledWith({ items: [{ type: 'file', uuid: driveFileDatabaseObject.uuid }] })).to.be.true; + expect(deleteFolderStub.calledOnce).to.be.false; }); it('When a WebDav client sends a DELETE request for a folder, it should reply with a 204 if the item is deleted correctly', async () => { @@ -75,26 +109,41 @@ describe('DELETE request handler', () => { const requestHandler = new DELETERequestHandler({ driveDatabaseManager, trashService, + driveFileService: DriveFileService.instance, + driveFolderService: DriveFolderService.instance, }); const request = createWebDavRequestFixture({ method: 'DELETE', url: '/folder/', }); - - const driveFolderDatabaseObject = getDriveFolderDatabaseFixture({}); - - sandbox.stub(driveDatabaseManager, 'findByRelativePath').resolves(driveFolderDatabaseObject); - const deleteFolderStub = sandbox.stub(driveDatabaseManager, 'deleteFolder').resolves(); - const trashItemsStub = sandbox.stub(trashService, 'trashItems').resolves(); - const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); + const mockFolder = newFolderItem(); + const requestedResource: WebDavRequestedResource = { + name: 'folder', + parentPath: '/', + path: path.parse('/folder/'), + type: 'folder', + url: '/folder/', + }; + + const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .resolves(mockFolder); + const trashItemsStub = sandbox.stub(trashService, 'trashItems').resolves(); + const deleteFileStub = sandbox.stub(driveDatabaseManager, 'deleteFileById').rejects(); + const deleteFolderStub = sandbox.stub(driveDatabaseManager, 'deleteFolderById').resolves(); + await requestHandler.handle(request, response); expect(response.status.calledWith(204)).to.be.true; + expect(getRequestedResourceStub.calledOnce).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; + expect(trashItemsStub.calledWith({ items: [{ type: 'folder', uuid: mockFolder.uuid }] })).to.be.true; + expect(deleteFileStub.calledOnce).to.be.false; expect(deleteFolderStub.calledOnce).to.be.true; - expect(trashItemsStub.calledWith({ items: [{ type: 'folder', uuid: driveFolderDatabaseObject.uuid }] })).to.be.true; }); }); From cd5086039677b7853392833f27c8ecc445050fac Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Thu, 31 Oct 2024 11:01:47 +0100 Subject: [PATCH 10/26] fixed and improved GET handler and tests --- src/webdav/handlers/GET.handler.ts | 30 +++++------ test/webdav/handlers/GET.handler.test.ts | 66 ++++++++++++++++-------- 2 files changed, 57 insertions(+), 39 deletions(-) diff --git a/src/webdav/handlers/GET.handler.ts b/src/webdav/handlers/GET.handler.ts index 06a95226..8c457c23 100644 --- a/src/webdav/handlers/GET.handler.ts +++ b/src/webdav/handlers/GET.handler.ts @@ -1,4 +1,4 @@ -import { WebDavMethodHandler, WebDavRequestedResource } from '../../types/webdav.types'; +import { WebDavMethodHandler } from '../../types/webdav.types'; import { Request, Response } from 'express'; import { WebDavUtils } from '../../utils/webdav.utils'; import { DriveFileService } from '../../services/drive/drive-file.service'; @@ -8,9 +8,9 @@ import { UploadService } from '../../services/network/upload.service'; import { DownloadService } from '../../services/network/download.service'; import { CryptoService } from '../../services/crypto.service'; import { AuthService } from '../../services/auth.service'; -import { DriveFile } from '../../services/database/drive-file/drive-file.domain'; import { NotFoundError, NotImplementedError } from '../../utils/errors.utils'; import { webdavLogger } from '../../utils/logger.utils'; +import { DriveFileItem } from '../../types/drive.types'; export class GETRequestHandler implements WebDavMethodHandler { constructor( @@ -26,25 +26,26 @@ export class GETRequestHandler implements WebDavMethodHandler { ) {} handle = async (req: Request, res: Response) => { - const resource = await WebDavUtils.getRequestedResource(req, this.dependencies.driveDatabaseManager); + const { driveDatabaseManager, driveFileService, authService, networkFacade } = this.dependencies; + const resource = await WebDavUtils.getRequestedResource(req); if (req.headers['content-range'] || req.headers['range']) throw new NotImplementedError('Range requests not supported'); if (resource.name.startsWith('._')) throw new NotFoundError('File not found'); webdavLogger.info(`GET request received for file at ${resource.url}`); - const driveFile = await this.getDriveFileDatabaseObject(resource); - - if (!driveFile) { - throw new NotFoundError('Drive file not found'); - } + const driveFile = (await WebDavUtils.getAndSearchItemFromResource({ + resource, + driveDatabaseManager, + driveFileService, + })) as DriveFileItem; webdavLogger.info(`✅ Found Drive File with uuid ${driveFile.uuid}`); res.set('Content-Type', 'application/octet-stream'); res.set('Content-length', driveFile.size.toString()); - const { mnemonic } = await this.dependencies.authService.getAuthDetails(); + const { mnemonic } = await authService.getAuthDetails(); webdavLogger.info('✅ Network ready for download'); const writable = new WritableStream({ @@ -56,14 +57,14 @@ export class GETRequestHandler implements WebDavMethodHandler { }, }); - const [executeDownload] = await this.dependencies.networkFacade.downloadToStream( + const [executeDownload] = await networkFacade.downloadToStream( driveFile.bucket, mnemonic, driveFile.fileId, writable, { progressCallback: (progress) => { - webdavLogger.info(`Download progress for file ${resource.name}: ${progress}%`); + webdavLogger.info(`Download progress for file ${resource.name}: ${(100 * progress).toFixed(2)}%`); }, }, ); @@ -74,11 +75,4 @@ export class GETRequestHandler implements WebDavMethodHandler { webdavLogger.info('✅ Download ready, replying to client'); }; - - private async getDriveFileDatabaseObject(resource: WebDavRequestedResource) { - const { driveDatabaseManager } = this.dependencies; - - const result = await driveDatabaseManager.findByRelativePath(resource.url); - return result as DriveFile | null; - } } diff --git a/test/webdav/handlers/GET.handler.test.ts b/test/webdav/handlers/GET.handler.test.ts index a8b1fb49..e84acdee 100644 --- a/test/webdav/handlers/GET.handler.test.ts +++ b/test/webdav/handlers/GET.handler.test.ts @@ -1,8 +1,12 @@ import sinon from 'sinon'; -import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../fixtures/webdav.fixture'; +import { + createWebDavRequestFixture, + createWebDavResponseFixture, + getRequestedFileResource, +} from '../../fixtures/webdav.fixture'; import { GETRequestHandler } from '../../../src/webdav/handlers/GET.handler'; import { DriveFileService } from '../../../src/services/drive/drive-file.service'; -import { getDriveFileDatabaseFixture, getDriveDatabaseManager } from '../../fixtures/drive-database.fixture'; +import { getDriveDatabaseManager } from '../../fixtures/drive-database.fixture'; import { CryptoService } from '../../../src/services/crypto.service'; import { DownloadService } from '../../../src/services/network/download.service'; import { UploadService } from '../../../src/services/network/upload.service'; @@ -12,6 +16,10 @@ import { NotFoundError, NotImplementedError } from '../../../src/utils/errors.ut import { SdkManager } from '../../../src/services/sdk-manager.service'; import { NetworkFacade } from '../../../src/services/network/network-facade.service'; import { UserFixture } from '../../fixtures/auth.fixture'; +import { fail } from 'node:assert'; +import { WebDavUtils } from '../../../src/utils/webdav.utils'; +import { WebDavRequestedResource } from '../../../src/types/webdav.types'; +import { newFileItem } from '../../fixtures/drive.fixture'; describe('GET request handler', () => { const sandbox = sinon.createSandbox(); @@ -26,7 +34,7 @@ describe('GET request handler', () => { sandbox.restore(); }); - it('When a WebDav client sends a GET request, and it contains a content-range header, should throw a NotImplementedError ', async () => { + it('When a WebDav client sends a GET request, and it contains a content-range header, then it should throw a NotImplementedError', async () => { const networkFacade = new NetworkFacade( getNetworkMock(), UploadService.instance, @@ -50,14 +58,13 @@ describe('GET request handler', () => { 'content-range': 'bytes 0-100/200', }, }); - const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); try { await sut.handle(request, response); - expect(true).to.be.false; + fail('Expected function to throw an error, but it did not.'); } catch (error) { expect(error).to.be.instanceOf(NotImplementedError); } @@ -69,7 +76,7 @@ describe('GET request handler', () => { const uploadService = UploadService.instance; const cryptoService = CryptoService.instance; const networkFacade = new NetworkFacade(getNetworkMock(), uploadService, downloadService, cryptoService); - const sut = new GETRequestHandler({ + const requestHandler = new GETRequestHandler({ driveFileService: DriveFileService.instance, uploadService, downloadService, @@ -84,18 +91,27 @@ describe('GET request handler', () => { url: '/file.txt', headers: {}, }); - - sandbox.stub(driveDatabaseManager, 'findByRelativePath').resolves(null); const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); + + const expectedError = new NotFoundError(`Resource not found on Internxt Drive at ${requestedFileResource.url}`); + + const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedFileResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .throws(expectedError); + try { - await sut.handle(request, response); - expect(true).to.be.false; + await requestHandler.handle(request, response); + fail('Expected function to throw an error, but it did not.'); } catch (error) { expect(error).to.be.instanceOf(NotFoundError); } + expect(getRequestedResourceStub.calledOnce).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; }); it('When a WebDav client sends a GET request, and the Drive file is found, should write a response with the content', async () => { @@ -105,7 +121,7 @@ describe('GET request handler', () => { const cryptoService = CryptoService.instance; const authService = AuthService.instance; const networkFacade = new NetworkFacade(getNetworkMock(), uploadService, downloadService, cryptoService); - const sut = new GETRequestHandler({ + const requestHandler = new GETRequestHandler({ driveFileService: DriveFileService.instance, uploadService, downloadService, @@ -120,20 +136,28 @@ describe('GET request handler', () => { url: '/file.txt', headers: {}, }); - - const driveFileDatabaseObject = getDriveFileDatabaseFixture({}); - - sandbox.stub(driveDatabaseManager, 'findByRelativePath').resolves(driveFileDatabaseObject); - sandbox - .stub(authService, 'getAuthDetails') - .resolves({ mnemonic: 'MNEMONIC', token: 'TOKEN', newToken: 'NEW_TOKEN', user: UserFixture }); - - sandbox.stub(networkFacade, 'downloadToStream').resolves([Promise.resolve(), new AbortController()]); const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); - await sut.handle(request, response); + const mockFile = newFileItem(); + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); + const mockAuthDetails = { mnemonic: 'MNEMONIC', token: 'TOKEN', newToken: 'NEW_TOKEN', user: UserFixture }; + + const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedFileResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .resolves(mockFile); + const authDetailsStub = sandbox.stub(authService, 'getAuthDetails').resolves(mockAuthDetails); + const downloadStreamStub = sandbox + .stub(networkFacade, 'downloadToStream') + .resolves([Promise.resolve(), new AbortController()]); + + await requestHandler.handle(request, response); expect(response.status.calledWith(200)).to.be.true; + expect(getRequestedResourceStub.calledOnce).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; + expect(authDetailsStub.calledOnce).to.be.true; + expect(downloadStreamStub.calledWith(mockFile.bucket, mockAuthDetails.mnemonic, mockFile.fileId)).to.be.true; }); }); From 01da42573820b8ecbada4312b8888e4f91862bd4 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Thu, 31 Oct 2024 11:17:24 +0100 Subject: [PATCH 11/26] fixed tests and improved try-catch testing --- test/fixtures/webdav.fixture.ts | 22 ++++++++++ test/services/keys.service.test.ts | 8 ++-- test/utils/crypto.utils.test.ts | 7 +--- test/webdav/handlers/DELETE.handler.test.ts | 45 ++++++++------------- 4 files changed, 43 insertions(+), 39 deletions(-) diff --git a/test/fixtures/webdav.fixture.ts b/test/fixtures/webdav.fixture.ts index f42ec82b..8f5473a2 100644 --- a/test/fixtures/webdav.fixture.ts +++ b/test/fixtures/webdav.fixture.ts @@ -1,6 +1,8 @@ import { mockReq, mockRes } from 'sinon-express-mock'; import { Request, Response } from 'express'; import { UserSettingsFixture } from './auth.fixture'; +import { WebDavRequestedResource } from '../../src/types/webdav.types'; +import path from 'path'; export function createWebDavRequestFixture(request: T): mockReq.MockReq & T & Request { const userSettings = UserSettingsFixture; @@ -16,3 +18,23 @@ export function createWebDavRequestFixture(request: T): mockRe export function createWebDavResponseFixture(response: T): mockRes.MockRes & T & Response { return mockRes(response); } + +export const getRequestedFileResource = (): WebDavRequestedResource => { + return { + name: 'file', + parentPath: '/', + path: path.parse('/file.txt'), + type: 'file', + url: '/file.txt', + }; +}; + +export const getRequestedFolderResource = (): WebDavRequestedResource => { + return { + name: 'folder', + parentPath: '/', + path: path.parse('/folder/'), + type: 'folder', + url: '/folder/', + }; +}; diff --git a/test/services/keys.service.test.ts b/test/services/keys.service.test.ts index fa50b28e..666a26a1 100644 --- a/test/services/keys.service.test.ts +++ b/test/services/keys.service.test.ts @@ -6,6 +6,7 @@ import * as openpgp from 'openpgp'; import { KeysService } from '../../src/services/keys.service'; import { ConfigService } from '../../src/services/config.service'; import { AesInit, CorruptedEncryptedPrivateKeyError } from '../../src/types/keys.types'; +import { fail } from 'assert'; describe('Keys service', () => { let keysServiceSandbox: SinonSandbox; @@ -34,13 +35,12 @@ describe('Keys service', () => { }); await KeysService.instance.assertValidateKeys('dontcareprivate', 'dontcarepublic'); - expect(true).to.be.true; //checks that assertValidateKeys does not throw any error }); it('When public and private keys are not valid, then the validation throws an error', async () => { try { await KeysService.instance.assertValidateKeys('privateKey', 'publickey'); - expect(false).to.be.true; //should throw error + fail('Expected function to throw an error, but it did not.'); } catch { /* no op */ } @@ -58,7 +58,7 @@ describe('Keys service', () => { //every dependency method resolves (no error thrown), but nothing should be encrypted/decrypted, so the result should not be valid try { await KeysService.instance.assertValidateKeys('dontcareprivate', 'dontcarepublic'); - expect(false).to.be.true; //should throw error + fail('Expected function to throw an error, but it did not.'); } catch (err) { const error = err as Error; expect(error.message).to.equal('Keys do not match'); @@ -112,8 +112,6 @@ describe('Keys service', () => { keysServiceSandbox.stub(KeysService.instance, 'isValidKey').resolves(true); await KeysService.instance.assertPrivateKeyIsValid(encryptedPrivateKey, password); - - expect(true).to.be.true; //checks that assertPrivateKeyIsValid does not throw any error }); it('When private key is encrypted with bad iterations, then it throws a WrongIterationsToEncryptPrivateKey error', async () => { diff --git a/test/utils/crypto.utils.test.ts b/test/utils/crypto.utils.test.ts index 2b0c6717..ed73357c 100644 --- a/test/utils/crypto.utils.test.ts +++ b/test/utils/crypto.utils.test.ts @@ -17,12 +17,7 @@ describe('Crypto utils', () => { }); it('When Magic IV or Magic Salt are missing should throw an error', async () => { - try { - CryptoUtils.getAesInit(); - expect(true).to.be.true; - } catch { - // Noop - } + CryptoUtils.getAesInit(); }); it('When aes information is required, then it is read from the config service', async () => { diff --git a/test/webdav/handlers/DELETE.handler.test.ts b/test/webdav/handlers/DELETE.handler.test.ts index df629ef2..f4593da3 100644 --- a/test/webdav/handlers/DELETE.handler.test.ts +++ b/test/webdav/handlers/DELETE.handler.test.ts @@ -1,6 +1,11 @@ import sinon from 'sinon'; import { DELETERequestHandler } from '../../../src/webdav/handlers/DELETE.handler'; -import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../fixtures/webdav.fixture'; +import { + createWebDavRequestFixture, + createWebDavResponseFixture, + getRequestedFileResource, + getRequestedFolderResource, +} from '../../fixtures/webdav.fixture'; import { getDriveDatabaseManager } from '../../fixtures/drive-database.fixture'; import { TrashService } from '../../../src/services/drive/trash.service'; import { expect } from 'chai'; @@ -9,8 +14,8 @@ import { DriveFileService } from '../../../src/services/drive/drive-file.service import { DriveFolderService } from '../../../src/services/drive/drive-folder.service'; import { WebDavUtils } from '../../../src/utils/webdav.utils'; import { newFileItem, newFolderItem } from '../../fixtures/drive.fixture'; +import { fail } from 'assert'; import { WebDavRequestedResource } from '../../../src/types/webdav.types'; -import path from 'path'; describe('DELETE request handler', () => { const sandbox = sinon.createSandbox(); @@ -35,24 +40,18 @@ describe('DELETE request handler', () => { status: sandbox.stub().returns({ send: sandbox.stub() }), }); - const requestedResource: WebDavRequestedResource = { - name: 'file', - parentPath: '/', - path: path.parse('/file.txt'), - type: 'file', - url: '/file.txt', - }; + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); - const expectedError = new NotFoundError(`Resource not found on Internxt Drive at ${requestedResource.url}`); + const expectedError = new NotFoundError(`Resource not found on Internxt Drive at ${requestedFileResource.url}`); - const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedResource); + const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedFileResource); const getAndSearchItemFromResourceStub = sandbox .stub(WebDavUtils, 'getAndSearchItemFromResource') .throws(expectedError); try { await requestHandler.handle(request, response); - expect(true).to.be.false; + fail('Expected function to throw an error, but it did not.'); } catch (error) { expect(error).to.be.instanceOf(NotFoundError); } @@ -78,15 +77,9 @@ describe('DELETE request handler', () => { }); const mockFile = newFileItem(); - const requestedResource: WebDavRequestedResource = { - name: 'file', - parentPath: '/', - path: path.parse('/file.txt'), - type: 'file', - url: '/file.txt', - }; + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); - const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedResource); + const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedFileResource); const getAndSearchItemFromResourceStub = sandbox .stub(WebDavUtils, 'getAndSearchItemFromResource') .resolves(mockFile); @@ -122,15 +115,11 @@ describe('DELETE request handler', () => { }); const mockFolder = newFolderItem(); - const requestedResource: WebDavRequestedResource = { - name: 'folder', - parentPath: '/', - path: path.parse('/folder/'), - type: 'folder', - url: '/folder/', - }; + const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource(); - const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedResource); + const getRequestedResourceStub = sandbox + .stub(WebDavUtils, 'getRequestedResource') + .resolves(requestedFolderResource); const getAndSearchItemFromResourceStub = sandbox .stub(WebDavUtils, 'getAndSearchItemFromResource') .resolves(mockFolder); From a7a09d75fc27e642302b1d00b743ba2381484d74 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Thu, 31 Oct 2024 13:26:40 +0100 Subject: [PATCH 12/26] improved resource fixture --- test/fixtures/webdav.fixture.ts | 46 ++++++++++++++++----- test/webdav/handlers/DELETE.handler.test.ts | 15 +++---- test/webdav/handlers/GET.handler.test.ts | 12 +++--- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/test/fixtures/webdav.fixture.ts b/test/fixtures/webdav.fixture.ts index 8f5473a2..32b955e1 100644 --- a/test/fixtures/webdav.fixture.ts +++ b/test/fixtures/webdav.fixture.ts @@ -19,22 +19,48 @@ export function createWebDavResponseFixture(response: T): mock return mockRes(response); } -export const getRequestedFileResource = (): WebDavRequestedResource => { +export const getRequestedFileResource = ({ + parentFolder = '/', + fileName = 'file', + fileType = 'txt', +} = {}): WebDavRequestedResource => { + if (!parentFolder.startsWith('/')) { + parentFolder = '/'.concat(parentFolder); + } + if (!parentFolder.endsWith('/')) { + parentFolder = parentFolder.concat('/'); + } + const completeURL = parentFolder.concat(fileType ? fileName + '.' + fileType : fileName); return { - name: 'file', - parentPath: '/', - path: path.parse('/file.txt'), + name: fileName, + parentPath: parentFolder, + path: path.parse(completeURL), type: 'file', - url: '/file.txt', + url: completeURL, }; }; -export const getRequestedFolderResource = (): WebDavRequestedResource => { +export const getRequestedFolderResource = ({ + parentFolder = '/', + folderName = 'folder', +} = {}): WebDavRequestedResource => { + if (!parentFolder.startsWith('/')) { + parentFolder = '/'.concat(parentFolder); + } + if (!parentFolder.endsWith('/')) { + parentFolder = parentFolder.concat('/'); + } + folderName = folderName.replaceAll('/', ''); + + let completeURL = parentFolder.concat(folderName); + if (!completeURL.endsWith('/')) { + completeURL = completeURL.concat('/'); + } return { - name: 'folder', - parentPath: '/', - path: path.parse('/folder/'), + name: folderName, + parentPath: parentFolder, + path: path.parse(completeURL), type: 'folder', - url: '/folder/', + url: completeURL, }; }; diff --git a/test/webdav/handlers/DELETE.handler.test.ts b/test/webdav/handlers/DELETE.handler.test.ts index f4593da3..966b49cb 100644 --- a/test/webdav/handlers/DELETE.handler.test.ts +++ b/test/webdav/handlers/DELETE.handler.test.ts @@ -32,16 +32,17 @@ describe('DELETE request handler', () => { driveFileService: DriveFileService.instance, driveFolderService: DriveFolderService.instance, }); + + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); + const request = createWebDavRequestFixture({ method: 'DELETE', - url: '/file.txt', + url: requestedFileResource.url, }); const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); - const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); - const expectedError = new NotFoundError(`Resource not found on Internxt Drive at ${requestedFileResource.url}`); const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedFileResource); @@ -68,16 +69,16 @@ describe('DELETE request handler', () => { driveFileService: DriveFileService.instance, driveFolderService: DriveFolderService.instance, }); + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); const request = createWebDavRequestFixture({ method: 'DELETE', - url: '/file.txt', + url: requestedFileResource.url, }); const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); const mockFile = newFileItem(); - const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedFileResource); const getAndSearchItemFromResourceStub = sandbox @@ -105,17 +106,17 @@ describe('DELETE request handler', () => { driveFileService: DriveFileService.instance, driveFolderService: DriveFolderService.instance, }); + const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource(); const request = createWebDavRequestFixture({ method: 'DELETE', - url: '/folder/', + url: requestedFolderResource.url, }); const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); const mockFolder = newFolderItem(); - const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource(); const getRequestedResourceStub = sandbox .stub(WebDavUtils, 'getRequestedResource') diff --git a/test/webdav/handlers/GET.handler.test.ts b/test/webdav/handlers/GET.handler.test.ts index e84acdee..db90a11c 100644 --- a/test/webdav/handlers/GET.handler.test.ts +++ b/test/webdav/handlers/GET.handler.test.ts @@ -23,6 +23,7 @@ import { newFileItem } from '../../fixtures/drive.fixture'; describe('GET request handler', () => { const sandbox = sinon.createSandbox(); + const getNetworkMock = () => { return SdkManager.instance.getNetwork({ user: 'user', @@ -86,17 +87,17 @@ describe('GET request handler', () => { networkFacade, }); + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); + const request = createWebDavRequestFixture({ method: 'GET', - url: '/file.txt', + url: requestedFileResource.url, headers: {}, }); const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); - const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); - const expectedError = new NotFoundError(`Resource not found on Internxt Drive at ${requestedFileResource.url}`); const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedFileResource); @@ -131,9 +132,11 @@ describe('GET request handler', () => { networkFacade, }); + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); + const request = createWebDavRequestFixture({ method: 'GET', - url: '/file.txt', + url: requestedFileResource.url, headers: {}, }); const response = createWebDavResponseFixture({ @@ -141,7 +144,6 @@ describe('GET request handler', () => { }); const mockFile = newFileItem(); - const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); const mockAuthDetails = { mnemonic: 'MNEMONIC', token: 'TOKEN', newToken: 'NEW_TOKEN', user: UserFixture }; const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedFileResource); From 7a0c8cc73c44fde3a971b4e05d84c93c0318a5a8 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Thu, 31 Oct 2024 13:38:04 +0100 Subject: [PATCH 13/26] fixed and improved MKCOL handler and tests --- src/webdav/handlers/MKCOL.handler.ts | 52 +++++++++-------- test/webdav/handlers/MKCOL.handler.test.ts | 66 +++++++++++++++------- 2 files changed, 75 insertions(+), 43 deletions(-) diff --git a/src/webdav/handlers/MKCOL.handler.ts b/src/webdav/handlers/MKCOL.handler.ts index 23034b6f..2a041ead 100644 --- a/src/webdav/handlers/MKCOL.handler.ts +++ b/src/webdav/handlers/MKCOL.handler.ts @@ -5,9 +5,9 @@ import { WebDavUtils } from '../../utils/webdav.utils'; import { ConflictError } from '../../utils/errors.utils'; import { DriveFolderService } from '../../services/drive/drive-folder.service'; import { webdavLogger } from '../../utils/logger.utils'; -import path from 'path'; import { XMLUtils } from '../../utils/xml.utils'; import { AsyncUtils } from '../../utils/async.utils'; +import { DriveFolderItem } from '../../types/drive.types'; export class MKCOLRequestHandler implements WebDavMethodHandler { constructor( @@ -19,39 +19,45 @@ export class MKCOLRequestHandler implements WebDavMethodHandler { handle = async (req: Request, res: Response) => { const { driveDatabaseManager, driveFolderService } = this.dependencies; - const decodedUrl = decodeURIComponent(req.url); - const resourceParsedPath = path.parse(decodedUrl); - const parentPath = WebDavUtils.getParentPath(decodedUrl); + const resource = await WebDavUtils.getRequestedResource(req); + webdavLogger.info('Resource received for MKCOL request', { resource }); - const parentResource = await driveDatabaseManager.findByRelativePath(parentPath); + const parentResource = await WebDavUtils.getRequestedResource(resource.parentPath); - if (!parentResource) { - throw new ConflictError(`Parent resource not found for parent path ${parentPath}`); - } + const parentFolderItem = (await WebDavUtils.getAndSearchItemFromResource({ + resource: parentResource, + driveDatabaseManager, + driveFolderService, + })) as DriveFolderItem; - webdavLogger.info(`MKCOL request received for folder at ${req.url}`); - webdavLogger.info(`Parent path: ${parentResource.id}`); + if (!parentFolderItem) { + throw new ConflictError(`Parent resource not found for parent path ${resource.parentPath}`); + } const [createFolder] = driveFolderService.createFolder({ - folderName: resourceParsedPath.base, - parentFolderId: parentResource.id, + folderName: resource.name, + parentFolderId: parentFolderItem.id, }); const newFolder = await createFolder; webdavLogger.info(`✅ Folder created with UUID ${newFolder.uuid}`); - await driveDatabaseManager.createFolder({ - name: newFolder.plain_name, - status: 'EXISTS', - encryptedName: newFolder.name, - bucket: newFolder.bucket, - id: newFolder.id, - parentId: newFolder.parentId, - uuid: newFolder.uuid, - createdAt: new Date(newFolder.createdAt), - updatedAt: new Date(newFolder.updatedAt), - }); + await driveDatabaseManager.createFolder( + { + name: newFolder.plain_name, + status: 'EXISTS', + encryptedName: newFolder.name, + bucket: newFolder.bucket, + id: newFolder.id, + parentId: newFolder.parentId, + parentUuid: newFolder.parentUuid, + uuid: newFolder.uuid, + createdAt: new Date(newFolder.createdAt), + updatedAt: new Date(newFolder.updatedAt), + }, + resource.url, + ); // This aims to prevent this issue: https://inxt.atlassian.net/browse/PB-1446 await AsyncUtils.sleep(500); diff --git a/test/webdav/handlers/MKCOL.handler.test.ts b/test/webdav/handlers/MKCOL.handler.test.ts index aab14029..fcfb2c30 100644 --- a/test/webdav/handlers/MKCOL.handler.test.ts +++ b/test/webdav/handlers/MKCOL.handler.test.ts @@ -1,15 +1,24 @@ import sinon from 'sinon'; import { MKCOLRequestHandler } from '../../../src/webdav/handlers/MKCOL.handler'; -import { getDriveDatabaseManager, getDriveFolderDatabaseFixture } from '../../fixtures/drive-database.fixture'; +import { getDriveDatabaseManager } from '../../fixtures/drive-database.fixture'; import { DriveFolderService } from '../../../src/services/drive/drive-folder.service'; -import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../fixtures/webdav.fixture'; +import { + createWebDavRequestFixture, + createWebDavResponseFixture, + getRequestedFolderResource, +} from '../../fixtures/webdav.fixture'; import { UserSettingsFixture } from '../../fixtures/auth.fixture'; import { expect } from 'chai'; import { CreateFolderResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { newFolderItem } from '../../fixtures/drive.fixture'; +import { WebDavRequestedResource } from '../../../src/types/webdav.types'; +import { WebDavUtils } from '../../../src/utils/webdav.utils'; describe('MKCOL request handler', () => { const sandbox = sinon.createSandbox(); + afterEach(() => sandbox.restore()); + it('When a WebDav client sends a MKCOL request, it should reply with a 201 if success', async () => { const driveDatabaseManager = getDriveDatabaseManager(); const driveFolderService = DriveFolderService.instance; @@ -17,10 +26,25 @@ describe('MKCOL request handler', () => { driveDatabaseManager, driveFolderService, }); + const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource({ + parentFolder: '/test', + folderName: 'FolderA', + }); + const requestedParentFolderResource: WebDavRequestedResource = getRequestedFolderResource({ + parentFolder: '/', + folderName: 'test', + }); + + const request = createWebDavRequestFixture({ + method: 'MKCOL', + url: requestedFolderResource.url, + user: UserSettingsFixture, + }); + const response = createWebDavResponseFixture({ + status: sandbox.stub().returns({ send: sandbox.stub() }), + }); - const parentFolder = getDriveFolderDatabaseFixture(); - sandbox.stub(driveDatabaseManager, 'findByRelativePath').resolves(parentFolder); - sandbox.stub(driveDatabaseManager, 'createFolder').resolves(); + const parentFolder = newFolderItem(); const newFolderResponse: CreateFolderResponse = { parentId: 123, bucket: 'bucket1', @@ -34,24 +58,26 @@ describe('MKCOL request handler', () => { parentUuid: '0123-5678-9012-3456', }; - sandbox + const getRequestedResourceStub = sandbox + .stub(WebDavUtils, 'getRequestedResource') + .onFirstCall() + .resolves(requestedFolderResource) + .onSecondCall() + .resolves(requestedParentFolderResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .resolves(parentFolder); + const createFolderStub = sandbox .stub(driveFolderService, 'createFolder') .returns([Promise.resolve(newFolderResponse), { cancel: () => {} }]); - const request = createWebDavRequestFixture({ - url: '/FolderA/', - method: 'MKCOL', - user: UserSettingsFixture, - }); - - const statusStub = sandbox.stub(); - const sendStub = sandbox.stub(); - - const response = createWebDavResponseFixture({ - status: statusStub.returns({ send: sendStub }), - }); + const createDatabaseFolderStub = sandbox.stub(driveDatabaseManager, 'createFolder').resolves(); await requestHandler.handle(request, response); - - expect(statusStub.calledWith(201)).to.be.true; + expect(response.status.calledWith(201)).to.be.true; + expect(getRequestedResourceStub.calledTwice).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; + expect(createFolderStub.calledWith({ folderName: requestedFolderResource.name, parentFolderId: parentFolder.id })) + .to.be.true; + expect(createDatabaseFolderStub.calledOnce).to.be.true; }); }); From 80de71a1e5af557875d754663fb8ff66aee0ad5a Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Thu, 31 Oct 2024 13:50:23 +0100 Subject: [PATCH 14/26] fixed and improved HEAD handler and tests --- src/webdav/handlers/HEAD.handler.ts | 5 +++-- test/webdav/handlers/HEAD.handler.test.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/webdav/handlers/HEAD.handler.ts b/src/webdav/handlers/HEAD.handler.ts index 7714197e..185d8e86 100644 --- a/src/webdav/handlers/HEAD.handler.ts +++ b/src/webdav/handlers/HEAD.handler.ts @@ -1,8 +1,9 @@ import { WebDavMethodHandler } from '../../types/webdav.types'; import { Request, Response } from 'express'; + export class HEADRequestHandler implements WebDavMethodHandler { - async handle(req: Request, res: Response) { + handle = async (_: Request, res: Response) => { // This is a NOOP request handler, clients like CyberDuck uses this. res.status(405).send(); - } + }; } diff --git a/test/webdav/handlers/HEAD.handler.test.ts b/test/webdav/handlers/HEAD.handler.test.ts index 5827c0f9..ea3ee3cd 100644 --- a/test/webdav/handlers/HEAD.handler.test.ts +++ b/test/webdav/handlers/HEAD.handler.test.ts @@ -1,25 +1,26 @@ import sinon from 'sinon'; import { HEADRequestHandler } from '../../../src/webdav/handlers/HEAD.handler'; import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../fixtures/webdav.fixture'; +import { expect } from 'chai'; describe('HEAD request handler', () => { const sandbox = sinon.createSandbox(); + afterEach(() => { sandbox.restore(); }); + it('When a WebDav client sends a HEAD request, it should reply with a 405', async () => { const requestHandler = new HEADRequestHandler(); const request = createWebDavRequestFixture({ method: 'HEAD', }); - const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); await requestHandler.handle(request, response); - - sinon.assert.calledWith(response.status, 405); + expect(response.status.calledWith(405)).to.be.true; }); }); From 5d0fc5cc85525d8e8ac82b295425e5c450440f0b Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Thu, 31 Oct 2024 13:50:54 +0100 Subject: [PATCH 15/26] fixed and improved OPTIONS handler and tests --- src/webdav/handlers/OPTIONS.handler.ts | 5 +++-- test/webdav/handlers/OPTIONS.handler.test.ts | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/webdav/handlers/OPTIONS.handler.ts b/src/webdav/handlers/OPTIONS.handler.ts index 60ca6497..bf90ff95 100644 --- a/src/webdav/handlers/OPTIONS.handler.ts +++ b/src/webdav/handlers/OPTIONS.handler.ts @@ -1,9 +1,10 @@ import { WebDavMethodHandler } from '../../types/webdav.types'; import { Request, Response } from 'express'; + export class OPTIONSRequestHandler implements WebDavMethodHandler { - async handle(_: Request, res: Response) { + handle = async (_: Request, res: Response) => { res.header('Allow', 'OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK'); res.header('DAV', '1, 2, ordered-collections'); res.status(200).send(); - } + }; } diff --git a/test/webdav/handlers/OPTIONS.handler.test.ts b/test/webdav/handlers/OPTIONS.handler.test.ts index 3a432b3a..4ca7af2d 100644 --- a/test/webdav/handlers/OPTIONS.handler.test.ts +++ b/test/webdav/handlers/OPTIONS.handler.test.ts @@ -2,11 +2,15 @@ import sinon from 'sinon'; import { OPTIONSRequestHandler } from '../../../src/webdav/handlers/OPTIONS.handler'; import { UserSettingsFixture } from '../../fixtures/auth.fixture'; import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../fixtures/webdav.fixture'; +import { expect } from 'chai'; + describe('OPTIONS request handler', () => { const sandbox = sinon.createSandbox(); + afterEach(() => { sandbox.restore(); }); + it('When a WebDav client sends an OPTIONS request, it should return the allowed methods', async () => { const requestHandler = new OPTIONSRequestHandler(); @@ -14,7 +18,6 @@ describe('OPTIONS request handler', () => { method: 'OPTIONS', user: UserSettingsFixture, }); - const response = createWebDavResponseFixture({ header: sinon.stub(), status: sinon.stub().returns({ send: sinon.stub() }), @@ -22,10 +25,13 @@ describe('OPTIONS request handler', () => { await requestHandler.handle(request, response); - sinon.assert.calledWith( - response.header, - 'Allow', - 'OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK', - ); + expect(response.status.calledWith(200)).to.be.true; + expect( + response.header.calledWith( + 'Allow', + 'OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK', + ), + ).to.be.true; + expect(response.header.calledWith('DAV', '1, 2, ordered-collections')).to.be.true; }); }); From 9aa55d85e71213c97177b5b0648b2406d5a3daac Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Mon, 4 Nov 2024 17:36:57 +0100 Subject: [PATCH 16/26] fixed and improved PROPFIND handler and tests --- src/webdav/handlers/PROPFIND.handler.ts | 148 +++++---- test/webdav/handlers/PROPFIND.handler.test.ts | 287 ++++++++++-------- 2 files changed, 237 insertions(+), 198 deletions(-) diff --git a/src/webdav/handlers/PROPFIND.handler.ts b/src/webdav/handlers/PROPFIND.handler.ts index 093ae4ba..cbcb61b9 100644 --- a/src/webdav/handlers/PROPFIND.handler.ts +++ b/src/webdav/handlers/PROPFIND.handler.ts @@ -2,65 +2,57 @@ import { WebDavMethodHandler, WebDavMethodHandlerOptions, WebDavRequestedResourc import { XMLUtils } from '../../utils/xml.utils'; import { DriveFileItem, DriveFolderItem } from '../../types/drive.types'; import { DriveFolderService } from '../../services/drive/drive-folder.service'; +import { DriveFileService } from '../../services/drive/drive-file.service'; import { FormatUtils } from '../../utils/format.utils'; import { Request, Response } from 'express'; import { randomUUID } from 'crypto'; import mime from 'mime-types'; import { DriveDatabaseManager } from '../../services/database/drive-database-manager.service'; import { WebDavUtils } from '../../utils/webdav.utils'; -import { NotFoundError } from '../../utils/errors.utils'; +import { webdavLogger } from '../../utils/logger.utils'; export class PROPFINDRequestHandler implements WebDavMethodHandler { constructor( private options: WebDavMethodHandlerOptions = { debug: false }, - private dependencies: { driveFolderService: DriveFolderService; driveDatabaseManager: DriveDatabaseManager }, + private dependencies: { + driveFolderService: DriveFolderService; + driveFileService: DriveFileService; + driveDatabaseManager: DriveDatabaseManager; + }, ) {} handle = async (req: Request, res: Response) => { - const resource = await WebDavUtils.getRequestedResource(req, this.dependencies.driveDatabaseManager); - const depth = req.header('depth') ?? '1'; + const { driveDatabaseManager, driveFolderService, driveFileService } = this.dependencies; + + const resource = await WebDavUtils.getRequestedResource(req); + webdavLogger.info('[PROPFIND] Request received', { resource }); + + const driveItem = await WebDavUtils.getAndSearchItemFromResource({ + resource, + driveDatabaseManager, + driveFolderService, + driveFileService, + }); switch (resource.type) { case 'file': { - res.status(207).send(await this.getFileMetaXML(resource)); + const fileMetaXML = await this.getFileMetaXML(resource, driveItem as DriveFileItem); + console.error({ fileMetaXML }); + res.status(207).send(fileMetaXML); break; } case 'folder': { - if (resource.url === '/') { - const rootFolder = await this.dependencies.driveFolderService.getFolderMetaById(req.user.rootFolderId); - await this.dependencies.driveDatabaseManager.createFolder({ - name: '', - status: 'EXISTS', - encryptedName: rootFolder.name, - bucket: rootFolder.bucket, - id: rootFolder.id, - parentId: rootFolder.parentId, - uuid: rootFolder.uuid, - createdAt: new Date(rootFolder.createdAt), - updatedAt: new Date(rootFolder.updatedAt), - }); - res.status(207).send(await this.getFolderContentXML('/', rootFolder.uuid, depth, true)); - break; - } - - const driveParentFolder = await this.dependencies.driveDatabaseManager.findByRelativePath(resource.url); - - if (!driveParentFolder) { - res.status(404).send(); - return; - } - - res.status(207).send(await this.getFolderContentXML(resource.url, driveParentFolder.uuid, depth)); + const depth = req.header('depth') ?? '1'; + const folderMetaXML = await this.getFolderContentXML(resource, driveItem as DriveFolderItem, depth); + console.error({ folderMetaXML }); + res.status(207).send(folderMetaXML); break; } } }; - private async getFileMetaXML(resource: WebDavRequestedResource): Promise { - const driveFileItem = await this.dependencies.driveDatabaseManager.findByRelativePath(resource.url); - - if (!driveFileItem || !('size' in driveFileItem)) throw new NotFoundError('File not found'); + private async getFileMetaXML(resource: WebDavRequestedResource, driveFileItem: DriveFileItem): Promise { const driveFile = this.driveFileItemToXMLNode( { name: driveFileItem.name, @@ -75,8 +67,9 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler { updatedAt: driveFileItem.updatedAt, status: driveFileItem.status, folderId: driveFileItem.folderId, + folderUuid: driveFileItem.folderUuid, }, - encodeURI(resource.url), + resource.url, ); const xml = XMLUtils.toWebDavXML([driveFile], { arrayNodeName: XMLUtils.addDefaultNamespace('response'), @@ -85,25 +78,27 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler { return xml; } - private async getFolderContentXML(relativePath: string, folderUuid: string, depth: string, isRootFolder = false) { + private async getFolderContentXML(resource: WebDavRequestedResource, folderItem: DriveFolderItem, depth: string) { + const relativePath = resource.url; + const isRootFolder = resource.url === '/'; let XMLNodes: object[] = []; switch (depth) { case '0': if (isRootFolder) { - XMLNodes.push(await this.getFolderRootXMLNode(relativePath, folderUuid)); + XMLNodes.push(this.driveFolderRootStatsToXMLNode(folderItem, relativePath)); } else { - XMLNodes.push(await this.getFolderXMLNode(relativePath, folderUuid)); + XMLNodes.push(this.driveFolderItemToXMLNode(folderItem, relativePath)); } break; case '1': default: if (isRootFolder) { - XMLNodes.push(await this.getFolderRootXMLNode(relativePath, folderUuid)); - XMLNodes = XMLNodes.concat(await this.getFolderChildsXMLNode(relativePath, folderUuid)); + XMLNodes.push(this.driveFolderRootStatsToXMLNode(folderItem, relativePath)); + XMLNodes = XMLNodes.concat(await this.getFolderChildsXMLNode(relativePath, folderItem.uuid)); } else { - XMLNodes.push(await this.getFolderXMLNode(relativePath, folderUuid)); - XMLNodes = XMLNodes.concat(await this.getFolderChildsXMLNode(relativePath, folderUuid)); + XMLNodes.push(this.driveFolderItemToXMLNode(folderItem, relativePath)); + XMLNodes = XMLNodes.concat(await this.getFolderChildsXMLNode(relativePath, folderItem.uuid)); } break; } @@ -135,20 +130,25 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler { encryptedName: folder.name, uuid: folder.uuid, parentId: null, + parentUuid: null, }, - encodeURI(folderRelativePath), + folderRelativePath, ); }); await Promise.all( - folderContent.folders.map((folder) => - driveDatabaseManager.createFolder({ - ...folder, - name: folder.plainName, - encryptedName: folder.name, - status: folder.deleted || folder.removed ? 'TRASHED' : 'EXISTS', - }), - ), + folderContent.folders.map((folder) => { + const folderRelativePath = WebDavUtils.joinURL(relativePath, folder.plainName, '/'); + return driveDatabaseManager.createFolder( + { + ...folder, + name: folder.plainName, + encryptedName: folder.name, + status: folder.deleted || folder.removed ? 'TRASHED' : 'EXISTS', + }, + folderRelativePath, + ); + }), ); const filesXML = folderContent.files.map((file) => { @@ -169,46 +169,38 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler { encryptedName: file.name, status: file.status, folderId: file.folderId, + folderUuid: file.folderUuid, size: Number(file.size), }, - encodeURI(fileRelativePath), + fileRelativePath, ); }); await Promise.all( folderContent.files.map((file) => { - driveDatabaseManager.createFile({ - ...file, - name: file.plainName, - fileId: file.fileId, - size: Number(file.size), - encryptedName: file.name, - }); + const fileRelativePath = WebDavUtils.joinURL( + relativePath, + file.type ? `${file.plainName}.${file.type}` : file.plainName, + ); + return driveDatabaseManager.createFile( + { + ...file, + name: file.plainName, + fileId: file.fileId, + size: Number(file.size), + encryptedName: file.name, + }, + fileRelativePath, + ); }), ); return foldersXML.concat(filesXML); } - private async getFolderRootXMLNode(relativePath: string, folderUuid: string) { - const { driveFolderService } = this.dependencies; - - const folderMeta = await driveFolderService.getFolderMetaByUuid(folderUuid); - const folderXML = this.driveFolderRootStatsToXMLNode(folderMeta, encodeURI(relativePath)); - return folderXML; - } - - private async getFolderXMLNode(relativePath: string, folderUuid: string) { - const { driveFolderService } = this.dependencies; - - const folderMeta = await driveFolderService.getFolderMetaByUuid(folderUuid); - const folderXML = this.driveFolderItemToXMLNode(folderMeta, encodeURI(relativePath)); - return folderXML; - } - private driveFolderRootStatsToXMLNode(driveFolderItem: DriveFolderItem, relativePath: string): object { const driveFolderXML = { - [XMLUtils.addDefaultNamespace('href')]: relativePath, + [XMLUtils.addDefaultNamespace('href')]: encodeURIComponent(relativePath), [XMLUtils.addDefaultNamespace('propstat')]: { [XMLUtils.addDefaultNamespace('status')]: 'HTTP/1.1 200 OK', [XMLUtils.addDefaultNamespace('prop')]: { @@ -238,7 +230,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler { const displayName = `${driveFolderItem.name}`; const driveFolderXML = { - [XMLUtils.addDefaultNamespace('href')]: relativePath, + [XMLUtils.addDefaultNamespace('href')]: encodeURIComponent(relativePath), [XMLUtils.addDefaultNamespace('propstat')]: { [XMLUtils.addDefaultNamespace('status')]: 'HTTP/1.1 200 OK', [XMLUtils.addDefaultNamespace('prop')]: { @@ -259,7 +251,7 @@ export class PROPFINDRequestHandler implements WebDavMethodHandler { const displayName = driveFileItem.type ? `${driveFileItem.name}.${driveFileItem.type}` : driveFileItem.name; const driveFileXML = { - [XMLUtils.addDefaultNamespace('href')]: relativePath, + [XMLUtils.addDefaultNamespace('href')]: encodeURIComponent(relativePath), [XMLUtils.addDefaultNamespace('propstat')]: { [XMLUtils.addDefaultNamespace('status')]: 'HTTP/1.1 200 OK', [XMLUtils.addDefaultNamespace('prop')]: { diff --git a/test/webdav/handlers/PROPFIND.handler.test.ts b/test/webdav/handlers/PROPFIND.handler.test.ts index 58d93451..af4c10fe 100644 --- a/test/webdav/handlers/PROPFIND.handler.test.ts +++ b/test/webdav/handlers/PROPFIND.handler.test.ts @@ -1,16 +1,24 @@ import sinon from 'sinon'; import { PROPFINDRequestHandler } from '../../../src/webdav/handlers/PROPFIND.handler'; -import { ConfigService } from '../../../src/services/config.service'; import { DriveFolderService } from '../../../src/services/drive/drive-folder.service'; import { UserSettingsFixture } from '../../fixtures/auth.fixture'; -import { newFolderItem, newPaginatedFolder } from '../../fixtures/drive.fixture'; -import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../fixtures/webdav.fixture'; +import { newFileItem, newFolderItem, newPaginatedFolder } from '../../fixtures/drive.fixture'; import { - getDriveFileDatabaseFixture, - getDriveFolderDatabaseFixture, - getDriveDatabaseManager, -} from '../../fixtures/drive-database.fixture'; + createWebDavRequestFixture, + createWebDavResponseFixture, + getRequestedFileResource, + getRequestedFolderResource, +} from '../../fixtures/webdav.fixture'; +import { getDriveDatabaseManager } from '../../fixtures/drive-database.fixture'; import { FormatUtils } from '../../../src/utils/format.utils'; +import { DriveFileService } from '../../../src/services/drive/drive-file.service'; +import { WebDavRequestedResource } from '../../../src/types/webdav.types'; +import { WebDavUtils } from '../../../src/utils/webdav.utils'; +import { expect } from 'chai'; +import mime from 'mime-types'; +import crypto from 'crypto'; +import { NotFoundError } from '../../../src/utils/errors.utils'; +import { fail } from 'assert'; describe('PROPFIND request handler', () => { const sandbox = sinon.createSandbox(); @@ -20,211 +28,235 @@ describe('PROPFIND request handler', () => { }); it('When a WebDav client sends a PROPFIND request for the root folder, and there is no content, should return the correct XML', async () => { - const configService = ConfigService.instance; const driveFolderService = DriveFolderService.instance; - - sandbox.stub(configService, 'readUser').resolves({ - user: UserSettingsFixture, - root_folder_uuid: 'test_root_folder_uuid', - token: 'TOKEN', - newToken: 'NEW_TOKEN', - mnemonic: 'MNEMONIC', - }); - - const folderFixture = newFolderItem({ - id: UserSettingsFixture.root_folder_id, - }); - sandbox.stub(driveFolderService, 'getFolderMetaById').resolves(folderFixture); - sandbox.stub(driveFolderService, 'getFolderMetaByUuid').resolves(folderFixture); - sandbox.stub(driveFolderService, 'getFolderContent').resolves({ folders: [], files: [] }); + const driveFileService = DriveFileService.instance; const requestHandler = new PROPFINDRequestHandler( { debug: true }, { + driveFileService, driveFolderService, driveDatabaseManager: getDriveDatabaseManager(), }, ); + const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource({ + parentFolder: '/', + folderName: '', + }); const request = createWebDavRequestFixture({ - url: '/', method: 'PROPFIND', + url: '/', user: UserSettingsFixture, }); - const sendStub = sandbox.stub(); - const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sendStub }), }); + const folderFixture = newFolderItem({ + id: UserSettingsFixture.root_folder_id, + }); + + const getRequestedResourceStub = sandbox + .stub(WebDavUtils, 'getRequestedResource') + .resolves(requestedFolderResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .resolves(folderFixture); + const getFolderContentStub = sandbox + .stub(driveFolderService, 'getFolderContent') + .resolves({ folders: [], files: [] }); + await requestHandler.handle(request, response); - sinon.assert.calledWith(response.status, 207); - sinon.assert.calledWith( - sendStub, - `/HTTP/1.1 200 OKapplication/octet-stream${FormatUtils.formatDateForWebDav(folderFixture.updatedAt)}F00000030`, - ); + expect(response.status.calledWith(207)).to.be.true; + expect( + sendStub.calledWith( + `${encodeURIComponent('/')}HTTP/1.1 200 OKapplication/octet-stream${FormatUtils.formatDateForWebDav(folderFixture.updatedAt)}F00000030`, + ), + ).to.be.true; + expect(getRequestedResourceStub.calledOnce).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; + expect(getFolderContentStub.calledOnce).to.be.true; }); it('When a WebDav client sends a PROPFIND request for the root folder, and there is content, should return the correct XML', async () => { - const configService = ConfigService.instance; const driveFolderService = DriveFolderService.instance; - - sandbox.stub(configService, 'readUser').resolves({ - user: UserSettingsFixture, - root_folder_uuid: 'test_root_folder_uuid', - token: 'TOKEN', - newToken: 'NEW_TOKEN', - mnemonic: 'MNEMONIC', - }); - - const folderFixture = newFolderItem({ - id: UserSettingsFixture.root_folder_id, - }); - const paginatedFolder1 = newPaginatedFolder({ - plainName: 'folder_1', - updatedAt: new Date('2024-03-04T15:11:01.000Z'), - uuid: 'FOLDER_UUID_1', - }); - - sandbox.stub(driveFolderService, 'getFolderMetaById').resolves(folderFixture); - sandbox.stub(driveFolderService, 'getFolderMetaByUuid').resolves(folderFixture); - sandbox.stub(driveFolderService, 'getFolderContent').resolves({ - folders: [paginatedFolder1], - files: [], - }); + const driveFileService = DriveFileService.instance; const requestHandler = new PROPFINDRequestHandler( { debug: true }, { + driveFileService, driveFolderService, driveDatabaseManager: getDriveDatabaseManager(), }, ); + const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource({ + parentFolder: '/', + folderName: '', + }); const request = createWebDavRequestFixture({ - url: '/', method: 'PROPFIND', + url: '/', + user: UserSettingsFixture, }); - const sendStub = sandbox.stub(); const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sendStub }), }); + const folderFixture = newFolderItem({ + id: UserSettingsFixture.root_folder_id, + }); + const paginatedFolder1 = newPaginatedFolder({ + plainName: 'folder_1', + updatedAt: new Date('2024-03-04T15:11:01.000Z'), + uuid: 'FOLDER_UUID_1', + }); + + const getRequestedResourceStub = sandbox + .stub(WebDavUtils, 'getRequestedResource') + .resolves(requestedFolderResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .resolves(folderFixture); + const getFolderContentStub = sandbox.stub(driveFolderService, 'getFolderContent').resolves({ + files: [], + folders: [paginatedFolder1], + }); + await requestHandler.handle(request, response); - sinon.assert.calledWith(response.status, 207); - sinon.assert.calledWith( - sendStub, - `/HTTP/1.1 200 OKapplication/octet-stream${FormatUtils.formatDateForWebDav(folderFixture.updatedAt)}F00000030/${paginatedFolder1.plainName}/HTTP/1.1 200 OK${paginatedFolder1.plainName}${FormatUtils.formatDateForWebDav(paginatedFolder1.updatedAt)}0`, - ); + expect(response.status.calledWith(207)).to.be.true; + expect( + sendStub.calledWith( + `${encodeURIComponent('/')}HTTP/1.1 200 OKapplication/octet-stream${FormatUtils.formatDateForWebDav(folderFixture.updatedAt)}F00000030${encodeURIComponent(`/${paginatedFolder1.plainName}/`)}HTTP/1.1 200 OK${paginatedFolder1.plainName}${FormatUtils.formatDateForWebDav(paginatedFolder1.updatedAt)}0`, + ), + ).to.be.true; + expect(getRequestedResourceStub.calledOnce).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; + expect(getFolderContentStub.calledOnce).to.be.true; }); it('When a WebDav client sends a PROPFIND request for a file, should return the correct XML', async () => { - const configService = ConfigService.instance; const driveFolderService = DriveFolderService.instance; - - sandbox.stub(configService, 'readUser').resolves({ - user: UserSettingsFixture, - root_folder_uuid: 'test_root_folder_uuid', - token: 'TOKEN', - newToken: 'NEW_TOKEN', - mnemonic: 'MNEMONIC', - }); - - const driveDatabaseManager = getDriveDatabaseManager(); - sandbox.stub(driveDatabaseManager, 'findByRelativePath').resolves(getDriveFileDatabaseFixture()); - + const driveFileService = DriveFileService.instance; const requestHandler = new PROPFINDRequestHandler( { debug: true }, { + driveFileService, driveFolderService, - driveDatabaseManager, + driveDatabaseManager: getDriveDatabaseManager(), }, ); + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource({ + parentFolder: '/', + fileName: 'file', + fileType: 'png', + }); const request = createWebDavRequestFixture({ - url: '/file.png', method: 'PROPFIND', + url: '/file.png', + user: UserSettingsFixture, }); - const sendStub = sandbox.stub(); const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sendStub }), }); + const fileFixture = newFileItem({ name: 'file', type: 'png' }); + const uuidFixture = 'test-test-test-test-test'; + const etagFixture = uuidFixture.replaceAll('-', ''); + const mimeFixture = 'image/png'; + + const getRequestedResourceStub = sandbox.stub(WebDavUtils, 'getRequestedResource').resolves(requestedFileResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .resolves(fileFixture); + const randomUUIDStub = sandbox.stub(crypto, 'randomUUID').returns(uuidFixture); + const mimeLookupStub = sandbox.stub(mime, 'lookup').returns(mimeFixture); + await requestHandler.handle(request, response); - sinon.assert.calledWith(response.status, 207); - // TODO: Test the XML response + expect(response.status.calledWith(207)).to.be.true; + expect( + sendStub.calledWith( + `${encodeURIComponent(requestedFileResource.url)}HTTP/1.1 200 OK"${etagFixture}"${fileFixture.name + '.' + fileFixture.type}${mimeFixture}${FormatUtils.formatDateForWebDav(fileFixture.updatedAt)}${fileFixture.size}`, + ), + ).to.be.true; + expect(getRequestedResourceStub.calledOnce).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; + expect(randomUUIDStub.calledOnce).to.be.true; + expect(mimeLookupStub.calledOnce).to.be.true; }); it('When a WebDav client sends a PROPFIND request for a folder, should return the correct XML', async () => { - const configService = ConfigService.instance; const driveFolderService = DriveFolderService.instance; - - sandbox.stub(configService, 'readUser').resolves({ - user: UserSettingsFixture, - root_folder_uuid: 'test_root_folder_uuid', - token: 'TOKEN', - newToken: 'NEW_TOKEN', - mnemonic: 'MNEMONIC', - }); - - const driveDatabaseManager = getDriveDatabaseManager(); - const paginatedFolder1 = newPaginatedFolder(); - sandbox.stub(driveFolderService, 'getFolderContent').resolves({ - files: [], - folders: [paginatedFolder1], - }); - const folderFixture = newFolderItem(); - sandbox.stub(driveFolderService, 'getFolderMetaByUuid').resolves(folderFixture); - sandbox.stub(driveDatabaseManager, 'findByRelativePath').resolves(getDriveFolderDatabaseFixture()); + const driveFileService = DriveFileService.instance; const requestHandler = new PROPFINDRequestHandler( { debug: true }, { + driveFileService, driveFolderService, - driveDatabaseManager, + driveDatabaseManager: getDriveDatabaseManager(), }, ); + const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource({ + parentFolder: '/', + folderName: 'folder_a', + }); const request = createWebDavRequestFixture({ - url: '/folder_a/', method: 'PROPFIND', + url: '/folder_a/', + user: UserSettingsFixture, }); - const sendStub = sandbox.stub(); const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sendStub }), }); + const folderFixture = newFolderItem({ name: requestedFolderResource.name }); + const paginatedFolder1 = newPaginatedFolder(); + + const getRequestedResourceStub = sandbox + .stub(WebDavUtils, 'getRequestedResource') + .resolves(requestedFolderResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .resolves(folderFixture); + const getFolderContentStub = sandbox.stub(driveFolderService, 'getFolderContent').resolves({ + files: [], + folders: [paginatedFolder1], + }); + await requestHandler.handle(request, response); - sinon.assert.calledWith(response.status, 207); + expect(response.status.calledWith(207)).to.be.true; // TODO: Test the XML response + expect(getRequestedResourceStub.calledOnce).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; + expect(getFolderContentStub.calledOnce).to.be.true; }); it('When a WebDav client sends a PROPFIND request for a folder and it does not exists, should return a 404', async () => { - const configService = ConfigService.instance; const driveFolderService = DriveFolderService.instance; - - sandbox.stub(configService, 'readUser').resolves({ - user: UserSettingsFixture, - root_folder_uuid: 'test_root_folder_uuid', - token: 'TOKEN', - newToken: 'NEW_TOKEN', - mnemonic: 'MNEMONIC', - }); - - const driveDatabaseManager = getDriveDatabaseManager(); - sandbox.stub(driveDatabaseManager, 'findByRelativePath').resolves(null); + const driveFileService = DriveFileService.instance; const requestHandler = new PROPFINDRequestHandler( { debug: true }, { + driveFileService, driveFolderService, - driveDatabaseManager, + driveDatabaseManager: getDriveDatabaseManager(), }, ); + const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource({ + parentFolder: '/', + folderName: 'folder_a', + }); const request = createWebDavRequestFixture({ - url: '/folder_a/', method: 'PROPFIND', + url: '/folder_a/', + user: UserSettingsFixture, }); const sendStub = sandbox.stub(); @@ -232,7 +264,22 @@ describe('PROPFIND request handler', () => { status: sandbox.stub().returns({ send: sendStub }), }); - await requestHandler.handle(request, response); - sinon.assert.calledWith(response.status, 404); + const expectedError = new NotFoundError(`Resource not found on Internxt Drive at ${requestedFolderResource.url}`); + + const getRequestedResourceStub = sandbox + .stub(WebDavUtils, 'getRequestedResource') + .resolves(requestedFolderResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .throws(expectedError); + + try { + await requestHandler.handle(request, response); + fail('Expected function to throw an error, but it did not.'); + } catch (error) { + expect(error).to.be.instanceOf(NotFoundError); + } + expect(getRequestedResourceStub.calledOnce).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; }); }); From c53724485fa96b41ff4d803dee8cce77b2104a03 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Mon, 4 Nov 2024 19:24:00 +0100 Subject: [PATCH 17/26] fixed and improved PUT handler and tests --- src/webdav/handlers/PUT.handler.ts | 80 ++++++++------- src/webdav/webdav-server.ts | 5 +- test/webdav/handlers/PUT.handler.test.ts | 123 +++++++++++++++-------- 3 files changed, 129 insertions(+), 79 deletions(-) diff --git a/src/webdav/handlers/PUT.handler.ts b/src/webdav/handlers/PUT.handler.ts index 1a7f2bd2..4590075d 100644 --- a/src/webdav/handlers/PUT.handler.ts +++ b/src/webdav/handlers/PUT.handler.ts @@ -1,57 +1,76 @@ import { Request, Response } from 'express'; import { DriveFileService } from '../../services/drive/drive-file.service'; import { NetworkFacade } from '../../services/network/network-facade.service'; -import { UploadService } from '../../services/network/upload.service'; -import { DownloadService } from '../../services/network/download.service'; -import { CryptoService } from '../../services/crypto.service'; import { AuthService } from '../../services/auth.service'; -import { WebDavMethodHandler, WebDavRequestedResource } from '../../types/webdav.types'; +import { WebDavMethodHandler } from '../../types/webdav.types'; import { ConflictError, UnsupportedMediaTypeError } from '../../utils/errors.utils'; import { WebDavUtils } from '../../utils/webdav.utils'; import { webdavLogger } from '../../utils/logger.utils'; import { DriveDatabaseManager } from '../../services/database/drive-database-manager.service'; -import DriveFolderModel from '../../services/database/drive-folder/drive-folder.model'; +import { DriveFileItem, DriveFolderItem } from '../../types/drive.types'; +import { DriveFolderService } from '../../services/drive/drive-folder.service'; +import { TrashService } from '../../services/drive/trash.service'; export class PUTRequestHandler implements WebDavMethodHandler { constructor( private dependencies: { driveFileService: DriveFileService; + driveFolderService: DriveFolderService; driveDatabaseManager: DriveDatabaseManager; - uploadService: UploadService; - downloadService: DownloadService; - cryptoService: CryptoService; + trashService: TrashService; authService: AuthService; networkFacade: NetworkFacade; }, ) {} handle = async (req: Request, res: Response) => { + const { driveDatabaseManager, authService, networkFacade, driveFileService, driveFolderService, trashService } = + this.dependencies; const contentLength = Number(req.headers['content-length']); if (!contentLength || isNaN(contentLength) || contentLength <= 0) { throw new UnsupportedMediaTypeError('Empty files are not supported'); } - const resource = await WebDavUtils.getRequestedResource(req, this.dependencies.driveDatabaseManager); - const driveFolder = await this.getDriveFolderRealmObject(resource); + const resource = await WebDavUtils.getRequestedResource(req); + webdavLogger.info(`PUT request received for uploading file '${resource.name}' to '${resource.parentPath}'`); + const parentResource = await WebDavUtils.getRequestedResource(resource.parentPath); - webdavLogger.info(`PUT request received for uploading file '${resource.name}' to '${resource.path.dir}'`); - if (!driveFolder) { - throw new ConflictError('Drive destination folder not found'); + const parentFolderItem = (await WebDavUtils.getAndSearchItemFromResource({ + resource: parentResource, + driveDatabaseManager, + driveFolderService, + })) as DriveFolderItem; + + if (!parentFolderItem) { + throw new ConflictError(`Parent resource not found for parent path ${resource.parentPath}`); + } + + try { + // 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.getAndSearchItemFromResource({ + resource: resource, + driveDatabaseManager, + driveFileService, + })) as DriveFileItem; + if (driveFileItem && driveFileItem.status === 'EXISTS') { + webdavLogger.info(`File '${resource.name}' already exists in '${resource.path.dir}', trashing it before PUT`); + await trashService.trashItems({ + items: [{ type: resource.type, uuid: driveFileItem.uuid }], + }); + await driveDatabaseManager.deleteFileById(driveFileItem.id); + } + } catch (_) { + //noop } - const { user, mnemonic } = await this.dependencies.authService.getAuthDetails(); + const { user, mnemonic } = await authService.getAuthDetails(); - const [uploadPromise] = await this.dependencies.networkFacade.uploadFromStream( - user.bucket, - mnemonic, - contentLength, - req, - { - progressCallback: (progress) => { - webdavLogger.info(`Upload progress for file ${resource.name}: ${(100*progress).toFixed(2)}%`); - }, + const [uploadPromise] = await networkFacade.uploadFromStream(user.bucket, mnemonic, contentLength, req, { + progressCallback: (progress) => { + webdavLogger.info(`Upload progress for file ${resource.name}: ${(100 * progress).toFixed(2)}%`); }, - ); + }); const uploadResult = await uploadPromise; @@ -61,22 +80,15 @@ export class PUTRequestHandler implements WebDavMethodHandler { name: resource.path.name, type: resource.path.ext.replaceAll('.', ''), size: contentLength, - folderId: driveFolder.id, + folderId: parentFolderItem.id, fileId: uploadResult.fileId, bucket: user.bucket, }); webdavLogger.info('✅ File uploaded to internxt drive'); - await this.dependencies.driveDatabaseManager.createFile(file); + await driveDatabaseManager.createFile(file, resource.path.dir + '/'); - res.status(200); - res.send(); + res.status(200).send(); }; - - private async getDriveFolderRealmObject(resource: WebDavRequestedResource) { - const { driveDatabaseManager } = this.dependencies; - const result = await driveDatabaseManager.findByRelativePath(resource.path.dir); - return result as DriveFolderModel | null; - } } diff --git a/src/webdav/webdav-server.ts b/src/webdav/webdav-server.ts index 788dbec2..13a30102 100644 --- a/src/webdav/webdav-server.ts +++ b/src/webdav/webdav-server.ts @@ -107,11 +107,10 @@ export class WebDavServer { asyncHandler( new PUTRequestHandler({ driveFileService: this.driveFileService, + driveFolderService: this.driveFolderService, driveDatabaseManager: this.driveDatabaseManager, - uploadService: this.uploadService, - downloadService: this.downloadService, - cryptoService: this.cryptoService, authService: this.authService, + trashService: this.trashService, networkFacade: networkFacade, }).handle, ), diff --git a/test/webdav/handlers/PUT.handler.test.ts b/test/webdav/handlers/PUT.handler.test.ts index be9574c6..82b2e2df 100644 --- a/test/webdav/handlers/PUT.handler.test.ts +++ b/test/webdav/handlers/PUT.handler.test.ts @@ -1,7 +1,11 @@ import sinon from 'sinon'; -import { createWebDavRequestFixture, createWebDavResponseFixture } from '../../fixtures/webdav.fixture'; +import { + createWebDavRequestFixture, + createWebDavResponseFixture, + getRequestedFileResource, + getRequestedFolderResource, +} from '../../fixtures/webdav.fixture'; import { DriveFileService } from '../../../src/services/drive/drive-file.service'; - import { CryptoService } from '../../../src/services/crypto.service'; import { DownloadService } from '../../../src/services/network/download.service'; import { UploadService } from '../../../src/services/network/upload.service'; @@ -12,11 +16,13 @@ import { SdkManager } from '../../../src/services/sdk-manager.service'; import { NetworkFacade } from '../../../src/services/network/network-facade.service'; import { UserFixture } from '../../fixtures/auth.fixture'; import { PUTRequestHandler } from '../../../src/webdav/handlers/PUT.handler'; -import { - getDriveDatabaseManager, - getDriveFileDatabaseFixture, - getDriveFolderDatabaseFixture, -} from '../../fixtures/drive-database.fixture'; +import { getDriveDatabaseManager } from '../../fixtures/drive-database.fixture'; +import { fail } from 'assert'; +import { DriveFolderService } from '../../../src/services/drive/drive-folder.service'; +import { TrashService } from '../../../src/services/drive/trash.service'; +import { WebDavRequestedResource } from '../../../src/types/webdav.types'; +import { WebDavUtils } from '../../../src/utils/webdav.utils'; +import { newFolderItem } from '../../fixtures/drive.fixture'; describe('PUT request handler', () => { const sandbox = sinon.createSandbox(); @@ -40,11 +46,10 @@ describe('PUT request handler', () => { ); const sut = new PUTRequestHandler({ driveFileService: DriveFileService.instance, - uploadService: UploadService.instance, - downloadService: DownloadService.instance, + driveFolderService: DriveFolderService.instance, driveDatabaseManager: getDriveDatabaseManager(), authService: AuthService.instance, - cryptoService: CryptoService.instance, + trashService: TrashService.instance, networkFacade, }); @@ -62,7 +67,7 @@ describe('PUT request handler', () => { try { await sut.handle(request, response); - expect(true).to.be.false; + fail('Expected function to throw an error, but it did not.'); } catch (error) { expect(error).to.be.instanceOf(UnsupportedMediaTypeError); } @@ -76,33 +81,52 @@ describe('PUT request handler', () => { const networkFacade = new NetworkFacade(getNetworkMock(), uploadService, downloadService, cryptoService); const sut = new PUTRequestHandler({ driveFileService: DriveFileService.instance, - uploadService, - downloadService, - driveDatabaseManager, + driveFolderService: DriveFolderService.instance, authService: AuthService.instance, - cryptoService, + trashService: TrashService.instance, networkFacade, + driveDatabaseManager, + }); + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); + const requestedParentFolderResource: WebDavRequestedResource = getRequestedFolderResource({ + parentFolder: '/', + folderName: '', }); const request = createWebDavRequestFixture({ method: 'PUT', - url: '/file.txt', + url: requestedFileResource.url, headers: { 'content-length': '100', }, }); - sandbox.stub(driveDatabaseManager, 'findByRelativePath').resolves(null); const response = createWebDavResponseFixture({ status: sandbox.stub().returns({ send: sandbox.stub() }), }); + const expectedError = new ConflictError( + `Parent resource not found for parent path ${requestedParentFolderResource.parentPath}`, + ); + + const getRequestedResourceStub = sandbox + .stub(WebDavUtils, 'getRequestedResource') + .onFirstCall() + .resolves(requestedFileResource) + .onSecondCall() + .resolves(requestedParentFolderResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .throws(expectedError); + try { await sut.handle(request, response); - expect(true).to.be.false; + fail('Expected function to throw an error, but it did not.'); } catch (error) { expect(error).to.be.instanceOf(ConflictError); } + expect(getRequestedResourceStub.calledTwice).to.be.true; + expect(getAndSearchItemFromResourceStub.calledOnce).to.be.true; }); it('When a WebDav client sends a PUT request, and the Drive destination folder is found, then it should upload the file to the folder', async () => { @@ -114,46 +138,61 @@ describe('PUT request handler', () => { const networkFacade = new NetworkFacade(getNetworkMock(), uploadService, downloadService, cryptoService); const sut = new PUTRequestHandler({ driveFileService: DriveFileService.instance, - uploadService, - downloadService, - driveDatabaseManager, - authService, - cryptoService, + driveFolderService: DriveFolderService.instance, + authService: AuthService.instance, + trashService: TrashService.instance, networkFacade, + driveDatabaseManager, + }); + + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); + const requestedParentFolderResource: WebDavRequestedResource = getRequestedFolderResource({ + parentFolder: '/', + folderName: '', }); + const folderFixture = newFolderItem({ name: requestedParentFolderResource.name }); const request = createWebDavRequestFixture({ method: 'PUT', - url: '/file.txt', + url: requestedFileResource.url, headers: { - 'content-length': '150', + 'content-length': '100', }, }); - const driveFileDBObject = getDriveFileDatabaseFixture({ name: 'file' }); - const driveFolderDBObject = getDriveFolderDatabaseFixture(); + const sendStub = sandbox.stub(); + const response = createWebDavResponseFixture({ + status: sandbox.stub().returns({ send: sendStub }), + }); - sandbox - .stub(driveDatabaseManager, 'findByRelativePath') - .withArgs('/file.txt') - .resolves(driveFileDBObject) - .withArgs('/') - .resolves(driveFolderDBObject); - sandbox + const getRequestedResourceStub = sandbox + .stub(WebDavUtils, 'getRequestedResource') + .onFirstCall() + .resolves(requestedFileResource) + .onSecondCall() + .resolves(requestedParentFolderResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .onFirstCall() + .resolves(folderFixture) + .onSecondCall() + .rejects(); + const getAuthDetailsStub = sandbox .stub(authService, 'getAuthDetails') .resolves({ mnemonic: 'MNEMONIC', token: 'TOKEN', newToken: 'NEW_TOKEN', user: UserFixture }); - - sandbox + const uploadFromStreamStub = sandbox .stub(networkFacade, 'uploadFromStream') .resolves([Promise.resolve({ fileId: '09218313209', hash: Buffer.from('test') }), new AbortController()]); - sandbox.stub(DriveFileService.instance, 'createFile').resolves(); - sandbox.stub(driveDatabaseManager, 'createFile').resolves(); - - const response = createWebDavResponseFixture({ - status: sandbox.stub().returns({ send: sandbox.stub() }), - }); + const createDriveFileStub = sandbox.stub(DriveFileService.instance, 'createFile').resolves(); + const createDBFileStub = sandbox.stub(driveDatabaseManager, 'createFile').resolves(); await sut.handle(request, response); expect(response.status.calledWith(200)).to.be.true; + expect(getRequestedResourceStub.calledTwice).to.be.true; + expect(getAndSearchItemFromResourceStub.calledTwice).to.be.true; + expect(getAuthDetailsStub.calledOnce).to.be.true; + expect(uploadFromStreamStub.calledOnce).to.be.true; + expect(createDriveFileStub.calledOnce).to.be.true; + expect(createDBFileStub.calledOnce).to.be.true; }); }); From 5905aca4e2f9d6afd9e38f6228e760ab0eb47c8a Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 5 Nov 2024 14:34:19 +0100 Subject: [PATCH 18/26] added config command to allow changing webdav protocol and port --- .env.template | 1 - src/commands/webdav-config.ts | 106 +++++++++++++++++++++++ src/commands/webdav.ts | 5 +- src/hooks/prerun/auth_check.ts | 3 +- src/services/config.service.ts | 27 +++++- src/services/drive/drive-file.service.ts | 2 +- src/services/validation.service.ts | 4 + src/types/command.types.ts | 13 +++ src/types/config.types.ts | 1 - src/webdav/webdav-server.ts | 21 ++++- test/webdav/webdav-server.test.ts | 64 ++++++++++++-- 11 files changed, 228 insertions(+), 19 deletions(-) create mode 100644 src/commands/webdav-config.ts diff --git a/.env.template b/.env.template index 78d7e5bb..4baea95c 100644 --- a/.env.template +++ b/.env.template @@ -8,6 +8,5 @@ APP_CRYPTO_SECRET=6KYQBP847D4ATSFA APP_CRYPTO_SECRET2=8Q8VMUE3BJZV87GT APP_MAGIC_IV=d139cb9a2cd17092e79e1861cf9d7023 APP_MAGIC_SALT=38dce0391b49efba88dbc8c39ebf868f0267eb110bb0012ab27dc52a528d61b1d1ed9d76f400ff58e3240028442b1eab9bb84e111d9dadd997982dbde9dbd25e -WEBDAV_SERVER_PORT=3005 RUDDERSTACK_WRITE_KEY= RUDDERSTACK_DATAPLANE_URL= \ No newline at end of file diff --git a/src/commands/webdav-config.ts b/src/commands/webdav-config.ts new file mode 100644 index 00000000..388c1bf7 --- /dev/null +++ b/src/commands/webdav-config.ts @@ -0,0 +1,106 @@ +import { Args, Command, Flags } from '@oclif/core'; +import { ConfigService } from '../services/config.service'; +import { CLIUtils } from '../utils/cli.utils'; +import { ErrorUtils } from '../utils/errors.utils'; +import { NotValidPortError } from '../types/command.types'; +import { ValidationService } from '../services/validation.service'; + +export default class WebDAVConfig extends Command { + static readonly description = 'Edit the configuration of the Internxt CLI WebDav server as the port or the protocol.'; + static readonly args = { + action: Args.string({ + required: true, + options: ['set-http', 'set-https', 'change-port'], + }), + }; + static readonly examples = [ + '<%= config.bin %> <%= command.id %> set-http', + '<%= config.bin %> <%= command.id %> set-https', + '<%= config.bin %> <%= command.id %> change-port', + ]; + static readonly flags = { + ...CLIUtils.CommonFlags, + port: Flags.string({ + char: 'p', + description: 'The new port that the WebDAV server is going to be have.', + required: false, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(WebDAVConfig); + const nonInteractive = flags['non-interactive']; + const webdavConfig = await ConfigService.instance.readWebdavConfig(); + + if (args.action !== 'change-port' && flags['port']) { + CLIUtils.warning('The port flag is going to be ignored, it can only be used with the action "change-port"'); + } + + switch (args.action) { + case 'set-http': { + await ConfigService.instance.saveWebdavConfig({ + ...webdavConfig, + protocol: 'http', + }); + CLIUtils.success('For the next start, the Webdav server will be served using HTTP'); + break; + } + + case 'set-https': { + await ConfigService.instance.saveWebdavConfig({ + ...webdavConfig, + protocol: 'https', + }); + CLIUtils.success('For the next start, the Webdav server will be served using HTTPS'); + break; + } + + case 'change-port': { + const newPort = await this.getWebDAVPort(flags['port'], nonInteractive); + await ConfigService.instance.saveWebdavConfig({ + ...webdavConfig, + port: newPort, + }); + CLIUtils.success('For the next start, the Webdav server will be served at the new port: ' + newPort); + break; + } + } + } + + async catch(error: Error) { + ErrorUtils.report(error, { command: this.id }); + CLIUtils.error(error.message); + this.exit(1); + } + + private static readonly MAX_ATTEMPTS = 3; + + public getWebDAVPort = async (webdavPortFlag: string | undefined, nonInteractive: boolean): Promise => { + let port = CLIUtils.getValueFromFlag( + { + value: webdavPortFlag, + name: WebDAVConfig.flags['port'].name, + error: new NotValidPortError(), + canBeEmpty: true, + }, + nonInteractive, + (port: string) => ValidationService.instance.validateTCPIntegerPort(port), + ); + if (!port) { + port = (await this.getNewWebDAVPortInteractively()).trim(); + } + return port; + }; + + public getNewWebDAVPortInteractively = (): Promise => { + return CLIUtils.promptWithAttempts( + { + message: 'What is the new WebDAV server port?', + options: { required: false }, + error: new NotValidPortError(), + }, + WebDAVConfig.MAX_ATTEMPTS, + (port: string) => ValidationService.instance.validateTCPIntegerPort(port), + ); + }; +} diff --git a/src/commands/webdav.ts b/src/commands/webdav.ts index 09d631fc..0e95c791 100644 --- a/src/commands/webdav.ts +++ b/src/commands/webdav.ts @@ -33,14 +33,15 @@ export default class Webdav extends Command { await PM2Utils.startWebDavServer(); CLIUtils.done(); const { status } = await PM2Utils.webdavServerStatus(); + const webdavConfigs = await ConfigService.instance.readWebdavConfig(); if (status === 'online') { ux.log(`\nWebDav server status: ${ux.colorize('green', 'online')}\n`); CLIUtils.success( - `Internxt WebDav server started successfully on https://${ConfigService.WEBDAV_LOCAL_URL}:${process.env.WEBDAV_SERVER_PORT}`, + `Internxt WebDav server started successfully at ${webdavConfigs.protocol}://${ConfigService.WEBDAV_LOCAL_URL}:${webdavConfigs.port}`, ); ux.log( - `\n[If the above URL is not working, the WebDAV server can be accessed directly via the localhost IP at: https://127.0.0.1:${process.env.WEBDAV_SERVER_PORT} ]\n`, + `\n[If the above URL is not working, the WebDAV server can be accessed directly via your localhost IP at: ${webdavConfigs.protocol}://127.0.0.1:${webdavConfigs.port} ]\n`, ); const authDetails = await AuthService.instance.getAuthDetails(); await AnalyticsService.instance.track('WebDAVEnabled', { app: 'internxt-cli', userId: authDetails.user.uuid }); diff --git a/src/hooks/prerun/auth_check.ts b/src/hooks/prerun/auth_check.ts index c5ec484b..28083bff 100644 --- a/src/hooks/prerun/auth_check.ts +++ b/src/hooks/prerun/auth_check.ts @@ -8,8 +8,9 @@ import { SdkManager } from '../../services/sdk-manager.service'; import { AuthService } from '../../services/auth.service'; import { MissingCredentialsError } from '../../types/command.types'; import Webdav from '../../commands/webdav'; +import WebDAVConfig from '../../commands/webdav-config'; -const CommandsToSkip = [Whoami, Login, Logout, Logs, Webdav]; +const CommandsToSkip = [Whoami, Login, Logout, Logs, Webdav, WebDAVConfig]; const hook: Hook<'prerun'> = async function (opts) { if (!CommandsToSkip.map((command) => command.name).includes(opts.Command.name)) { CLIUtils.doing('Checking credentials'); diff --git a/src/services/config.service.ts b/src/services/config.service.ts index 2c6bbe42..697e3777 100644 --- a/src/services/config.service.ts +++ b/src/services/config.service.ts @@ -2,7 +2,7 @@ import path from 'path'; import os from 'os'; import fs from 'fs/promises'; import { ConfigKeys } from '../types/config.types'; -import { CLICredentials } from '../types/command.types'; +import { CLICredentials, WebdavConfig } from '../types/command.types'; import { CryptoService } from './crypto.service'; export class ConfigService { @@ -12,7 +12,10 @@ export class ConfigService { static readonly CREDENTIALS_FILE = path.join(this.INTERNXT_CLI_DATA_DIR, '.inxtcli'); static readonly DRIVE_SQLITE_FILE = path.join(this.INTERNXT_CLI_DATA_DIR, 'internxt-cli-drive.sqlite'); static readonly WEBDAV_SSL_CERTS_DIR = path.join(this.INTERNXT_CLI_DATA_DIR, 'certs'); + static readonly WEBDAV_CONFIGS_FILE = path.join(this.INTERNXT_CLI_DATA_DIR, 'config.webdav.inxt'); static readonly WEBDAV_LOCAL_URL = 'webdav.local.internxt.com'; + static readonly WEBDAV_DEFAULT_PORT = '3005'; + static readonly WEBDAV_DEFAULT_PROTOCOL = 'https'; public static readonly instance: ConfigService = new ConfigService(); /** @@ -71,6 +74,28 @@ export class ConfigService { } }; + public saveWebdavConfig = async (webdavConfig: WebdavConfig): Promise => { + await this.ensureInternxtCliDataDirExists(); + const configs = JSON.stringify(webdavConfig); + await fs.writeFile(ConfigService.WEBDAV_CONFIGS_FILE, configs, 'utf8'); + }; + + public readWebdavConfig = async (): Promise => { + try { + const configsData = await fs.readFile(ConfigService.WEBDAV_CONFIGS_FILE, 'utf8'); + const configs = JSON.parse(configsData); + return { + port: configs?.port ?? ConfigService.WEBDAV_DEFAULT_PORT, + protocol: configs?.protocol ?? ConfigService.WEBDAV_DEFAULT_PROTOCOL, + }; + } catch { + return { + port: ConfigService.WEBDAV_DEFAULT_PORT, + protocol: ConfigService.WEBDAV_DEFAULT_PROTOCOL, + }; + } + }; + ensureInternxtCliDataDirExists = async () => { try { await fs.access(ConfigService.INTERNXT_CLI_DATA_DIR); diff --git a/src/services/drive/drive-file.service.ts b/src/services/drive/drive-file.service.ts index 0721b1aa..6a398488 100644 --- a/src/services/drive/drive-file.service.ts +++ b/src/services/drive/drive-file.service.ts @@ -68,7 +68,7 @@ export class DriveFileService { public renameFile = (fileUuid: string, payload: { plainName?: string; type?: string | null }): Promise => { const storageClient = SdkManager.instance.getStorage(true); - return storageClient.updateFileMetaWithUUID(fileUuid, payload); + return storageClient.updateFileMetaByUUID(fileUuid, payload); }; public getFileMetadataByPath = async (path: string): Promise => { diff --git a/src/services/validation.service.ts b/src/services/validation.service.ts index 5005450d..51477f17 100644 --- a/src/services/validation.service.ts +++ b/src/services/validation.service.ts @@ -23,4 +23,8 @@ export class ValidationService { public validateYesOrNoString = (message: string): boolean => { return message.length > 0 && /^(yes|no|y|n)$/i.test(message.toLowerCase().trim()); }; + + public validateTCPIntegerPort = (port: string): boolean => { + return /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535; + }; } diff --git a/src/types/command.types.ts b/src/types/command.types.ts index eed27cb5..a648f7e9 100644 --- a/src/types/command.types.ts +++ b/src/types/command.types.ts @@ -11,6 +11,11 @@ export interface CLICredentials extends LoginCredentials { root_folder_uuid: string; } +export interface WebdavConfig { + port: string; + protocol: 'http' | 'https'; +} + export class NotValidEmailError extends Error { constructor() { super('Email is not valid'); @@ -91,6 +96,14 @@ export class EmptyItemNameError extends Error { } } +export class NotValidPortError extends Error { + constructor() { + super('Port should be a number between 1 and 65535'); + + Object.setPrototypeOf(this, NotValidPortError.prototype); + } +} + export type PaginatedItem = { plainName: string; uuid: string; diff --git a/src/types/config.types.ts b/src/types/config.types.ts index 864e7334..9cc43e16 100644 --- a/src/types/config.types.ts +++ b/src/types/config.types.ts @@ -9,7 +9,6 @@ export interface ConfigKeys { readonly APP_MAGIC_IV: string; readonly APP_MAGIC_SALT: string; readonly NETWORK_URL: string; - readonly WEBDAV_SERVER_PORT: string; readonly RUDDERSTACK_WRITE_KEY: string; readonly RUDDERSTACK_DATAPLANE_URL: string; } diff --git a/src/webdav/webdav-server.ts b/src/webdav/webdav-server.ts index 13a30102..d553a5ba 100644 --- a/src/webdav/webdav-server.ts +++ b/src/webdav/webdav-server.ts @@ -1,5 +1,6 @@ import { Express } from 'express'; import https from 'https'; +import http from 'http'; import { ConfigService } from '../services/config.service'; import { OPTIONSRequestHandler } from './handlers/OPTIONS.handler'; import { PROPFINDRequestHandler } from './handlers/PROPFIND.handler'; @@ -151,15 +152,27 @@ export class WebDavServer { }; async start() { - const port = this.configService.get('WEBDAV_SERVER_PORT'); + const configs = await this.configService.readWebdavConfig(); this.app.disable('x-powered-by'); await this.registerMiddlewares(); await this.registerHandlers(); - const server = https.createServer(NetworkUtils.getWebdavSSLCerts(), this.app).listen(port, () => { - webdavLogger.info(`Internxt WebDav server listening at https://${ConfigService.WEBDAV_LOCAL_URL}:${port}`); - }); + const plainHttp = configs.protocol === 'http'; + let server: http.Server | https.Server; + if (plainHttp) { + server = http.createServer(this.app); + } else { + const httpsCerts = NetworkUtils.getWebdavSSLCerts(); + server = https.createServer(httpsCerts, this.app); + } + // Allow long uploads/downloads from WebDAV clients (up to 15 minutes before closing connection): server.requestTimeout = 15 * 60 * 1000; + + server.listen(configs.port, () => { + webdavLogger.info( + `Internxt WebDav server listening at ${configs.protocol}://${ConfigService.WEBDAV_LOCAL_URL}:${configs.port}`, + ); + }); } } diff --git a/test/webdav/webdav-server.test.ts b/test/webdav/webdav-server.test.ts index e127df4d..23de3945 100644 --- a/test/webdav/webdav-server.test.ts +++ b/test/webdav/webdav-server.test.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import express from 'express'; import sinon from 'sinon'; import { randomBytes, randomInt } from 'crypto'; +import http from 'http'; import https from 'https'; import { ConfigService } from '../../src/services/config.service'; import { DriveFolderService } from '../../src/services/drive/drive-folder.service'; @@ -12,10 +13,10 @@ import { DriveFileService } from '../../src/services/drive/drive-file.service'; import { DownloadService } from '../../src/services/network/download.service'; import { AuthService } from '../../src/services/auth.service'; import { CryptoService } from '../../src/services/crypto.service'; -import { ConfigKeys } from '../../src/types/config.types'; import { NetworkUtils } from '../../src/utils/network.utils'; import { TrashService } from '../../src/services/drive/trash.service'; import { UserSettingsFixture } from '../fixtures/auth.fixture'; +import { WebdavConfig } from '../../src/types/command.types'; describe('WebDav server', () => { const sandbox = sinon.createSandbox(); @@ -24,10 +25,10 @@ describe('WebDav server', () => { sandbox.restore(); }); - it('When the WebDav server is started, it should generate self-signed certificates', async () => { - const envEndpoint: { key: keyof ConfigKeys; value: string } = { - key: 'WEBDAV_SERVER_PORT', - value: randomInt(65535).toString(), + it('When the WebDav server is started with https, it should generate self-signed certificates', async () => { + const webdavConfig: WebdavConfig = { + port: randomInt(65535).toString(), + protocol: 'https', }; const sslSelfSigned = { private: randomBytes(8).toString('hex'), @@ -36,7 +37,7 @@ describe('WebDav server', () => { fingerprint: randomBytes(8).toString('hex'), }; - sandbox.stub(ConfigService.instance, 'get').withArgs(envEndpoint.key).returns(envEndpoint.value); + sandbox.stub(ConfigService.instance, 'readWebdavConfig').resolves(webdavConfig); sandbox.stub(ConfigService.instance, 'readUser').resolves({ token: 'TOKEN', newToken: 'NEW_TOKEN', @@ -45,9 +46,13 @@ describe('WebDav server', () => { user: UserSettingsFixture, }); // @ts-expect-error - We stub the method partially - const createServerStub = sandbox.stub(https, 'createServer').returns({ + const createHTTPSServerStub = sandbox.stub(https, 'createServer').returns({ listen: sandbox.stub().resolves(), }); + // @ts-expect-error - We stub the method partially + const createHTTPServerStub = sandbox.stub(http, 'createServer').returns({ + listen: sandbox.stub().rejects(), + }); sandbox.stub(NetworkUtils, 'getWebdavSSLCerts').returns({ cert: sslSelfSigned.cert, key: sslSelfSigned.private }); const app = express(); @@ -65,6 +70,49 @@ describe('WebDav server', () => { ); await server.start(); - expect(createServerStub).to.be.calledOnceWith({ cert: sslSelfSigned.cert, key: sslSelfSigned.private }); + expect(createHTTPSServerStub).to.be.calledOnceWith({ cert: sslSelfSigned.cert, key: sslSelfSigned.private }); + expect(createHTTPServerStub.called).to.be.false; + }); + + it('When the WebDav server is started with http, it should run http', async () => { + const webdavConfig: WebdavConfig = { + port: randomInt(65535).toString(), + protocol: 'http', + }; + + sandbox.stub(ConfigService.instance, 'readWebdavConfig').resolves(webdavConfig); + sandbox.stub(ConfigService.instance, 'readUser').resolves({ + token: 'TOKEN', + newToken: 'NEW_TOKEN', + mnemonic: 'MNEMONIC', + root_folder_uuid: 'ROOT_FOLDER_UUID', + user: UserSettingsFixture, + }); + // @ts-expect-error - We stub the method partially + const createHTTPServerStub = sandbox.stub(http, 'createServer').returns({ + listen: sandbox.stub().resolves(), + }); + // @ts-expect-error - We stub the method partially + const createHTTPSServerStub = sandbox.stub(https, 'createServer').returns({ + listen: sandbox.stub().rejects(), + }); + + const app = express(); + const server = new WebDavServer( + app, + ConfigService.instance, + DriveFileService.instance, + DriveFolderService.instance, + getDriveDatabaseManager(), + UploadService.instance, + DownloadService.instance, + AuthService.instance, + CryptoService.instance, + TrashService.instance, + ); + await server.start(); + + expect(createHTTPServerStub.calledOnce).to.be.true; + expect(createHTTPSServerStub.called).to.be.false; }); }); From 38f93da2a90fff3adb0768a4ebde47cedd6f6567 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 5 Nov 2024 14:36:10 +0100 Subject: [PATCH 19/26] renew selfsigned ssl certs if they are expired --- src/types/network.types.ts | 5 ++++ src/utils/network.utils.ts | 35 ++++++++++++++++++++------- test/utils/network.utils.test.ts | 41 +++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/types/network.types.ts b/src/types/network.types.ts index f2f9cb5b..7a668dcc 100644 --- a/src/types/network.types.ts +++ b/src/types/network.types.ts @@ -11,3 +11,8 @@ export interface NetworkOperationBaseOptions { export type UploadOptions = NetworkOperationBaseOptions; export type DownloadOptions = NetworkOperationBaseOptions; + +export interface SelfsignedCert { + cert: string | Buffer; + key: string | Buffer; +} diff --git a/src/utils/network.utils.ts b/src/utils/network.utils.ts index 0267ae09..c0bb3b7d 100644 --- a/src/utils/network.utils.ts +++ b/src/utils/network.utils.ts @@ -1,5 +1,5 @@ -import { NetworkCredentials } from '../types/network.types'; -import { createHash } from 'node:crypto'; +import { NetworkCredentials, SelfsignedCert } from '../types/network.types'; +import { createHash, X509Certificate } from 'node:crypto'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import selfsigned from 'selfsigned'; @@ -18,19 +18,36 @@ export class NetworkUtils { privateKey: path.join(ConfigService.WEBDAV_SSL_CERTS_DIR, 'priv.key'), }; + static generateNewSelfsignedCerts(): SelfsignedCert { + const newCerts = this.generateSelfSignedSSLCerts(); + this.saveWebdavSSLCerts(newCerts); + return { + cert: newCerts.cert, + key: newCerts.private, + }; + } + static getWebdavSSLCerts() { if (!existsSync(this.WEBDAV_SSL_CERTS_PATH.cert) || !existsSync(this.WEBDAV_SSL_CERTS_PATH.privateKey)) { - const newCerts = this.generateSelfSignedSSLCerts(); - this.saveWebdavSSLCerts(newCerts); - return { - cert: newCerts.cert, - key: newCerts.private, - }; + return this.generateNewSelfsignedCerts(); } else { - return { + let selfsignedCert: SelfsignedCert = { cert: readFileSync(this.WEBDAV_SSL_CERTS_PATH.cert), key: readFileSync(this.WEBDAV_SSL_CERTS_PATH.privateKey), }; + + const { validTo } = new X509Certificate(selfsignedCert.cert); + const dateToday = new Date(); + const dateValid = new Date(validTo); + + if (dateToday > dateValid) { + const newCerts = this.generateNewSelfsignedCerts(); + selfsignedCert = { + cert: newCerts.cert, + key: newCerts.key, + }; + } + return selfsignedCert; } } diff --git a/test/utils/network.utils.test.ts b/test/utils/network.utils.test.ts index def5a1b7..23c0614e 100644 --- a/test/utils/network.utils.test.ts +++ b/test/utils/network.utils.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import Sinon, { SinonSandbox } from 'sinon'; import fs from 'fs'; -import { randomBytes } from 'crypto'; +import crypto, { randomBytes } from 'crypto'; import selfsigned from 'selfsigned'; import { NetworkUtils } from '../../src/utils/network.utils'; @@ -64,9 +64,48 @@ describe('Network utils', () => { .returns(sslMock.cert) .withArgs(NetworkUtils.WEBDAV_SSL_CERTS_PATH.privateKey) .returns(sslMock.private); + const future = new Date(); + future.setDate(future.getDate() + 1); + networkUtilsSandbox.stub(crypto, 'X509Certificate').returns({ validTo: future }); const result = NetworkUtils.getWebdavSSLCerts(); expect(result).to.deep.equal({ cert: sslMock.cert, key: sslMock.private }); }); + + it('When webdav ssl certs are required and they exist, but they are expired, then they are generated and saved to files', async () => { + const sslSelfSigned: selfsigned.GenerateResult = { + private: randomBytes(8).toString('hex'), + public: randomBytes(8).toString('hex'), + cert: randomBytes(8).toString('hex'), + fingerprint: randomBytes(8).toString('hex'), + }; + const sslMock = { + private: randomBytes(8).toString('hex'), + cert: randomBytes(8).toString('hex'), + }; + networkUtilsSandbox.stub(fs, 'existsSync').returns(true); + const stubSaveCerts = networkUtilsSandbox.stub(fs, 'writeFileSync').returns(); + + networkUtilsSandbox + .stub(fs, 'readFileSync') + .withArgs(NetworkUtils.WEBDAV_SSL_CERTS_PATH.cert) + .returns(sslMock.cert) + .withArgs(NetworkUtils.WEBDAV_SSL_CERTS_PATH.privateKey) + .returns(sslMock.private); + const past = new Date(); + past.setDate(past.getDate() - 1); + networkUtilsSandbox.stub(crypto, 'X509Certificate').returns({ validTo: past }); + + const stubGerateSelfsignedCerts = networkUtilsSandbox + .stub(selfsigned, 'generate') + // @ts-expect-error - We stub the stat method partially + .returns(sslSelfSigned); + + const result = NetworkUtils.getWebdavSSLCerts(); + + expect(result).to.deep.equal({ cert: sslSelfSigned.cert, key: sslSelfSigned.private }); + expect(stubGerateSelfsignedCerts.calledOnce).to.be.true; + expect(stubSaveCerts.calledTwice).to.be.true; + }); }); From 5d21d41afed40a4865a68aac4564924908d34171 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 5 Nov 2024 16:25:04 +0100 Subject: [PATCH 20/26] updated readme --- README.md | 29 +++++++++++++++++++++++++++++ WEBDAV.md | 7 +++---- src/commands/webdav-config.ts | 6 +++--- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c114135c..77f432a9 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ USAGE * [`internxt trash restore`](#internxt-trash-restore-1) * [`internxt upload`](#internxt-upload) * [`internxt webdav ACTION`](#internxt-webdav-action) +* [`internxt webdav-config ACTION`](#internxt-webdav-config-action) * [`internxt whoami`](#internxt-whoami) ## `internxt add-cert` @@ -544,6 +545,34 @@ EXAMPLES _See code: [src/commands/webdav.ts](https://github.com/internxt/cli/blob/v1.3.0/src/commands/webdav.ts)_ +## `internxt webdav-config ACTION` + +Edit the configuration of the Internxt CLI WebDav server as the port or the protocol. + +``` +USAGE + $ internxt webdav-config ACTION [-n] [-p ] + +FLAGS + -p, --port= The new port that the WebDAV server is going to be have. + +HELPER FLAGS + -n, --non-interactive Blocks the cli from being interactive. If passed, the cli will not request data through the + console and will throw errors directly + +DESCRIPTION + Edit the configuration of the Internxt CLI WebDav server as the port or the protocol. + +EXAMPLES + $ internxt webdav-config set-http + + $ internxt webdav-config set-https + + $ internxt webdav-config change-port +``` + +_See code: [src/commands/webdav-config.ts](https://github.com/internxt/cli/blob/v1.3.0/src/commands/webdav-config.ts)_ + ## `internxt whoami` Display the current user logged into the Internxt CLI. diff --git a/WEBDAV.md b/WEBDAV.md index 2c388c31..f9c0c730 100644 --- a/WEBDAV.md +++ b/WEBDAV.md @@ -38,14 +38,13 @@ Find below the methods that are supported in the latest version of the Internxt | OPTIONS | ✅ | | GET | ✅ | | HEAD | ✅ | -| POST | ❌ | | PUT | ✅ | | DELETE | ✅ | | PROPFIND | ✅ | | PROPPATCH | ❌ | | MKCOL | ✅ | | COPY | ❌ | -| MOVE | ❌ | +| MOVE | ✅ | ## Requisites @@ -61,6 +60,6 @@ Find below the methods that are supported in the latest version of the Internxt ## Known issues -- We use selfsigned certificates, so all the requests to the WebDav local server are encrypted, since the certificates are selfsigned, many WebDav clients will complain about the certificates trust. You can safely ignore this warning. +- We use self-signed certificates when using HTTPS. This ensures that all requests to the local WebDAV server are encrypted. However, since the certificates are self-signed, many WebDAV clients may show warnings about certificate trust. You can safely ignore these warnings. -- You may encounter issues with the DNS resolution of webdav.local.internxt.com. In such cases, you can safely replace this address with 127.0.0.1, or the corresponding IP where you have deployed the webdav server if you are connecting from another location. +- You may encounter issues with DNS resolution for webdav.local.internxt.com. In such cases, you can safely replace this address with 127.0.0.1 or the IP address where the WebDAV server is deployed if connecting from a different location. diff --git a/src/commands/webdav-config.ts b/src/commands/webdav-config.ts index 388c1bf7..3d42c74e 100644 --- a/src/commands/webdav-config.ts +++ b/src/commands/webdav-config.ts @@ -33,7 +33,7 @@ export default class WebDAVConfig extends Command { const webdavConfig = await ConfigService.instance.readWebdavConfig(); if (args.action !== 'change-port' && flags['port']) { - CLIUtils.warning('The port flag is going to be ignored, it can only be used with the action "change-port"'); + CLIUtils.warning('The port flag will be ignored; it can only be used with the "change-port" action.'); } switch (args.action) { @@ -42,7 +42,7 @@ export default class WebDAVConfig extends Command { ...webdavConfig, protocol: 'http', }); - CLIUtils.success('For the next start, the Webdav server will be served using HTTP'); + CLIUtils.success('On the next start, the WebDAV server will use HTTP.'); break; } @@ -51,7 +51,7 @@ export default class WebDAVConfig extends Command { ...webdavConfig, protocol: 'https', }); - CLIUtils.success('For the next start, the Webdav server will be served using HTTPS'); + CLIUtils.success('On the next start, the WebDAV server will use HTTPS.'); break; } From 900b949cdb6551e93b44642f47ad6a93dd599634 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 5 Nov 2024 16:42:31 +0100 Subject: [PATCH 21/26] improved add-cert linux script --- scripts/add-cert.sh | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/scripts/add-cert.sh b/scripts/add-cert.sh index 17c398d6..8245ecdf 100644 --- a/scripts/add-cert.sh +++ b/scripts/add-cert.sh @@ -1,10 +1,24 @@ #!/bin/bash -CERT_PATH=$1 +CERT_PATH="$1" + +if [[ -z "$CERT_PATH" ]]; then + echo "Usage: $0 path_to_certificate" + exit 1 +fi if [[ "$OSTYPE" == "darwin"* ]]; then - sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain $CERT_PATH -else - sudo cp $CERT_PATH /usr/local/share/ca-certificates/ + sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$CERT_PATH" +elif [[ -d "/usr/local/share/ca-certificates/" ]]; then # For Debian-based distributions + sudo cp "$CERT_PATH" /usr/local/share/ca-certificates/ sudo update-ca-certificates -fi \ No newline at end of file +elif [[ -d "/etc/pki/ca-trust/source/anchors/" ]]; then # For Fedora-based distribution + sudo cp "$CERT_PATH" /etc/pki/ca-trust/source/anchors/ + sudo update-ca-trust -i +elif [[ -d "/etc/ca-certificates/trust-source/anchors/" ]]; then # For Arch Linux and derivatives + sudo cp "$CERT_PATH" /etc/ca-certificates/trust-source/anchors/ + sudo trust extract-compat +else + echo "Local certificates folder not found" + exit 1 +fi From 39b28c217d15c4b4202b10dac07c3eacbaa4130d Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 5 Nov 2024 17:45:44 +0100 Subject: [PATCH 22/26] added some tests --- src/services/analytics.service.ts | 6 ++--- test/services/validation.service.test.ts | 29 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/services/analytics.service.ts b/src/services/analytics.service.ts index 380c9d01..2e25bb0f 100644 --- a/src/services/analytics.service.ts +++ b/src/services/analytics.service.ts @@ -1,4 +1,4 @@ -import Rudderstack, { apiObject } from '@rudderstack/rudder-sdk-node'; +import { apiObject } from '@rudderstack/rudder-sdk-node'; import { ConfigService } from './config.service'; //import packageJSON from '../../package.json'; //import os from 'os'; @@ -14,7 +14,7 @@ export class AnalyticsService { constructor(private config: ConfigService) {} - private getRudderstack() { + /*private getRudderstack() { return new Rudderstack(this.config.get('RUDDERSTACK_WRITE_KEY'), { dataPlaneUrl: this.config.get('RUDDERSTACK_DATAPLANE_URL'), }); @@ -44,7 +44,7 @@ export class AnalyticsService { default: return 'Unknown'; } - } + }*/ track( eventKey: keyof typeof AnalyticsEvents, diff --git a/test/services/validation.service.test.ts b/test/services/validation.service.test.ts index 1f0ebf1a..cde7f474 100644 --- a/test/services/validation.service.test.ts +++ b/test/services/validation.service.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import Sinon, { SinonSandbox } from 'sinon'; import { auth } from '@internxt/lib'; -import { randomUUID } from 'crypto'; +import { randomInt, randomUUID } from 'crypto'; import { UserFixture } from '../fixtures/auth.fixture'; import { ValidationService } from '../../src/services/validation.service'; @@ -38,4 +38,31 @@ describe('Validation Service', () => { expect(ValidationService.instance.validateUUIDv4('6cd6894a-2996-4729-8a4a-955d5a84c0c7')).to.be.true; expect(ValidationService.instance.validateUUIDv4(randomUUID())).to.be.true; }); + + it('When yes or not string is validated, then validation service validates it as expected', () => { + expect(ValidationService.instance.validateYesOrNoString('1234567')).to.be.false; + expect(ValidationService.instance.validateYesOrNoString('loremipsum')).to.be.false; + expect(ValidationService.instance.validateYesOrNoString('')).to.be.false; + expect(ValidationService.instance.validateYesOrNoString('11111111-1111-1111-1111-111111111111')).to.be.false; + expect(ValidationService.instance.validateYesOrNoString('yes')).to.be.true; + expect(ValidationService.instance.validateYesOrNoString('YES')).to.be.true; + expect(ValidationService.instance.validateYesOrNoString('no')).to.be.true; + expect(ValidationService.instance.validateYesOrNoString('NO')).to.be.true; + expect(ValidationService.instance.validateYesOrNoString('Y')).to.be.true; + expect(ValidationService.instance.validateYesOrNoString('N')).to.be.true; + expect(ValidationService.instance.validateYesOrNoString('y')).to.be.true; + expect(ValidationService.instance.validateYesOrNoString('n')).to.be.true; + }); + + it('When tcp port is validated, then validation service validates it as expected', () => { + expect(ValidationService.instance.validateTCPIntegerPort('0')).to.be.false; + expect(ValidationService.instance.validateTCPIntegerPort('65536')).to.be.false; + expect(ValidationService.instance.validateTCPIntegerPort('')).to.be.false; + expect(ValidationService.instance.validateTCPIntegerPort('loremipsumA')).to.be.false; + expect(ValidationService.instance.validateTCPIntegerPort('11111111-1111-1111-1111-111111111111')).to.be.false; + expect(ValidationService.instance.validateTCPIntegerPort('3005')).to.be.true; + expect(ValidationService.instance.validateTCPIntegerPort('65535')).to.be.true; + expect(ValidationService.instance.validateTCPIntegerPort('1')).to.be.true; + expect(ValidationService.instance.validateTCPIntegerPort(String(randomInt(1, 65535)))).to.be.true; + }); }); From 1f5ae822c3e474a64905d5f22bb697fcd5cf8d66 Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 5 Nov 2024 17:57:13 +0100 Subject: [PATCH 23/26] bumped sdk version --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 68b3b5ad..ff7b6a36 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "dependencies": { "@internxt/inxt-js": "^2.0.11", "@internxt/lib": "^1.2.1", - "@internxt/sdk": "^1.5.17", + "@internxt/sdk": "^1.5.25", "@oclif/core": "^3", "@rudderstack/rudder-sdk-node": "^2.0.7", "axios": "^1.6.7", diff --git a/yarn.lock b/yarn.lock index e762a959..79e2b395 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1350,10 +1350,10 @@ query-string "^7.1.0" uuid "^8.3.2" -"@internxt/sdk@^1.5.17": - version "1.5.17" - resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.5.17/a08d06a5bbb603cecb0ba71f6114701ff91e9ab2#a08d06a5bbb603cecb0ba71f6114701ff91e9ab2" - integrity sha512-s9uw1l2EIbHNR8qZ8mCYYIkqU6mXharS9SK3e67NuLuzmWDJYd4Tei/wpVu4DGrSH4AvVGoIIeSFQAshLnI2Zg== +"@internxt/sdk@^1.5.25": + version "1.5.25" + resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.5.25/c0dc19d92b4002d897c5cc177168f36c9c34001f#c0dc19d92b4002d897c5cc177168f36c9c34001f" + integrity sha512-FTMGTv2p3ZKl91BMhBlig7CyZ45hTWoSUDf2cyM/iIZtdublGbMipG644tIlZbtrEDmQRA8XCO2NjkPV3IjXow== dependencies: axios "^0.24.0" query-string "^7.1.0" From c640b25f2bbe2d8e2bd4d6eebfeab75f8443906c Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 5 Nov 2024 18:15:04 +0100 Subject: [PATCH 24/26] fixed sonarcloud issues --- src/services/analytics.service.ts | 2 +- .../drive-database-manager.service.ts | 4 +- src/services/drive/drive-folder.service.ts | 4 +- src/utils/webdav.utils.ts | 6 +-- src/webdav/handlers/DELETE.handler.ts | 2 +- src/webdav/handlers/GET.handler.ts | 2 +- src/webdav/handlers/MKCOL.handler.ts | 2 +- src/webdav/handlers/MOVE.handler.ts | 2 +- src/webdav/handlers/PROPFIND.handler.ts | 5 +-- src/webdav/handlers/PUT.handler.ts | 2 +- src/webdav/webdav-server.ts | 45 +++++++++---------- 11 files changed, 36 insertions(+), 40 deletions(-) diff --git a/src/services/analytics.service.ts b/src/services/analytics.service.ts index 2e25bb0f..50b543bc 100644 --- a/src/services/analytics.service.ts +++ b/src/services/analytics.service.ts @@ -12,7 +12,7 @@ export const AnalyticsEvents = { export class AnalyticsService { public static readonly instance: AnalyticsService = new AnalyticsService(ConfigService.instance); - constructor(private config: ConfigService) {} + constructor(private readonly config: ConfigService) {} /*private getRudderstack() { return new Rudderstack(this.config.get('RUDDERSTACK_WRITE_KEY'), { diff --git a/src/services/database/drive-database-manager.service.ts b/src/services/database/drive-database-manager.service.ts index 1d2040fd..a30b93c1 100644 --- a/src/services/database/drive-database-manager.service.ts +++ b/src/services/database/drive-database-manager.service.ts @@ -18,8 +18,8 @@ export class DriveDatabaseManager { }); constructor( - private driveFileRepository: DriveFileRepository, - private driveFolderRepository: DriveFolderRepository, + private readonly driveFileRepository: DriveFileRepository, + private readonly driveFolderRepository: DriveFolderRepository, ) {} static readonly init = async () => { diff --git a/src/services/drive/drive-folder.service.ts b/src/services/drive/drive-folder.service.ts index 513bde43..ebc3a5be 100644 --- a/src/services/drive/drive-folder.service.ts +++ b/src/services/drive/drive-folder.service.ts @@ -27,7 +27,7 @@ export class DriveFolderService { return { folders, files }; }; - private getAllSubfolders = async ( + private readonly getAllSubfolders = async ( storageClient: Storage, folderUuid: string, offset: number, @@ -42,7 +42,7 @@ export class DriveFolderService { } }; - private getAllSubfiles = async ( + private readonly getAllSubfiles = async ( storageClient: Storage, folderUuid: string, offset: number, diff --git a/src/utils/webdav.utils.ts b/src/utils/webdav.utils.ts index 20764f11..863d57c1 100644 --- a/src/utils/webdav.utils.ts +++ b/src/utils/webdav.utils.ts @@ -17,8 +17,8 @@ export class WebDavUtils { static removeHostFromURL(completeURL: string) { // add a temp http schema if its not present - if (!completeURL.startsWith('/') && !/^http[s]?:\/\//i.test(completeURL)) { - completeURL = 'http://' + completeURL; + if (!completeURL.startsWith('/') && !/^https?:\/\//i.test(completeURL)) { + completeURL = 'https://' + completeURL; } const parsedUrl = new URL(completeURL); @@ -36,7 +36,7 @@ export class WebDavUtils { } const decodedUrl = decodeURIComponent(requestUrl); const parsedPath = path.parse(decodedUrl); - const parentPath = `/${path.dirname(decodedUrl).replace(/^\/|\/$/g, '')}/`.replaceAll('//', '/'); + const parentPath = `/${path.dirname(decodedUrl).replace(/^(\/)|(\/)$/g, '')}/`.replaceAll('//', '/'); const isFolder = requestUrl.endsWith('/'); diff --git a/src/webdav/handlers/DELETE.handler.ts b/src/webdav/handlers/DELETE.handler.ts index c8b7db41..611c0a5d 100644 --- a/src/webdav/handlers/DELETE.handler.ts +++ b/src/webdav/handlers/DELETE.handler.ts @@ -9,7 +9,7 @@ import { DriveFolderService } from '../../services/drive/drive-folder.service'; export class DELETERequestHandler implements WebDavMethodHandler { constructor( - private dependencies: { + private readonly dependencies: { driveDatabaseManager: DriveDatabaseManager; trashService: TrashService; driveFileService: DriveFileService; diff --git a/src/webdav/handlers/GET.handler.ts b/src/webdav/handlers/GET.handler.ts index 8c457c23..e71bc31c 100644 --- a/src/webdav/handlers/GET.handler.ts +++ b/src/webdav/handlers/GET.handler.ts @@ -14,7 +14,7 @@ import { DriveFileItem } from '../../types/drive.types'; export class GETRequestHandler implements WebDavMethodHandler { constructor( - private dependencies: { + private readonly dependencies: { driveFileService: DriveFileService; driveDatabaseManager: DriveDatabaseManager; uploadService: UploadService; diff --git a/src/webdav/handlers/MKCOL.handler.ts b/src/webdav/handlers/MKCOL.handler.ts index 2a041ead..4a533b5f 100644 --- a/src/webdav/handlers/MKCOL.handler.ts +++ b/src/webdav/handlers/MKCOL.handler.ts @@ -11,7 +11,7 @@ import { DriveFolderItem } from '../../types/drive.types'; export class MKCOLRequestHandler implements WebDavMethodHandler { constructor( - private dependencies: { + private readonly dependencies: { driveDatabaseManager: DriveDatabaseManager; driveFolderService: DriveFolderService; }, diff --git a/src/webdav/handlers/MOVE.handler.ts b/src/webdav/handlers/MOVE.handler.ts index 51007ba8..11352119 100644 --- a/src/webdav/handlers/MOVE.handler.ts +++ b/src/webdav/handlers/MOVE.handler.ts @@ -10,7 +10,7 @@ import { DriveFileItem, DriveFolderItem } from '../../types/drive.types'; export class MOVERequestHandler implements WebDavMethodHandler { constructor( - private dependencies: { + private readonly dependencies: { driveDatabaseManager: DriveDatabaseManager; driveFolderService: DriveFolderService; driveFileService: DriveFileService; diff --git a/src/webdav/handlers/PROPFIND.handler.ts b/src/webdav/handlers/PROPFIND.handler.ts index cbcb61b9..3e80e175 100644 --- a/src/webdav/handlers/PROPFIND.handler.ts +++ b/src/webdav/handlers/PROPFIND.handler.ts @@ -1,4 +1,4 @@ -import { WebDavMethodHandler, WebDavMethodHandlerOptions, WebDavRequestedResource } from '../../types/webdav.types'; +import { WebDavMethodHandler, WebDavRequestedResource } from '../../types/webdav.types'; import { XMLUtils } from '../../utils/xml.utils'; import { DriveFileItem, DriveFolderItem } from '../../types/drive.types'; import { DriveFolderService } from '../../services/drive/drive-folder.service'; @@ -13,8 +13,7 @@ import { webdavLogger } from '../../utils/logger.utils'; export class PROPFINDRequestHandler implements WebDavMethodHandler { constructor( - private options: WebDavMethodHandlerOptions = { debug: false }, - private dependencies: { + private readonly dependencies: { driveFolderService: DriveFolderService; driveFileService: DriveFileService; driveDatabaseManager: DriveDatabaseManager; diff --git a/src/webdav/handlers/PUT.handler.ts b/src/webdav/handlers/PUT.handler.ts index 4590075d..bba0c60c 100644 --- a/src/webdav/handlers/PUT.handler.ts +++ b/src/webdav/handlers/PUT.handler.ts @@ -13,7 +13,7 @@ import { TrashService } from '../../services/drive/trash.service'; export class PUTRequestHandler implements WebDavMethodHandler { constructor( - private dependencies: { + private readonly dependencies: { driveFileService: DriveFileService; driveFolderService: DriveFolderService; driveDatabaseManager: DriveDatabaseManager; diff --git a/src/webdav/webdav-server.ts b/src/webdav/webdav-server.ts index d553a5ba..94545e94 100644 --- a/src/webdav/webdav-server.ts +++ b/src/webdav/webdav-server.ts @@ -33,19 +33,19 @@ import { AnalyticsService } from '../services/analytics.service'; export class WebDavServer { constructor( - private app: Express, - private configService: ConfigService, - private driveFileService: DriveFileService, - private driveFolderService: DriveFolderService, - private driveDatabaseManager: DriveDatabaseManager, - private uploadService: UploadService, - private downloadService: DownloadService, - private authService: AuthService, - private cryptoService: CryptoService, - private trashService: TrashService, + private readonly app: Express, + private readonly configService: ConfigService, + private readonly driveFileService: DriveFileService, + private readonly driveFolderService: DriveFolderService, + private readonly driveDatabaseManager: DriveDatabaseManager, + private readonly uploadService: UploadService, + private readonly downloadService: DownloadService, + private readonly authService: AuthService, + private readonly cryptoService: CryptoService, + private readonly trashService: TrashService, ) {} - private async getNetworkFacade() { + private readonly getNetworkFacade = async () => { const credentials = await this.configService.readUser(); if (!credentials) throw new Error('Credentials not found in Config service, do login first'); @@ -55,9 +55,9 @@ export class WebDavServer { }); return new NetworkFacade(networkModule, this.uploadService, this.downloadService, this.cryptoService); - } + }; - private registerMiddlewares = async () => { + private readonly registerMiddlewares = async () => { this.app.use(bodyParser.text({ type: ['application/xml', 'text/xml'] })); this.app.use(ErrorHandlingMiddleware); this.app.use(AuthMiddleware(ConfigService.instance)); @@ -71,7 +71,7 @@ export class WebDavServer { ); }; - private registerHandlers = async () => { + private readonly registerHandlers = async () => { const networkFacade = await this.getNetworkFacade(); this.app.head('*', asyncHandler(new HEADRequestHandler().handle)); this.app.get( @@ -92,14 +92,11 @@ export class WebDavServer { this.app.propfind( '*', asyncHandler( - new PROPFINDRequestHandler( - { debug: true }, - { - driveFileService: this.driveFileService, - driveFolderService: this.driveFolderService, - driveDatabaseManager: this.driveDatabaseManager, - }, - ).handle, + new PROPFINDRequestHandler({ + driveFileService: this.driveFileService, + driveFolderService: this.driveFolderService, + driveDatabaseManager: this.driveDatabaseManager, + }).handle, ), ); @@ -151,7 +148,7 @@ export class WebDavServer { this.app.copy('*', asyncHandler(new COPYRequestHandler().handle)); }; - async start() { + start = async () => { const configs = await this.configService.readWebdavConfig(); this.app.disable('x-powered-by'); await this.registerMiddlewares(); @@ -174,5 +171,5 @@ export class WebDavServer { `Internxt WebDav server listening at ${configs.protocol}://${ConfigService.WEBDAV_LOCAL_URL}:${configs.port}`, ); }); - } + }; } From 5ac4eb4d9ecb4fcefac440b07dd3af7f18a9330d Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Tue, 5 Nov 2024 18:18:09 +0100 Subject: [PATCH 25/26] fixed tests --- test/webdav/handlers/PROPFIND.handler.test.ts | 65 +++++++------------ 1 file changed, 25 insertions(+), 40 deletions(-) diff --git a/test/webdav/handlers/PROPFIND.handler.test.ts b/test/webdav/handlers/PROPFIND.handler.test.ts index af4c10fe..8664e7fd 100644 --- a/test/webdav/handlers/PROPFIND.handler.test.ts +++ b/test/webdav/handlers/PROPFIND.handler.test.ts @@ -30,14 +30,11 @@ describe('PROPFIND request handler', () => { it('When a WebDav client sends a PROPFIND request for the root folder, and there is no content, should return the correct XML', async () => { const driveFolderService = DriveFolderService.instance; const driveFileService = DriveFileService.instance; - const requestHandler = new PROPFINDRequestHandler( - { debug: true }, - { - driveFileService, - driveFolderService, - driveDatabaseManager: getDriveDatabaseManager(), - }, - ); + const requestHandler = new PROPFINDRequestHandler({ + driveFileService, + driveFolderService, + driveDatabaseManager: getDriveDatabaseManager(), + }); const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource({ parentFolder: '/', folderName: '', @@ -82,14 +79,11 @@ describe('PROPFIND request handler', () => { it('When a WebDav client sends a PROPFIND request for the root folder, and there is content, should return the correct XML', async () => { const driveFolderService = DriveFolderService.instance; const driveFileService = DriveFileService.instance; - const requestHandler = new PROPFINDRequestHandler( - { debug: true }, - { - driveFileService, - driveFolderService, - driveDatabaseManager: getDriveDatabaseManager(), - }, - ); + const requestHandler = new PROPFINDRequestHandler({ + driveFileService, + driveFolderService, + driveDatabaseManager: getDriveDatabaseManager(), + }); const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource({ parentFolder: '/', folderName: '', @@ -140,14 +134,11 @@ describe('PROPFIND request handler', () => { it('When a WebDav client sends a PROPFIND request for a file, should return the correct XML', async () => { const driveFolderService = DriveFolderService.instance; const driveFileService = DriveFileService.instance; - const requestHandler = new PROPFINDRequestHandler( - { debug: true }, - { - driveFileService, - driveFolderService, - driveDatabaseManager: getDriveDatabaseManager(), - }, - ); + const requestHandler = new PROPFINDRequestHandler({ + driveFileService, + driveFolderService, + driveDatabaseManager: getDriveDatabaseManager(), + }); const requestedFileResource: WebDavRequestedResource = getRequestedFileResource({ parentFolder: '/', fileName: 'file', @@ -192,14 +183,11 @@ describe('PROPFIND request handler', () => { it('When a WebDav client sends a PROPFIND request for a folder, should return the correct XML', async () => { const driveFolderService = DriveFolderService.instance; const driveFileService = DriveFileService.instance; - const requestHandler = new PROPFINDRequestHandler( - { debug: true }, - { - driveFileService, - driveFolderService, - driveDatabaseManager: getDriveDatabaseManager(), - }, - ); + const requestHandler = new PROPFINDRequestHandler({ + driveFileService, + driveFolderService, + driveDatabaseManager: getDriveDatabaseManager(), + }); const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource({ parentFolder: '/', folderName: 'folder_a', @@ -240,14 +228,11 @@ describe('PROPFIND request handler', () => { it('When a WebDav client sends a PROPFIND request for a folder and it does not exists, should return a 404', async () => { const driveFolderService = DriveFolderService.instance; const driveFileService = DriveFileService.instance; - const requestHandler = new PROPFINDRequestHandler( - { debug: true }, - { - driveFileService, - driveFolderService, - driveDatabaseManager: getDriveDatabaseManager(), - }, - ); + const requestHandler = new PROPFINDRequestHandler({ + driveFileService, + driveFolderService, + driveDatabaseManager: getDriveDatabaseManager(), + }); const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource({ parentFolder: '/', folderName: 'folder_a', From 8bf89e19bdb7d264ad452606b3af415936d66dcf Mon Sep 17 00:00:00 2001 From: larry-internxt Date: Wed, 6 Nov 2024 09:49:22 +0100 Subject: [PATCH 26/26] added tests --- src/utils/webdav.utils.ts | 4 +- src/webdav/handlers/PUT.handler.ts | 4 +- test/fixtures/drive-database.fixture.ts | 47 ++- test/services/config.service.test.ts | 66 +++- test/utils/webdav.utils.test.ts | 380 ++++++++++++++++++++++- test/webdav/handlers/PUT.handler.test.ts | 81 ++++- 6 files changed, 535 insertions(+), 47 deletions(-) diff --git a/src/utils/webdav.utils.ts b/src/utils/webdav.utils.ts index 863d57c1..05504cb4 100644 --- a/src/utils/webdav.utils.ts +++ b/src/utils/webdav.utils.ts @@ -36,7 +36,9 @@ export class WebDavUtils { } const decodedUrl = decodeURIComponent(requestUrl); const parsedPath = path.parse(decodedUrl); - const parentPath = `/${path.dirname(decodedUrl).replace(/^(\/)|(\/)$/g, '')}/`.replaceAll('//', '/'); + let parentPath = path.dirname(decodedUrl); + if (!parentPath.startsWith('/')) parentPath = '/'.concat(parentPath); + if (!parentPath.endsWith('/')) parentPath = parentPath.concat('/'); const isFolder = requestUrl.endsWith('/'); diff --git a/src/webdav/handlers/PUT.handler.ts b/src/webdav/handlers/PUT.handler.ts index bba0c60c..a7db45a0 100644 --- a/src/webdav/handlers/PUT.handler.ts +++ b/src/webdav/handlers/PUT.handler.ts @@ -55,12 +55,12 @@ export class PUTRequestHandler implements WebDavMethodHandler { })) as DriveFileItem; if (driveFileItem && driveFileItem.status === 'EXISTS') { webdavLogger.info(`File '${resource.name}' already exists in '${resource.path.dir}', trashing it before PUT`); + await driveDatabaseManager.deleteFileById(driveFileItem.id); await trashService.trashItems({ items: [{ type: resource.type, uuid: driveFileItem.uuid }], }); - await driveDatabaseManager.deleteFileById(driveFileItem.id); } - } catch (_) { + } catch { //noop } diff --git a/test/fixtures/drive-database.fixture.ts b/test/fixtures/drive-database.fixture.ts index 0fe03ccd..1b8396a2 100644 --- a/test/fixtures/drive-database.fixture.ts +++ b/test/fixtures/drive-database.fixture.ts @@ -4,46 +4,41 @@ import { DriveFolder } from '../../src/services/database/drive-folder/drive-fold import { DriveDatabaseManager } from '../../src/services/database/drive-database-manager.service'; import { DriveFileRepository } from '../../src/services/database/drive-file/drive-file.repository'; import { DriveFolderRepository } from '../../src/services/database/drive-folder/drive-folder.repository'; +import { randomInt, randomUUID } from 'crypto'; -export const getDriveFileDatabaseFixture = (payload: Partial = {}): DriveFile => { - // @ts-expect-error - We only mock the properties we need - const object: DriveFile = { - id: new Date().getTime(), +export const getDriveFileDatabaseFixture = (): DriveFile => { + const object: DriveFile = new DriveFile({ + id: randomInt(2000), name: `file_${new Date().getTime().toString()}`, - uuid: `uuid_${new Date().getTime().toString()}`, - relativePath: '', + uuid: randomUUID(), + relativePath: `file_${new Date().getTime().toString()}.txt`, createdAt: new Date(), updatedAt: new Date(), status: 'EXISTS', fileId: `file_id_${new Date().getTime().toString()}`, - folderId: 0, + folderId: randomInt(2000), bucket: new Date().getTime().toString(), - size: 0, - }; - - // @ts-expect-error - We only mock the properties we need - return { - ...object, - ...payload, - }; + size: randomInt(2000), + folderUuid: randomUUID(), + type: 'txt', + }); + return object; }; -export const getDriveFolderDatabaseFixture = (payload: Partial = {}): DriveFolder => { - // @ts-expect-error - We only mock the properties we need - const object: DriveFolder = { - id: new Date().getTime(), +export const getDriveFolderDatabaseFixture = (): DriveFolder => { + const object: DriveFolder = new DriveFolder({ + id: randomInt(2000), name: `folder_${new Date().getTime().toString()}`, - uuid: `uuid_${new Date().getTime().toString()}`, + uuid: randomUUID(), relativePath: '', createdAt: new Date(), updatedAt: new Date(), - }; + parentId: randomInt(2000), + parentUuid: randomUUID(), + status: 'EXISTS', + }); - // @ts-expect-error - We only mock the properties we need - return { - ...object, - ...payload, - }; + return object; }; export const getDriveDatabaseManager = (): DriveDatabaseManager => { diff --git a/test/services/config.service.test.ts b/test/services/config.service.test.ts index 12c08963..d01a7dd3 100644 --- a/test/services/config.service.test.ts +++ b/test/services/config.service.test.ts @@ -1,10 +1,10 @@ import { expect } from 'chai'; -import crypto, { randomUUID } from 'crypto'; +import crypto, { randomInt, randomUUID } from 'crypto'; import Sinon, { SinonSandbox } from 'sinon'; import fs from 'fs/promises'; import { ConfigService } from '../../src/services/config.service'; import { CryptoService } from '../../src/services/crypto.service'; -import { CLICredentials, LoginCredentials } from '../../src/types/command.types'; +import { CLICredentials, LoginCredentials, WebdavConfig } from '../../src/types/command.types'; import { UserFixture } from '../fixtures/auth.fixture'; import { config } from 'dotenv'; @@ -154,4 +154,66 @@ describe('Config service', () => { expect(stubMkdir).to.be.calledOnceWith(ConfigService.WEBDAV_SSL_CERTS_DIR); }); + + it('When webdav config options are saved, then they are written to a file', async () => { + const webdavConfig: WebdavConfig = { + port: String(randomInt(65000)), + protocol: 'https', + }; + const stringConfig = JSON.stringify(webdavConfig); + + const fsStub = configServiceSandbox + .stub(fs, 'writeFile') + .withArgs(ConfigService.WEBDAV_CONFIGS_FILE, stringConfig) + .resolves(); + + await ConfigService.instance.saveWebdavConfig(webdavConfig); + expect(fsStub).to.be.calledWith(ConfigService.WEBDAV_CONFIGS_FILE, stringConfig); + }); + + it('When webdav config options are read and exist, then they are read from a file', async () => { + const webdavConfig: WebdavConfig = { + port: String(randomInt(65000)), + protocol: 'http', + }; + const stringConfig = JSON.stringify(webdavConfig); + + const fsStub = configServiceSandbox + .stub(fs, 'readFile') + .withArgs(ConfigService.WEBDAV_CONFIGS_FILE) + .resolves(stringConfig); + + const webdavConfigResult = await ConfigService.instance.readWebdavConfig(); + expect(webdavConfigResult).to.be.eql(webdavConfig); + expect(fsStub).to.be.calledWith(ConfigService.WEBDAV_CONFIGS_FILE); + }); + + it('When webdav config options are read but not exist, then they are returned from defaults', async () => { + const defaultWebdavConfig: WebdavConfig = { + port: ConfigService.WEBDAV_DEFAULT_PORT, + protocol: ConfigService.WEBDAV_DEFAULT_PROTOCOL, + }; + + const fsStub = configServiceSandbox + .stub(fs, 'readFile') + .withArgs(ConfigService.WEBDAV_CONFIGS_FILE) + .resolves(undefined); + + const webdavConfigResult = await ConfigService.instance.readWebdavConfig(); + expect(webdavConfigResult).to.be.eql(defaultWebdavConfig); + expect(fsStub).to.be.calledWith(ConfigService.WEBDAV_CONFIGS_FILE); + }); + + it('When webdav config options are read but an error is thrown, then they are returned from defaults', async () => { + const defaultWebdavConfig: WebdavConfig = { + port: ConfigService.WEBDAV_DEFAULT_PORT, + protocol: ConfigService.WEBDAV_DEFAULT_PROTOCOL, + }; + + const fsStub = configServiceSandbox.stub(fs, 'readFile').withArgs(ConfigService.WEBDAV_CONFIGS_FILE).rejects(); + + const webdavConfigResult = await ConfigService.instance.readWebdavConfig(); + expect(webdavConfigResult).to.be.eql(defaultWebdavConfig); + expect(fsStub).to.be.calledWith(ConfigService.WEBDAV_CONFIGS_FILE); + }); }); diff --git a/test/utils/webdav.utils.test.ts b/test/utils/webdav.utils.test.ts index 6305b085..e486fd99 100644 --- a/test/utils/webdav.utils.test.ts +++ b/test/utils/webdav.utils.test.ts @@ -1,24 +1,299 @@ import { expect } from 'chai'; +import sinon from 'sinon'; import { WebDavUtils } from '../../src/utils/webdav.utils'; import { createWebDavRequestFixture } from '../fixtures/webdav.fixture'; +import { + getDriveDatabaseManager, + getDriveFileDatabaseFixture, + getDriveFolderDatabaseFixture, +} from '../fixtures/drive-database.fixture'; +import { WebDavRequestedResource } from '../../src/types/webdav.types'; +import { newFileItem, newFolderItem } from '../fixtures/drive.fixture'; +import { DriveFolderService } from '../../src/services/drive/drive-folder.service'; +import { DriveFileService } from '../../src/services/drive/drive-file.service'; +import { fail } from 'assert'; +import { NotFoundError } from '../../src/utils/errors.utils'; describe('Webdav utils', () => { - it('When a list of path components are given, should generate a correct href', () => { - const href = WebDavUtils.joinURL('/path', 'to', 'file'); - expect(href).to.equal('/path/to/file'); + const sandbox = sinon.createSandbox(); + + afterEach(() => { + sandbox.restore(); + }); + + describe('joinURL', () => { + it('When a list of path components are given, then it should generate a correct href', () => { + const href = WebDavUtils.joinURL('/path', 'to', 'file'); + expect(href).to.equal('/path/to/file'); + }); + + it('When a list of path components are given, should generate a correct href and remove incorrect characters', () => { + const href = WebDavUtils.joinURL('/path', 'to', 'folder/'); + expect(href).to.equal('/path/to/folder/'); + }); }); - it('When a list of path components are given, should generate a correct href and remove incorrect characters', () => { - const href = WebDavUtils.joinURL('/path', 'to', 'folder/'); - expect(href).to.equal('/path/to/folder/'); + describe('removeHostFromURL', () => { + it('When a list of path components are given, then it should generate a correct href', () => { + expect(WebDavUtils.removeHostFromURL('https://test.com/folder1')).to.equal('/folder1'); + expect(WebDavUtils.removeHostFromURL('http://test.com/folder1')).to.equal('/folder1'); + expect(WebDavUtils.removeHostFromURL('test.com/folder1')).to.equal('/folder1'); + expect(WebDavUtils.removeHostFromURL('https://test.com/folder1/folder2/folder3/')).to.equal( + '/folder1/folder2/folder3/', + ); + expect(WebDavUtils.removeHostFromURL('https://test.com/folder1/test.jpg')).to.equal('/folder1/test.jpg'); + }); + }); + + describe('getRequestedResource', () => { + it('When folder request is given, then it should return the requested resource', async () => { + const request = createWebDavRequestFixture({ + url: '/url/to/folder/', + }); + const resource = await WebDavUtils.getRequestedResource(request); + expect(resource).to.deep.equal({ + url: '/url/to/folder/', + type: 'folder', + name: 'folder', + parentPath: '/url/to/', + path: { + base: 'folder', + dir: '/url/to', + ext: '', + name: 'folder', + root: '/', + }, + }); + }); + + it('When folder url is given, then it should generate the requested resource', async () => { + const requestURL = '/url/to/folder/'; + const resource = await WebDavUtils.getRequestedResource(requestURL); + expect(resource).to.deep.equal({ + url: '/url/to/folder/', + type: 'folder', + name: 'folder', + parentPath: '/url/to/', + path: { + base: 'folder', + dir: '/url/to', + ext: '', + name: 'folder', + root: '/', + }, + }); + }); + + it('When file request is given, then it should generate the requested resource', async () => { + const request = createWebDavRequestFixture({ + url: '/url/to/test.png', + }); + const resource = await WebDavUtils.getRequestedResource(request); + expect(resource).to.deep.equal({ + url: '/url/to/test.png', + type: 'file', + name: 'test', + parentPath: '/url/to/', + path: { + base: 'test.png', + dir: '/url/to', + ext: '.png', + name: 'test', + root: '/', + }, + }); + }); + + it('When file url is given, then it should generate the requested resource', async () => { + const requestURL = '/url/to/test.png'; + const resource = await WebDavUtils.getRequestedResource(requestURL); + expect(resource).to.deep.equal({ + url: '/url/to/test.png', + type: 'file', + name: 'test', + parentPath: '/url/to/', + path: { + base: 'test.png', + dir: '/url/to', + ext: '.png', + name: 'test', + root: '/', + }, + }); + }); }); - it('When a request is given, should generate the requested resource', async () => { - const request = createWebDavRequestFixture({ + describe('getDatabaseItemFromResource', () => { + const requestFileFixture: WebDavRequestedResource = { + url: '/url/to/test.png', + type: 'file', + name: 'test', + parentPath: '/url/to/', + path: { + base: 'test.png', + dir: '/url/to', + ext: '.png', + name: 'test', + root: '/', + }, + }; + const requestFolderFixture: WebDavRequestedResource = { url: '/url/to/folder/', + type: 'folder', + name: 'folder', + parentPath: '/url/to/', + path: { + base: 'folder', + dir: '/url/to', + ext: '', + name: 'folder', + root: '/', + }, + }; + + it('When folder request is given, then it should return the requested resource', async () => { + const driveDatabaseManager = getDriveDatabaseManager(); + const folder = getDriveFolderDatabaseFixture(); + const findFolderStub = sandbox.stub(driveDatabaseManager, 'findFolderByRelativePath').resolves(folder); + const findFileStub = sandbox.stub(driveDatabaseManager, 'findFileByRelativePath').rejects(); + + const item = await WebDavUtils.getDatabaseItemFromResource(requestFolderFixture, driveDatabaseManager); + expect(item).to.eql(folder.toItem()); + expect(findFolderStub.calledOnce).to.be.true; + expect(findFileStub.called).to.be.false; }); - const resource = await WebDavUtils.getRequestedResource(request); - expect(resource).to.deep.equal({ + + it('When file request is given, then it should return the requested resource', async () => { + const driveDatabaseManager = getDriveDatabaseManager(); + const file = getDriveFileDatabaseFixture(); + const findFileStub = sandbox.stub(driveDatabaseManager, 'findFileByRelativePath').resolves(file); + const findFolderStub = sandbox.stub(driveDatabaseManager, 'findFolderByRelativePath').rejects(); + + const item = await WebDavUtils.getDatabaseItemFromResource(requestFileFixture, driveDatabaseManager); + expect(item).to.eql(file.toItem()); + expect(findFileStub.calledOnce).to.be.true; + expect(findFolderStub.called).to.be.false; + }); + + it('When file item is not found, then it should return null', async () => { + const driveDatabaseManager = getDriveDatabaseManager(); + const findFileStub = sandbox.stub(driveDatabaseManager, 'findFileByRelativePath').resolves(null); + const findFolderStub = sandbox.stub(driveDatabaseManager, 'findFolderByRelativePath').rejects(); + + const item = await WebDavUtils.getDatabaseItemFromResource(requestFileFixture, driveDatabaseManager); + expect(item).to.be.null; + expect(findFileStub.calledOnce).to.be.true; + expect(findFolderStub.called).to.be.false; + }); + }); + + describe('setDatabaseItem', () => { + it('When folder item is saved, then it is persisted to the database', async () => { + const driveDatabaseManager = getDriveDatabaseManager(); + const expectedFolder = getDriveFolderDatabaseFixture(); + const createFolderStub = sandbox.stub(driveDatabaseManager, 'createFolder').resolves(expectedFolder); + const createFileStub = sandbox.stub(driveDatabaseManager, 'createFile').rejects(); + + const driveFolder = await WebDavUtils.setDatabaseItem( + 'folder', + newFolderItem(), + driveDatabaseManager, + 'relative-path', + ); + expect(driveFolder).to.eql(expectedFolder); + expect(createFolderStub.calledOnce).to.be.true; + expect(createFileStub.called).to.be.false; + }); + + it('When file item is saved, then it is persisted to the database', async () => { + const driveDatabaseManager = getDriveDatabaseManager(); + const expectedFile = getDriveFileDatabaseFixture(); + const createFileStub = sandbox.stub(driveDatabaseManager, 'createFile').resolves(expectedFile); + const createFolderStub = sandbox.stub(driveDatabaseManager, 'createFolder').rejects(); + + const driveFile = await WebDavUtils.setDatabaseItem('file', newFileItem(), driveDatabaseManager, 'relative-path'); + expect(driveFile).to.eql(expectedFile); + expect(createFileStub.calledOnce).to.be.true; + expect(createFolderStub.called).to.be.false; + }); + }); + + describe('getDriveItemFromResource', () => { + const requestFileFixture: WebDavRequestedResource = { + url: '/url/to/test.png', + type: 'file', + name: 'test', + parentPath: '/url/to/', + path: { + base: 'test.png', + dir: '/url/to', + ext: '.png', + name: 'test', + root: '/', + }, + }; + const requestFolderFixture: WebDavRequestedResource = { + url: '/url/to/folder/', + type: 'folder', + name: 'folder', + parentPath: '/url/to/', + path: { + base: 'folder', + dir: '/url/to', + ext: '', + name: 'folder', + root: '/', + }, + }; + + it('When folder resource is looked by its path, then it is returned', async () => { + const expectedFolder = newFolderItem(); + const findFolderStub = sandbox + .stub(DriveFolderService.instance, 'getFolderMetadataByPath') + .resolves(expectedFolder); + const findFileStub = sandbox.stub(DriveFileService.instance, 'getFileMetadataByPath').rejects(); + + const driveFolderItem = await WebDavUtils.getDriveItemFromResource( + requestFolderFixture, + DriveFolderService.instance, + undefined, + ); + expect(driveFolderItem).to.eql(expectedFolder); + expect(findFolderStub.calledOnce).to.be.true; + expect(findFileStub.called).to.be.false; + }); + + it('When file resource is looked by its path, then it is returned', async () => { + const expectedFile = newFileItem(); + const findFileStub = sandbox.stub(DriveFileService.instance, 'getFileMetadataByPath').resolves(expectedFile); + const findFolderStub = sandbox.stub(DriveFolderService.instance, 'getFolderMetadataByPath').rejects(); + + const driveFileItem = await WebDavUtils.getDriveItemFromResource( + requestFileFixture, + undefined, + DriveFileService.instance, + ); + expect(driveFileItem).to.eql(expectedFile); + expect(findFileStub.calledOnce).to.be.true; + expect(findFolderStub.called).to.be.false; + }); + }); + + describe('getAndSearchItemFromResource', () => { + const requestFileFixture: WebDavRequestedResource = { + url: '/url/to/test.png', + type: 'file', + name: 'test', + parentPath: '/url/to/', + path: { + base: 'test.png', + dir: '/url/to', + ext: '.png', + name: 'test', + root: '/', + }, + }; + const requestFolderFixture: WebDavRequestedResource = { url: '/url/to/folder/', type: 'folder', name: 'folder', @@ -30,6 +305,91 @@ describe('Webdav utils', () => { name: 'folder', root: '/', }, + }; + + it('When folder item is looked by the resource and exists in the local db, then it is returned from db', async () => { + const driveDatabaseManager = getDriveDatabaseManager(); + const expectedFolder = newFolderItem(); + const findFolderStub = sandbox.stub(WebDavUtils, 'getDatabaseItemFromResource').resolves(expectedFolder); + const findFolderOnDriveStub = sandbox.stub(WebDavUtils, 'getDriveItemFromResource').rejects(); + const saveFolderOnLocalStub = sandbox.stub(WebDavUtils, 'setDatabaseItem').rejects(); + + const driveFolderItem = await WebDavUtils.getAndSearchItemFromResource({ + resource: requestFolderFixture, + driveDatabaseManager, + }); + expect(driveFolderItem).to.eql(expectedFolder); + expect(findFolderStub.calledOnce).to.be.true; + expect(findFolderOnDriveStub.called).to.be.false; + expect(saveFolderOnLocalStub.called).to.be.false; + }); + + it('When file item is looked by the resource and exists in the local db, then it is returned from db', async () => { + const driveDatabaseManager = getDriveDatabaseManager(); + const expectedFile = newFileItem(); + const findFileStub = sandbox.stub(WebDavUtils, 'getDatabaseItemFromResource').resolves(expectedFile); + const findFileOnDriveStub = sandbox.stub(WebDavUtils, 'getDriveItemFromResource').rejects(); + const saveFileOnLocalStub = sandbox.stub(WebDavUtils, 'setDatabaseItem').rejects(); + + const driveFolderItem = await WebDavUtils.getAndSearchItemFromResource({ + resource: requestFileFixture, + driveDatabaseManager, + }); + expect(driveFolderItem).to.eql(expectedFile); + expect(findFileStub.calledOnce).to.be.true; + expect(findFileOnDriveStub.called).to.be.false; + expect(saveFileOnLocalStub.called).to.be.false; + }); + + it('When folder item is looked by the resource and not exists in the local db, then it is returned from drive', async () => { + const driveDatabaseManager = getDriveDatabaseManager(); + const expectedFolder = newFolderItem(); + const findFolderStub = sandbox.stub(WebDavUtils, 'getDatabaseItemFromResource').resolves(null); + const findFolderOnDriveStub = sandbox.stub(WebDavUtils, 'getDriveItemFromResource').resolves(expectedFolder); + const saveFolderOnLocalStub = sandbox.stub(WebDavUtils, 'setDatabaseItem').resolves(); + + const driveFolderItem = await WebDavUtils.getAndSearchItemFromResource({ + resource: requestFolderFixture, + driveDatabaseManager, + }); + expect(driveFolderItem).to.eql(expectedFolder); + expect(findFolderStub.calledOnce).to.be.true; + expect(findFolderOnDriveStub.calledOnce).to.be.true; + expect(saveFolderOnLocalStub.calledOnce).to.be.true; + }); + + it('When file item is looked by the resource and not exists in the local db, then it is returned from drive', async () => { + const driveDatabaseManager = getDriveDatabaseManager(); + const expectedFile = newFileItem(); + const findFileStub = sandbox.stub(WebDavUtils, 'getDatabaseItemFromResource').resolves(null); + const findFileOnDriveStub = sandbox.stub(WebDavUtils, 'getDriveItemFromResource').resolves(expectedFile); + const saveFileOnLocalStub = sandbox.stub(WebDavUtils, 'setDatabaseItem').resolves(); + + const driveFolderItem = await WebDavUtils.getAndSearchItemFromResource({ + resource: requestFileFixture, + driveDatabaseManager, + }); + expect(driveFolderItem).to.eql(expectedFile); + expect(findFileStub.calledOnce).to.be.true; + expect(findFileOnDriveStub.called).to.be.true; + expect(saveFileOnLocalStub.called).to.be.true; + }); + + it('When file item is looked by the resource and not exists in the local db nor drive, then a not found error is thrown', async () => { + const driveDatabaseManager = getDriveDatabaseManager(); + const findItemStub = sandbox.stub(WebDavUtils, 'getDatabaseItemFromResource').resolves(null); + const findItemOnDriveStub = sandbox.stub(WebDavUtils, 'getDriveItemFromResource').resolves(undefined); + const saveItemOnLocalStub = sandbox.stub(WebDavUtils, 'setDatabaseItem').rejects(); + + try { + await WebDavUtils.getAndSearchItemFromResource({ resource: requestFileFixture, driveDatabaseManager }); + fail('Expected function to throw an error, but it did not.'); + } catch (error) { + expect(error).to.be.instanceOf(NotFoundError); + } + expect(findItemStub.calledOnce).to.be.true; + expect(findItemOnDriveStub.called).to.be.true; + expect(saveItemOnLocalStub.called).to.be.false; }); }); }); diff --git a/test/webdav/handlers/PUT.handler.test.ts b/test/webdav/handlers/PUT.handler.test.ts index 82b2e2df..4484c1d7 100644 --- a/test/webdav/handlers/PUT.handler.test.ts +++ b/test/webdav/handlers/PUT.handler.test.ts @@ -22,7 +22,7 @@ import { DriveFolderService } from '../../../src/services/drive/drive-folder.ser import { TrashService } from '../../../src/services/drive/trash.service'; import { WebDavRequestedResource } from '../../../src/types/webdav.types'; import { WebDavUtils } from '../../../src/utils/webdav.utils'; -import { newFolderItem } from '../../fixtures/drive.fixture'; +import { newFileItem, newFolderItem } from '../../fixtures/drive.fixture'; describe('PUT request handler', () => { const sandbox = sinon.createSandbox(); @@ -105,10 +105,6 @@ describe('PUT request handler', () => { status: sandbox.stub().returns({ send: sandbox.stub() }), }); - const expectedError = new ConflictError( - `Parent resource not found for parent path ${requestedParentFolderResource.parentPath}`, - ); - const getRequestedResourceStub = sandbox .stub(WebDavUtils, 'getRequestedResource') .onFirstCall() @@ -117,7 +113,7 @@ describe('PUT request handler', () => { .resolves(requestedParentFolderResource); const getAndSearchItemFromResourceStub = sandbox .stub(WebDavUtils, 'getAndSearchItemFromResource') - .throws(expectedError); + .resolves(undefined); try { await sut.handle(request, response); @@ -195,4 +191,77 @@ describe('PUT request handler', () => { expect(createDriveFileStub.calledOnce).to.be.true; expect(createDBFileStub.calledOnce).to.be.true; }); + + it('When a WebDav client sends a PUT request, and the file already exists, then it should upload and replace the file to the folder', async () => { + const driveDatabaseManager = getDriveDatabaseManager(); + const downloadService = DownloadService.instance; + const uploadService = UploadService.instance; + const cryptoService = CryptoService.instance; + const authService = AuthService.instance; + const trashService = TrashService.instance; + const networkFacade = new NetworkFacade(getNetworkMock(), uploadService, downloadService, cryptoService); + const sut = new PUTRequestHandler({ + driveFileService: DriveFileService.instance, + driveFolderService: DriveFolderService.instance, + authService: AuthService.instance, + trashService: TrashService.instance, + networkFacade, + driveDatabaseManager, + }); + + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); + const requestedParentFolderResource: WebDavRequestedResource = getRequestedFolderResource({ + parentFolder: '/', + folderName: '', + }); + const folderFixture = newFolderItem({ name: requestedParentFolderResource.name }); + const fileFixture = newFileItem({ folderId: folderFixture.id, folderUuid: folderFixture.uuid }); + + const request = createWebDavRequestFixture({ + method: 'PUT', + url: requestedFileResource.url, + headers: { + 'content-length': '100', + }, + }); + + const sendStub = sandbox.stub(); + const response = createWebDavResponseFixture({ + status: sandbox.stub().returns({ send: sendStub }), + }); + + const getRequestedResourceStub = sandbox + .stub(WebDavUtils, 'getRequestedResource') + .onFirstCall() + .resolves(requestedFileResource) + .onSecondCall() + .resolves(requestedParentFolderResource); + const getAndSearchItemFromResourceStub = sandbox + .stub(WebDavUtils, 'getAndSearchItemFromResource') + .onFirstCall() + .resolves(folderFixture) + .onSecondCall() + .resolves(fileFixture); + const deleteDBFileStub = sandbox.stub(driveDatabaseManager, 'deleteFileById').resolves(); + const deleteDriveFileStub = sandbox.stub(trashService, 'trashItems').resolves(); + const getAuthDetailsStub = sandbox + .stub(authService, 'getAuthDetails') + .resolves({ mnemonic: 'MNEMONIC', token: 'TOKEN', newToken: 'NEW_TOKEN', user: UserFixture }); + const uploadFromStreamStub = sandbox + .stub(networkFacade, 'uploadFromStream') + .resolves([Promise.resolve({ fileId: '09218313209', hash: Buffer.from('test') }), new AbortController()]); + const createDriveFileStub = sandbox.stub(DriveFileService.instance, 'createFile').resolves(); + const createDBFileStub = sandbox.stub(driveDatabaseManager, 'createFile').resolves(); + + await sut.handle(request, response); + expect(response.status.calledWith(200)).to.be.true; + expect(getRequestedResourceStub.calledTwice).to.be.true; + expect(getAndSearchItemFromResourceStub.calledTwice).to.be.true; + expect(getAuthDetailsStub.calledOnce).to.be.true; + expect(uploadFromStreamStub.calledOnce).to.be.true; + expect(createDriveFileStub.calledOnce).to.be.true; + expect(createDBFileStub.calledOnce).to.be.true; + expect(deleteDBFileStub.calledOnce).to.be.true; + expect(deleteDriveFileStub.calledOnce).to.be.true; + }); });