From 5e99f18fc0b84f9abcbef3b6ecc0d23e207497e1 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Thu, 9 Oct 2025 15:43:37 +0200 Subject: [PATCH 1/5] Feat: add --createFullPath and --no-createFullPath flags to enable/disable auto creating missing parent directories --- src/commands/webdav-config.ts | 19 +++++++++++++------ src/services/config.service.ts | 3 +++ src/types/command.types.ts | 1 + test/services/config.service.test.ts | 4 ++++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/commands/webdav-config.ts b/src/commands/webdav-config.ts index e3fe4043..93e14f4b 100644 --- a/src/commands/webdav-config.ts +++ b/src/commands/webdav-config.ts @@ -38,19 +38,25 @@ export default class WebDAVConfig extends Command { required: false, min: 0, }), + createFullPath: Flags.boolean({ + char: 'c', + description: 'Auto-create missing parent directories during file uploads.', + required: false, + allowNo: true, + }), }; static readonly enableJsonFlag = true; public run = async () => { - const { flags } = await this.parse(WebDAVConfig); + const { + flags: { host, port, http, https, timeout, createFullPath }, + } = await this.parse(WebDAVConfig); const webdavConfig = await ConfigService.instance.readWebdavConfig(); - const host = flags['host']; if (host) { webdavConfig['host'] = host; } - const port = flags['port']; if (port) { if (ValidationService.instance.validateTCPIntegerPort(port)) { webdavConfig['port'] = port; @@ -59,21 +65,22 @@ export default class WebDAVConfig extends Command { } } - const http = flags['http']; if (http) { webdavConfig['protocol'] = 'http'; } - const https = flags['https']; if (https) { webdavConfig['protocol'] = 'https'; } - const timeout = flags['timeout']; if (timeout !== undefined) { webdavConfig['timeoutMinutes'] = timeout; } + if (createFullPath !== undefined) { + webdavConfig['createFullPath'] = createFullPath; + } + await ConfigService.instance.saveWebdavConfig(webdavConfig); const message = `On the next start, the WebDAV server will use the next config: ${JSON.stringify(webdavConfig)}`; CLIUtils.success(this.log.bind(this), message); diff --git a/src/services/config.service.ts b/src/services/config.service.ts index a5ed7e9f..8743ddad 100644 --- a/src/services/config.service.ts +++ b/src/services/config.service.ts @@ -17,6 +17,7 @@ export class ConfigService { static readonly WEBDAV_DEFAULT_PORT = '3005'; static readonly WEBDAV_DEFAULT_PROTOCOL = 'https'; static readonly WEBDAV_DEFAULT_TIMEOUT = 0; + static readonly WEBDAV_DEFAULT_CREATE_FULL_PATH = false; public static readonly instance: ConfigService = new ConfigService(); /** @@ -85,6 +86,7 @@ export class ConfigService { port: configs?.port ?? ConfigService.WEBDAV_DEFAULT_PORT, protocol: configs?.protocol ?? ConfigService.WEBDAV_DEFAULT_PROTOCOL, timeoutMinutes: configs?.timeoutMinutes ?? ConfigService.WEBDAV_DEFAULT_TIMEOUT, + createFullPath: configs?.createFullPath ?? ConfigService.WEBDAV_DEFAULT_CREATE_FULL_PATH, }; } catch { return { @@ -92,6 +94,7 @@ export class ConfigService { port: ConfigService.WEBDAV_DEFAULT_PORT, protocol: ConfigService.WEBDAV_DEFAULT_PROTOCOL, timeoutMinutes: ConfigService.WEBDAV_DEFAULT_TIMEOUT, + createFullPath: ConfigService.WEBDAV_DEFAULT_CREATE_FULL_PATH, }; } }; diff --git a/src/types/command.types.ts b/src/types/command.types.ts index dc417ed8..c52b9cfa 100644 --- a/src/types/command.types.ts +++ b/src/types/command.types.ts @@ -36,6 +36,7 @@ export interface WebdavConfig { port: string; protocol: 'http' | 'https'; timeoutMinutes: number; + createFullPath: boolean; } export class NotValidEmailError extends Error { diff --git a/test/services/config.service.test.ts b/test/services/config.service.test.ts index ba87ea6a..556c06fc 100644 --- a/test/services/config.service.test.ts +++ b/test/services/config.service.test.ts @@ -120,6 +120,7 @@ describe('Config service', () => { port: crypto.randomInt(65000).toString(), protocol: 'https', timeoutMinutes: crypto.randomInt(100), + createFullPath: false, }; const stringConfig = JSON.stringify(webdavConfig); @@ -135,6 +136,7 @@ describe('Config service', () => { port: crypto.randomInt(65000).toString(), protocol: 'http', timeoutMinutes: crypto.randomInt(100), + createFullPath: false, }; const stringConfig = JSON.stringify(webdavConfig); @@ -151,6 +153,7 @@ describe('Config service', () => { port: ConfigService.WEBDAV_DEFAULT_PORT, protocol: ConfigService.WEBDAV_DEFAULT_PROTOCOL, timeoutMinutes: ConfigService.WEBDAV_DEFAULT_TIMEOUT, + createFullPath: ConfigService.WEBDAV_DEFAULT_CREATE_FULL_PATH, }; const fsStub = vi.spyOn(fs, 'readFile').mockResolvedValue(''); @@ -166,6 +169,7 @@ describe('Config service', () => { port: ConfigService.WEBDAV_DEFAULT_PORT, protocol: ConfigService.WEBDAV_DEFAULT_PROTOCOL, timeoutMinutes: ConfigService.WEBDAV_DEFAULT_TIMEOUT, + createFullPath: ConfigService.WEBDAV_DEFAULT_CREATE_FULL_PATH, }; const fsStub = vi.spyOn(fs, 'readFile').mockRejectedValue(new Error()); From 4f56f35cbbd83192e17dd4a73dfabfcdd5bc6aae Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Thu, 23 Oct 2025 16:37:02 +0200 Subject: [PATCH 2/5] Feat: Create full path functionality when enabled --- src/utils/drive.utils.ts | 17 +- src/utils/webdav.utils.ts | 41 ++++- src/webdav/handlers/MKCOL.handler.ts | 36 ++-- src/webdav/handlers/MOVE.handler.ts | 2 +- src/webdav/handlers/PUT.handler.ts | 51 +++--- src/webdav/services/webdav-folder.service.ts | 82 +++++++++ src/webdav/webdav-server.ts | 9 +- test/webdav/handlers/MKCOL.handler.test.ts | 45 ++--- test/webdav/handlers/PUT.handler.test.ts | 79 ++++++++- .../services/webdav-folder.service.test.ts | 160 ++++++++++++++++++ 10 files changed, 434 insertions(+), 88 deletions(-) create mode 100644 src/webdav/services/webdav-folder.service.ts create mode 100644 test/webdav/services/webdav-folder.service.test.ts diff --git a/src/utils/drive.utils.ts b/src/utils/drive.utils.ts index 601d45cb..666b0061 100644 --- a/src/utils/drive.utils.ts +++ b/src/utils/drive.utils.ts @@ -1,4 +1,4 @@ -import { FileMeta, FolderMeta } from '@internxt/sdk/dist/drive/storage/types'; +import { FileMeta, FolderMeta, CreateFolderResponse } from '@internxt/sdk/dist/drive/storage/types'; import { DriveFileItem, DriveFolderItem } from '../types/drive.types'; export class DriveUtils { @@ -35,4 +35,19 @@ export class DriveUtils { updatedAt: new Date(folderMeta.updatedAt), }; } + + static createFolderResponseToItem(folderResponse: CreateFolderResponse): DriveFolderItem { + return { + uuid: folderResponse.uuid, + id: folderResponse.id, + bucket: folderResponse.bucket, + status: folderResponse.deleted || folderResponse.removed ? 'TRASHED' : 'EXISTS', + name: folderResponse.plainName ?? folderResponse.name, + encryptedName: folderResponse.name, + parentId: folderResponse.parentId, + parentUuid: folderResponse.parentUuid, + createdAt: new Date(folderResponse.createdAt), + updatedAt: new Date(folderResponse.updatedAt), + }; + } } diff --git a/src/utils/webdav.utils.ts b/src/utils/webdav.utils.ts index 308500d9..80b33a83 100644 --- a/src/utils/webdav.utils.ts +++ b/src/utils/webdav.utils.ts @@ -22,6 +22,23 @@ export class WebDavUtils { return url; } + static decodeUrl(requestUrl: string, decodeUri = true): string { + return (decodeUri ? decodeURIComponent(requestUrl) : requestUrl).replaceAll('/./', '/'); + } + + static normalizeFolderPath(path: string): string { + let normalizedPath = path; + + if (!normalizedPath.startsWith('/')) { + normalizedPath = `/${normalizedPath}`; + } + + if (!normalizedPath.endsWith('/')) { + normalizedPath = `${normalizedPath}/`; + } + return normalizedPath; + } + static async getRequestedResource(urlObject: string | Request, decodeUri = true): Promise { let requestUrl: string; if (typeof urlObject === 'string') { @@ -30,11 +47,9 @@ export class WebDavUtils { requestUrl = urlObject.url; } - const decodedUrl = (decodeUri ? decodeURIComponent(requestUrl) : requestUrl).replaceAll('/./', '/'); + const decodedUrl = this.decodeUrl(requestUrl, decodeUri); const parsedPath = path.parse(decodedUrl); - let parentPath = path.dirname(decodedUrl); - if (!parentPath.startsWith('/')) parentPath = '/'.concat(parentPath); - if (!parentPath.endsWith('/')) parentPath = parentPath.concat('/'); + const parentPath = this.normalizeFolderPath(path.dirname(decodedUrl)); const isFolder = requestUrl.endsWith('/'); @@ -57,6 +72,24 @@ export class WebDavUtils { } } + static async getDriveItemFromResource(params: { + resource: WebDavRequestedResource; + driveFolderService: DriveFolderService; + driveFileService?: never; + }): Promise; + + static async getDriveItemFromResource(params: { + resource: WebDavRequestedResource; + driveFolderService?: never; + driveFileService: DriveFileService; + }): Promise; + + static async getDriveItemFromResource(params: { + resource: WebDavRequestedResource; + driveFolderService: DriveFolderService; + driveFileService: DriveFileService; + }): Promise; + static async getDriveItemFromResource({ resource, driveFolderService, diff --git a/src/webdav/handlers/MKCOL.handler.ts b/src/webdav/handlers/MKCOL.handler.ts index 591b9216..4f29d981 100644 --- a/src/webdav/handlers/MKCOL.handler.ts +++ b/src/webdav/handlers/MKCOL.handler.ts @@ -4,38 +4,26 @@ import { WebDavUtils } from '../../utils/webdav.utils'; import { DriveFolderService } from '../../services/drive/drive-folder.service'; import { webdavLogger } from '../../utils/logger.utils'; import { XMLUtils } from '../../utils/xml.utils'; -import { AsyncUtils } from '../../utils/async.utils'; -import { DriveFolderItem } from '../../types/drive.types'; -import { ConflictError, MethodNotAllowed } from '../../utils/errors.utils'; +import { WebDavFolderService } from '../services/webdav-folder.service'; +import { MethodNotAllowed } from '../../utils/errors.utils'; export class MKCOLRequestHandler implements WebDavMethodHandler { constructor( private readonly dependencies: { driveFolderService: DriveFolderService; + webDavFolderService: WebDavFolderService; }, ) {} handle = async (req: Request, res: Response) => { - const { driveFolderService } = this.dependencies; + const { driveFolderService, webDavFolderService } = this.dependencies; const resource = await WebDavUtils.getRequestedResource(req); webdavLogger.info(`[MKCOL] Request received for ${resource.type} at ${resource.url}`); - const parentResource = await WebDavUtils.getRequestedResource(resource.parentPath, false); - - const parentDriveItem = await WebDavUtils.getDriveItemFromResource({ - resource: parentResource, - driveFolderService, - }); - - if (!parentDriveItem) { - // WebDAV RFC - // When the MKCOL operation creates a new collection resource, - // all ancestors MUST already exist, or the method MUST fail - // with a 409 (Conflict) status code - throw new ConflictError(`Parent folders not found on Internxt Drive at ${resource.url}`); - } - const parentFolderItem = parentDriveItem as DriveFolderItem; + const parentDriveFolderItem = + (await webDavFolderService.getDriveFolderItemFromPath(resource.parentPath)) ?? + (await webDavFolderService.createParentPathOrThrow(resource.parentPath)); const driveFolderItem = await WebDavUtils.getDriveItemFromResource({ resource, @@ -49,17 +37,13 @@ export class MKCOLRequestHandler implements WebDavMethodHandler { throw new MethodNotAllowed('Folder already exists'); } - const [createFolder] = driveFolderService.createFolder({ - plainName: resource.path.base, - parentFolderUuid: parentFolderItem.uuid, + const newFolder = await webDavFolderService.createFolder({ + folderName: resource.path.base, + parentFolderUuid: parentDriveFolderItem.uuid, }); - const newFolder = await createFolder; - webdavLogger.info(`[MKCOL] ✅ Folder created with UUID ${newFolder.uuid}`); - // This aims to prevent this issue: https://inxt.atlassian.net/browse/PB-1446 - await AsyncUtils.sleep(500); res.status(201).send(XMLUtils.toWebDavXML({}, {})); }; } diff --git a/src/webdav/handlers/MOVE.handler.ts b/src/webdav/handlers/MOVE.handler.ts index b0796025..54a993fc 100644 --- a/src/webdav/handlers/MOVE.handler.ts +++ b/src/webdav/handlers/MOVE.handler.ts @@ -75,7 +75,7 @@ export class MOVERequestHandler implements WebDavMethodHandler { if (!destinationDriveFolderItem) { throw new NotFoundError(`Resource not found on Internxt Drive at ${resource.url}`); } - const destinationFolderItem = destinationDriveFolderItem as DriveFileItem; + const destinationFolderItem = destinationDriveFolderItem; if (resource.type === 'folder') { const folder = originalDriveItem as DriveFolderItem; diff --git a/src/webdav/handlers/PUT.handler.ts b/src/webdav/handlers/PUT.handler.ts index b9f6cde2..9af7a85b 100644 --- a/src/webdav/handlers/PUT.handler.ts +++ b/src/webdav/handlers/PUT.handler.ts @@ -3,11 +3,9 @@ import { DriveFileService } from '../../services/drive/drive-file.service'; import { NetworkFacade } from '../../services/network/network-facade.service'; import { AuthService } from '../../services/auth.service'; import { WebDavMethodHandler } from '../../types/webdav.types'; -import { ConflictError, NotFoundError, UnsupportedMediaTypeError } from '../../utils/errors.utils'; +import { NotFoundError, UnsupportedMediaTypeError } from '../../utils/errors.utils'; import { WebDavUtils } from '../../utils/webdav.utils'; import { webdavLogger } from '../../utils/logger.utils'; -import { DriveFileItem } from '../../types/drive.types'; -import { DriveFolderService } from '../../services/drive/drive-folder.service'; import { TrashService } from '../../services/drive/trash.service'; import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types'; import { CLIUtils } from '../../utils/cli.utils'; @@ -15,12 +13,14 @@ import { BufferStream } from '../../utils/stream.utils'; import { Readable } from 'node:stream'; import { isFileThumbnailable } from '../../utils/thumbnail.utils'; import { ThumbnailService } from '../../services/thumbnail.service'; +import { WebDavFolderService } from '../services/webdav-folder.service'; +import { AsyncUtils } from '../../utils/async.utils'; export class PUTRequestHandler implements WebDavMethodHandler { constructor( private readonly dependencies: { driveFileService: DriveFileService; - driveFolderService: DriveFolderService; + webDavFolderService: WebDavFolderService; trashService: TrashService; authService: AuthService; networkFacade: NetworkFacade; @@ -28,7 +28,6 @@ export class PUTRequestHandler implements WebDavMethodHandler { ) {} handle = async (req: Request, res: Response) => { - const { 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'); @@ -41,32 +40,19 @@ export class PUTRequestHandler implements WebDavMethodHandler { webdavLogger.info(`[PUT] Request received for ${resource.type} at ${resource.url}`); webdavLogger.info(`[PUT] Uploading '${resource.name}' to '${resource.parentPath}'`); - const parentResource = await WebDavUtils.getRequestedResource(resource.parentPath, false); - - const parentDriveFolderItem = await WebDavUtils.getDriveItemFromResource({ - resource: parentResource, - driveFolderService, - }); - - if (!parentDriveFolderItem) { - // WebDAV RFC - // When the PUT operation creates a new resource, - // all ancestors MUST already exist, or the method MUST fail - // with a 409 (Conflict) status code - throw new ConflictError(`Parent folders not found on Internxt Drive at ${resource.url}`); - } - const parentFolderItem = parentDriveFolderItem as DriveFileItem; - + const parentDriveFolderItem = + (await this.dependencies.webDavFolderService.getDriveFolderItemFromPath(resource.parentPath)) ?? + (await this.dependencies.webDavFolderService.createParentPathOrThrow(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.getDriveItemFromResource({ + const driveFileItem = await WebDavUtils.getDriveItemFromResource({ resource: resource, - driveFileService, - })) as DriveFileItem; + driveFileService: this.dependencies.driveFileService, + }); if (driveFileItem && driveFileItem.status === 'EXISTS') { webdavLogger.info(`[PUT] File '${resource.name}' already exists in '${resource.path.dir}', trashing it...`); - await trashService.trashItems({ + await this.dependencies.trashService.trashItems({ items: [{ type: resource.type, uuid: driveFileItem.uuid, id: null }], }); } @@ -74,7 +60,7 @@ export class PUTRequestHandler implements WebDavMethodHandler { //noop } - const { user } = await authService.getAuthDetails(); + const { user } = await this.dependencies.authService.getAuthDetails(); const fileType = resource.path.ext.replace('.', ''); @@ -98,7 +84,7 @@ export class PUTRequestHandler implements WebDavMethodHandler { }; const fileId = await new Promise((resolve: (fileId: string) => void, reject) => { - const state = networkFacade.uploadFile( + const state = this.dependencies.networkFacade.uploadFile( fileStream, contentLength, user.bucket, @@ -127,7 +113,7 @@ export class PUTRequestHandler implements WebDavMethodHandler { plainName: resource.path.name, type: fileType, size: contentLength, - folderUuid: parentFolderItem.uuid, + folderUuid: parentDriveFolderItem.uuid, fileId: fileId, bucket: user.bucket, encryptVersion: EncryptionVersion.Aes03, @@ -143,7 +129,7 @@ export class PUTRequestHandler implements WebDavMethodHandler { fileType, user.bucket, file.uuid, - networkFacade, + this.dependencies.networkFacade, ); } } @@ -154,6 +140,13 @@ export class PUTRequestHandler implements WebDavMethodHandler { const uploadTime = timer.stop(); webdavLogger.info(`[PUT] ✅ File uploaded in ${uploadTime}ms to Internxt Drive`); + // Wait for backend search index to propagate (same as folder creation delay in PB-1446) + await AsyncUtils.sleep(500); + + webdavLogger.info( + `[PUT] [RESPONSE-201] ${resource.url} - Returning 201 Created after ${uploadTime}ms (+ 500ms propagation delay)`, + ); + res.status(201).send(); }; } diff --git a/src/webdav/services/webdav-folder.service.ts b/src/webdav/services/webdav-folder.service.ts new file mode 100644 index 00000000..54c5ef39 --- /dev/null +++ b/src/webdav/services/webdav-folder.service.ts @@ -0,0 +1,82 @@ +import { ConfigService } from '../../services/config.service'; +import { DriveFolderService } from '../../services/drive/drive-folder.service'; +import { DriveFolderItem } from '../../types/drive.types'; +import { ConflictError } from '../../utils/errors.utils'; +import { WebDavUtils } from '../../utils/webdav.utils'; +import { AsyncUtils } from '../../utils/async.utils'; +import { AuthService } from '../../services/auth.service'; +import { DriveUtils } from '../../utils/drive.utils'; + +export class WebDavFolderService { + constructor( + private readonly dependencies: { + driveFolderService: DriveFolderService; + configService: ConfigService; + }, + ) {} + + public getDriveFolderItemFromPath = async (path: string): Promise => { + const resource = await WebDavUtils.getRequestedResource(path, false); + return await WebDavUtils.getDriveItemFromResource({ + resource, + driveFolderService: this.dependencies.driveFolderService, + }); + }; + + public createFolder = async ({ + folderName, + parentFolderUuid, + }: { + folderName: string; + parentFolderUuid: string; + }): Promise => { + const [createFolderPromise] = this.dependencies.driveFolderService.createFolder({ + plainName: folderName, + parentFolderUuid: parentFolderUuid, + }); + + const newFolder = await createFolderPromise; + + // This aims to prevent this issue: https://inxt.atlassian.net/browse/PB-1446 + await AsyncUtils.sleep(500); + + return DriveUtils.createFolderResponseToItem(newFolder); + }; + + public createParentPathOrThrow = async (parentPath: string): Promise => { + const { createFullPath } = await this.dependencies.configService.readWebdavConfig(); + if (!createFullPath) { + // WebDAV RFC: https://datatracker.ietf.org/doc/html/rfc4918#section-9.7.1 + // When the PUT operation creates a new resource, + // all ancestors MUST already exist, or the method MUST fail + // with a 409 (Conflict) status code + throw new ConflictError( + `Parent folders not found on Internxt Drive at ${WebDavUtils.decodeUrl(parentPath, false)}`, + ); + } + const folders = parentPath.split('/').filter((f) => f.length > 0); + const { user } = await AuthService.instance.getAuthDetails(); + return await this.createFolderRecursively(folders, user.rootFolderId); + }; + + private async createFolderRecursively( + remainingFolders: string[], + parentFolderUuid: string, + accumulatedPath = '', + ): Promise { + const [currentFolderName, ...rest] = remainingFolders; + + const newPath = WebDavUtils.joinURL(accumulatedPath, currentFolderName); + const folderPath = WebDavUtils.normalizeFolderPath(newPath); + + const folder = + (await this.getDriveFolderItemFromPath(folderPath)) ?? + (await this.createFolder({ folderName: currentFolderName, parentFolderUuid })); + + if (rest.length === 0) { + return folder; + } + + return await this.createFolderRecursively(rest, folder.uuid, newPath); + } +} diff --git a/src/webdav/webdav-server.ts b/src/webdav/webdav-server.ts index 536092fe..842c2fcb 100644 --- a/src/webdav/webdav-server.ts +++ b/src/webdav/webdav-server.ts @@ -29,6 +29,7 @@ import { COPYRequestHandler } from './handlers/COPY.handler'; import { TrashService } from '../services/drive/trash.service'; import { Environment } from '@internxt/inxt-js'; import { MkcolMiddleware } from './middewares/mkcol.middleware'; +import { WebDavFolderService } from './services/webdav-folder.service'; export class WebDavServer { constructor( @@ -85,6 +86,10 @@ export class WebDavServer { private readonly registerHandlers = async () => { const serverListenPath = /(.*)/; const networkFacade = await this.getNetworkFacade(); + const webDavFolderService = new WebDavFolderService({ + driveFolderService: this.driveFolderService, + configService: this.configService, + }); this.app.head( serverListenPath, asyncHandler( @@ -121,7 +126,7 @@ export class WebDavServer { asyncHandler( new PUTRequestHandler({ driveFileService: this.driveFileService, - driveFolderService: this.driveFolderService, + webDavFolderService: webDavFolderService, authService: this.authService, trashService: this.trashService, networkFacade: networkFacade, @@ -134,6 +139,7 @@ export class WebDavServer { asyncHandler( new MKCOLRequestHandler({ driveFolderService: this.driveFolderService, + webDavFolderService: webDavFolderService, }).handle, ), ); @@ -152,6 +158,7 @@ export class WebDavServer { serverListenPath, asyncHandler( new MOVERequestHandler({ + // TODO: Add WebDavFolderService driveFolderService: this.driveFolderService, driveFileService: this.driveFileService, }).handle, diff --git a/test/webdav/handlers/MKCOL.handler.test.ts b/test/webdav/handlers/MKCOL.handler.test.ts index 6a56ddd8..f2b3f920 100644 --- a/test/webdav/handlers/MKCOL.handler.test.ts +++ b/test/webdav/handlers/MKCOL.handler.test.ts @@ -7,10 +7,11 @@ import { getRequestedFolderResource, } from '../../fixtures/webdav.fixture'; import { UserSettingsFixture } from '../../fixtures/auth.fixture'; -import { CreateFolderResponse } from '@internxt/sdk/dist/drive/storage/types'; -import { newCreateFolderResponse, newFolderItem } from '../../fixtures/drive.fixture'; +import { newFolderItem } from '../../fixtures/drive.fixture'; import { WebDavRequestedResource } from '../../../src/types/webdav.types'; import { WebDavUtils } from '../../../src/utils/webdav.utils'; +import { WebDavFolderService } from '../../../src/webdav/services/webdav-folder.service'; +import { ConfigService } from '../../../src/services/config.service'; describe('MKCOL request handler', () => { beforeEach(() => { @@ -19,18 +20,18 @@ describe('MKCOL request handler', () => { it('When a WebDav client sends a MKCOL request, it should reply with a 201 if success', async () => { const driveFolderService = DriveFolderService.instance; + const webDavFolderService = new WebDavFolderService({ + driveFolderService: DriveFolderService.instance, + configService: ConfigService.instance, + }); const requestHandler = new MKCOLRequestHandler({ driveFolderService, + webDavFolderService, }); const requestedFolderResource: WebDavRequestedResource = getRequestedFolderResource({ parentFolder: '/test', folderName: 'FolderA', }); - const requestedParentFolderResource: WebDavRequestedResource = getRequestedFolderResource({ - parentFolder: '/', - folderName: 'test', - }); - const request = createWebDavRequestFixture({ method: 'MKCOL', url: requestedFolderResource.url, @@ -40,31 +41,31 @@ describe('MKCOL request handler', () => { status: vi.fn().mockReturnValue({ send: vi.fn() }), }); - const parentFolder = newFolderItem(); - const newFolderResponse: CreateFolderResponse = newCreateFolderResponse({ - plainName: 'FolderA', - parentId: parentFolder.id, - parentUuid: parentFolder.uuid, - }); + const parentFolder = newFolderItem({ name: 'test', uuid: 'parent-uuid' }); const getRequestedResourceStub = vi .spyOn(WebDavUtils, 'getRequestedResource') - .mockResolvedValueOnce(requestedFolderResource) - .mockResolvedValueOnce(requestedParentFolderResource); + .mockResolvedValue(requestedFolderResource); + const getDriveFolderItemFromPathStub = vi + .spyOn(webDavFolderService, 'getDriveFolderItemFromPath') + .mockResolvedValue(parentFolder); const getAndSearchItemFromResourceStub = vi .spyOn(WebDavUtils, 'getDriveItemFromResource') - .mockResolvedValueOnce(parentFolder) - .mockResolvedValueOnce(undefined); + .mockResolvedValue(undefined); const createFolderStub = vi - .spyOn(driveFolderService, 'createFolder') - .mockReturnValue([Promise.resolve(newFolderResponse), { cancel: () => {} }]); + .spyOn(webDavFolderService, 'createFolder') + .mockResolvedValue(newFolderItem({ name: 'FolderA', uuid: 'new-folder-uuid' })); await requestHandler.handle(request, response); expect(response.status).toHaveBeenCalledWith(201); - expect(getRequestedResourceStub).toHaveBeenCalledTimes(2); - expect(getAndSearchItemFromResourceStub).toHaveBeenCalledTimes(2); + expect(getRequestedResourceStub).toHaveBeenCalledWith(request); + expect(getDriveFolderItemFromPathStub).toHaveBeenCalledWith(requestedFolderResource.parentPath); + expect(getAndSearchItemFromResourceStub).toHaveBeenCalledWith({ + resource: requestedFolderResource, + driveFolderService, + }); expect(createFolderStub).toHaveBeenCalledWith({ - plainName: requestedFolderResource.name, + folderName: requestedFolderResource.path.base, parentFolderUuid: parentFolder.uuid, }); }); diff --git a/test/webdav/handlers/PUT.handler.test.ts b/test/webdav/handlers/PUT.handler.test.ts index 0724b172..eaad44c5 100644 --- a/test/webdav/handlers/PUT.handler.test.ts +++ b/test/webdav/handlers/PUT.handler.test.ts @@ -6,6 +6,7 @@ import { getRequestedFolderResource, } from '../../fixtures/webdav.fixture'; import { DriveFileService } from '../../../src/services/drive/drive-file.service'; +import { DriveFolderService } from '../../../src/services/drive/drive-folder.service'; import { CryptoService } from '../../../src/services/crypto.service'; import { DownloadService } from '../../../src/services/network/download.service'; import { AuthService } from '../../../src/services/auth.service'; @@ -14,7 +15,7 @@ import { SdkManager } from '../../../src/services/sdk-manager.service'; import { NetworkFacade } from '../../../src/services/network/network-facade.service'; import { PUTRequestHandler } from '../../../src/webdav/handlers/PUT.handler'; import { fail } from 'node:assert'; -import { DriveFolderService } from '../../../src/services/drive/drive-folder.service'; +import { WebDavFolderService } from '../../../src/webdav/services/webdav-folder.service'; import { TrashService } from '../../../src/services/drive/trash.service'; import { WebDavRequestedResource } from '../../../src/types/webdav.types'; import { WebDavUtils } from '../../../src/utils/webdav.utils'; @@ -53,9 +54,13 @@ describe('PUT request handler', () => { DownloadService.instance, CryptoService.instance, ); + const webDavFolderService = new WebDavFolderService({ + driveFolderService: DriveFolderService.instance, + configService: ConfigService.instance, + }); const sut = new PUTRequestHandler({ driveFileService: DriveFileService.instance, - driveFolderService: DriveFolderService.instance, + webDavFolderService, authService: AuthService.instance, trashService: TrashService.instance, networkFacade, @@ -86,9 +91,13 @@ describe('PUT request handler', () => { const cryptoService = CryptoService.instance; const authService = AuthService.instance; const networkFacade = new NetworkFacade(getNetworkMock(), getEnvironmentMock(), downloadService, cryptoService); + const webDavFolderService = new WebDavFolderService({ + driveFolderService: DriveFolderService.instance, + configService: ConfigService.instance, + }); const sut = new PUTRequestHandler({ driveFileService: DriveFileService.instance, - driveFolderService: DriveFolderService.instance, + webDavFolderService, authService: AuthService.instance, trashService: TrashService.instance, networkFacade, @@ -148,9 +157,13 @@ describe('PUT request handler', () => { const authService = AuthService.instance; const trashService = TrashService.instance; const networkFacade = new NetworkFacade(getNetworkMock(), getEnvironmentMock(), downloadService, cryptoService); + const webDavFolderService = new WebDavFolderService({ + driveFolderService: DriveFolderService.instance, + configService: ConfigService.instance, + }); const sut = new PUTRequestHandler({ driveFileService: DriveFileService.instance, - driveFolderService: DriveFolderService.instance, + webDavFolderService, authService: AuthService.instance, trashService: TrashService.instance, networkFacade, @@ -205,4 +218,62 @@ describe('PUT request handler', () => { expect(createDriveFileStub).toHaveBeenCalledOnce(); expect(deleteDriveFileStub).toHaveBeenCalledOnce(); }); + + it('When file is uploaded, then it should wait 500ms for backend propagation before returning 201', async () => { + const downloadService = DownloadService.instance; + const cryptoService = CryptoService.instance; + const authService = AuthService.instance; + const networkFacade = new NetworkFacade(getNetworkMock(), getEnvironmentMock(), downloadService, cryptoService); + const webDavFolderService = new WebDavFolderService({ + driveFolderService: DriveFolderService.instance, + configService: ConfigService.instance, + }); + const sut = new PUTRequestHandler({ + driveFileService: DriveFileService.instance, + webDavFolderService, + authService: AuthService.instance, + trashService: TrashService.instance, + networkFacade, + }); + + const requestedFileResource: WebDavRequestedResource = getRequestedFileResource(); + const requestedParentFolderResource: WebDavRequestedResource = getRequestedFolderResource({ + parentFolder: '/', + folderName: '', + }); + const folderFixture = newFolderItem({ name: requestedParentFolderResource.name }); + const fileFixture = newDriveFile({ folderId: folderFixture.id, folderUuid: folderFixture.uuid }); + + const request = createWebDavRequestFixture({ + method: 'PUT', + url: requestedFileResource.url, + headers: { + 'content-length': '100', + }, + }); + + const response = createWebDavResponseFixture({ + status: vi.fn().mockReturnValue({ send: vi.fn() }), + }); + + vi.spyOn(WebDavUtils, 'getRequestedResource') + .mockResolvedValueOnce(requestedFileResource) + .mockResolvedValueOnce(requestedParentFolderResource); + vi.spyOn(WebDavUtils, 'getDriveItemFromResource').mockResolvedValueOnce(folderFixture).mockResolvedValue(undefined); + vi.spyOn(authService, 'getAuthDetails').mockResolvedValue(UserCredentialsFixture); + vi.spyOn(networkFacade, 'uploadFile').mockImplementation( + // @ts-expect-error - We only mock the properties we need + (_, __, ___, callback: (err: Error | null, res: string | null) => void) => { + return callback(null, 'uploaded-file-id'); + }, + ); + vi.spyOn(DriveFileService.instance, 'createFile').mockResolvedValue(fileFixture.toItem()); + + const startTime = Date.now(); + await sut.handle(request, response); + const endTime = Date.now(); + + expect(response.status).toHaveBeenCalledWith(201); + expect(endTime - startTime).toBeGreaterThanOrEqual(500); + }); }); diff --git a/test/webdav/services/webdav-folder.service.test.ts b/test/webdav/services/webdav-folder.service.test.ts new file mode 100644 index 00000000..7d7f1fd4 --- /dev/null +++ b/test/webdav/services/webdav-folder.service.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fail } from 'node:assert'; +import { WebDavFolderService } from '../../../src/webdav/services/webdav-folder.service'; +import { DriveFolderService } from '../../../src/services/drive/drive-folder.service'; +import { ConfigService } from '../../../src/services/config.service'; +import { AuthService } from '../../../src/services/auth.service'; +import { newFolderItem, newCreateFolderResponse } from '../../fixtures/drive.fixture'; +import { ConflictError } from '../../../src/utils/errors.utils'; +import { UserCredentialsFixture } from '../../fixtures/login.fixture'; + +describe('WebDavFolderService', () => { + let sut: WebDavFolderService; + let driveFolderService: DriveFolderService; + let configService: ConfigService; + + const mockWebdavConfig = (createFullPath: boolean) => { + vi.spyOn(configService, 'readWebdavConfig').mockResolvedValue({ + createFullPath, + port: '3005', + protocol: 'https', + host: 'localhost', + timeoutMinutes: 5, + }); + }; + + const mockAuthDetails = (rootFolderId: string) => { + vi.spyOn(AuthService.instance, 'getAuthDetails').mockResolvedValue({ + ...UserCredentialsFixture, + user: { ...UserCredentialsFixture.user, rootFolderId }, + }); + }; + + beforeEach(() => { + vi.restoreAllMocks(); + driveFolderService = DriveFolderService.instance; + configService = ConfigService.instance; + sut = new WebDavFolderService({ + driveFolderService, + configService, + }); + }); + + describe('createParentPathOrThrow', () => { + it('should throw ConflictError when createFullPath is disabled ', async () => { + mockWebdavConfig(false); + + try { + await sut.createParentPathOrThrow('/backup/folder1/'); + fail('Expected function to throw ConflictError, but it did not.'); + } catch (error) { + expect(error).to.be.instanceOf(ConflictError); + expect((error as ConflictError).message).to.contain( + 'Parent folders not found on Internxt Drive at /backup/folder1/', + ); + } + }); + + it('should create a single folder at root level when path has one segment', async () => { + const rootFolderId = 'root-uuid-123'; + const createdFolder = newFolderItem({ name: 'backup', uuid: 'backup-uuid' }); + + mockWebdavConfig(true); + mockAuthDetails(rootFolderId); + const getDriveFolderSpy = vi.spyOn(sut, 'getDriveFolderItemFromPath').mockResolvedValue(undefined); + const createFolderSpy = vi.spyOn(sut, 'createFolder').mockResolvedValue(createdFolder); + + const result = await sut.createParentPathOrThrow('/backup/'); + + expect(result).to.deep.equal(createdFolder); + expect(getDriveFolderSpy).toHaveBeenCalledWith('/backup/'); + expect(createFolderSpy).toHaveBeenCalledWith({ + folderName: 'backup', + parentFolderUuid: rootFolderId, + }); + }); + + it('should return existing folder without creating when folder already exists', async () => { + const existingFolder = newFolderItem({ name: 'backup', uuid: 'backup-uuid' }); + + mockWebdavConfig(true); + const getDriveFolderSpy = vi.spyOn(sut, 'getDriveFolderItemFromPath').mockResolvedValue(existingFolder); + const createFolderSpy = vi.spyOn(sut, 'createFolder'); + + const result = await sut.createParentPathOrThrow('/backup/'); + + expect(result).to.deep.equal(existingFolder); + expect(getDriveFolderSpy).toHaveBeenCalledWith('/backup/'); + expect(createFolderSpy).not.toHaveBeenCalled(); + }); + + it('should recursively create nested folders when path has multiple segments', async () => { + const rootFolderId = 'root-uuid-123'; + const backupFolder = newFolderItem({ name: 'backup', uuid: 'backup-uuid' }); + const folder1 = newFolderItem({ name: 'folder1', uuid: 'folder1-uuid' }); + const subfolder = newFolderItem({ name: 'subfolder', uuid: 'subfolder-uuid' }); + + mockWebdavConfig(true); + mockAuthDetails(rootFolderId); + + const getDriveFolderSpy = vi + .spyOn(sut, 'getDriveFolderItemFromPath') + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + + const createFolderSpy = vi + .spyOn(sut, 'createFolder') + .mockResolvedValueOnce(backupFolder) + .mockResolvedValueOnce(folder1) + .mockResolvedValueOnce(subfolder); + + const result = await sut.createParentPathOrThrow('/backup/folder1/subfolder/'); + + expect(result).to.deep.equal(subfolder); + expect(getDriveFolderSpy).toHaveBeenCalledTimes(3); + expect(createFolderSpy).toHaveBeenCalledTimes(3); + expect(createFolderSpy).toHaveBeenNthCalledWith(1, { + folderName: 'backup', + parentFolderUuid: rootFolderId, + }); + expect(createFolderSpy).toHaveBeenNthCalledWith(2, { + folderName: 'folder1', + parentFolderUuid: backupFolder.uuid, + }); + expect(createFolderSpy).toHaveBeenNthCalledWith(3, { + folderName: 'subfolder', + parentFolderUuid: folder1.uuid, + }); + }); + }); + + describe('createFolder', () => { + it('it should wait 500ms for backend propagation when folder is created', async () => { + const folderResponse = newCreateFolderResponse({ + plainName: 'test-folder', + uuid: 'test-uuid', + }); + + vi.spyOn(driveFolderService, 'createFolder').mockReturnValue([ + Promise.resolve(folderResponse), + { cancel: () => {} }, + ]); + + const startTime = Date.now(); + const result = await sut.createFolder({ + folderName: 'test-folder', + parentFolderUuid: 'parent-uuid', + }); + const endTime = Date.now(); + + expect(result.uuid).to.equal('test-uuid'); + expect(result.name).to.equal('test-folder'); + expect(endTime - startTime).toBeGreaterThanOrEqual(500); + expect(driveFolderService.createFolder).toHaveBeenCalledWith({ + plainName: 'test-folder', + parentFolderUuid: 'parent-uuid', + }); + }); + }); +}); From 78cdbea338d815b0735dc8280debb337e790f156 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Thu, 23 Oct 2025 17:22:44 +0200 Subject: [PATCH 3/5] feat: added createFullPath functionaliry to move --- src/webdav/handlers/MOVE.handler.ts | 17 ++++++----------- src/webdav/webdav-server.ts | 2 +- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/webdav/handlers/MOVE.handler.ts b/src/webdav/handlers/MOVE.handler.ts index 54a993fc..d7b3e568 100644 --- a/src/webdav/handlers/MOVE.handler.ts +++ b/src/webdav/handlers/MOVE.handler.ts @@ -6,17 +6,19 @@ import { NotFoundError } from '../../utils/errors.utils'; import { webdavLogger } from '../../utils/logger.utils'; import { WebDavUtils } from '../../utils/webdav.utils'; import { DriveFileItem, DriveFolderItem } from '../../types/drive.types'; +import { WebDavFolderService } from '../services/webdav-folder.service'; export class MOVERequestHandler implements WebDavMethodHandler { constructor( private readonly dependencies: { driveFolderService: DriveFolderService; driveFileService: DriveFileService; + webDavFolderService: WebDavFolderService; }, ) {} handle = async (req: Request, res: Response) => { - const { driveFolderService, driveFileService } = this.dependencies; + const { driveFolderService, driveFileService, webDavFolderService } = this.dependencies; const resource = await WebDavUtils.getRequestedResource(req); webdavLogger.info(`[MOVE] Request received for ${resource.type} at ${resource.url}`); @@ -65,17 +67,10 @@ export class MOVERequestHandler implements WebDavMethodHandler { } 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 destinationDriveFolderItem = await WebDavUtils.getDriveItemFromResource({ - resource: destinationFolderResource, - driveFolderService, - }); - - if (!destinationDriveFolderItem) { - throw new NotFoundError(`Resource not found on Internxt Drive at ${resource.url}`); - } - const destinationFolderItem = destinationDriveFolderItem; + const destinationFolderItem = + (await webDavFolderService.getDriveFolderItemFromPath(destinationResource.parentPath)) ?? + (await webDavFolderService.createParentPathOrThrow(destinationResource.parentPath)); if (resource.type === 'folder') { const folder = originalDriveItem as DriveFolderItem; diff --git a/src/webdav/webdav-server.ts b/src/webdav/webdav-server.ts index 842c2fcb..62f6238b 100644 --- a/src/webdav/webdav-server.ts +++ b/src/webdav/webdav-server.ts @@ -158,9 +158,9 @@ export class WebDavServer { serverListenPath, asyncHandler( new MOVERequestHandler({ - // TODO: Add WebDavFolderService driveFolderService: this.driveFolderService, driveFileService: this.driveFileService, + webDavFolderService, }).handle, ), ); From 1edce026a59fe15c4363489154910bd22b299a91 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Fri, 24 Oct 2025 11:38:08 +0200 Subject: [PATCH 4/5] fix: fix test (edited commit) --- .gitignore | 5 ++++- test/webdav/services/webdav-folder.service.test.ts | 5 ++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ebc6e88f..24ea6429 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ oclif.manifest.json coverage cli.inxt *.log -.vscode \ No newline at end of file +.vscode + +# Claude Code project instructions +CLAUDE.md \ No newline at end of file diff --git a/test/webdav/services/webdav-folder.service.test.ts b/test/webdav/services/webdav-folder.service.test.ts index 7d7f1fd4..ca434e59 100644 --- a/test/webdav/services/webdav-folder.service.test.ts +++ b/test/webdav/services/webdav-folder.service.test.ts @@ -12,7 +12,7 @@ describe('WebDavFolderService', () => { let sut: WebDavFolderService; let driveFolderService: DriveFolderService; let configService: ConfigService; - + const rootFolderId = 'root-uuid-123'; const mockWebdavConfig = (createFullPath: boolean) => { vi.spyOn(configService, 'readWebdavConfig').mockResolvedValue({ createFullPath, @@ -56,7 +56,6 @@ describe('WebDavFolderService', () => { }); it('should create a single folder at root level when path has one segment', async () => { - const rootFolderId = 'root-uuid-123'; const createdFolder = newFolderItem({ name: 'backup', uuid: 'backup-uuid' }); mockWebdavConfig(true); @@ -78,6 +77,7 @@ describe('WebDavFolderService', () => { const existingFolder = newFolderItem({ name: 'backup', uuid: 'backup-uuid' }); mockWebdavConfig(true); + mockAuthDetails(rootFolderId); const getDriveFolderSpy = vi.spyOn(sut, 'getDriveFolderItemFromPath').mockResolvedValue(existingFolder); const createFolderSpy = vi.spyOn(sut, 'createFolder'); @@ -89,7 +89,6 @@ describe('WebDavFolderService', () => { }); it('should recursively create nested folders when path has multiple segments', async () => { - const rootFolderId = 'root-uuid-123'; const backupFolder = newFolderItem({ name: 'backup', uuid: 'backup-uuid' }); const folder1 = newFolderItem({ name: 'folder1', uuid: 'folder1-uuid' }); const subfolder = newFolderItem({ name: 'subfolder', uuid: 'subfolder-uuid' }); From 5d676d4b85564d5e852db223a72924d3b81f91c7 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Fri, 24 Oct 2025 12:06:08 +0200 Subject: [PATCH 5/5] Fix: Change by default behavior the creation of full path + create union type --- src/services/config.service.ts | 2 +- src/types/drive.types.ts | 2 ++ src/utils/webdav.utils.ts | 8 ++++---- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/services/config.service.ts b/src/services/config.service.ts index 8743ddad..d6dca41d 100644 --- a/src/services/config.service.ts +++ b/src/services/config.service.ts @@ -17,7 +17,7 @@ export class ConfigService { static readonly WEBDAV_DEFAULT_PORT = '3005'; static readonly WEBDAV_DEFAULT_PROTOCOL = 'https'; static readonly WEBDAV_DEFAULT_TIMEOUT = 0; - static readonly WEBDAV_DEFAULT_CREATE_FULL_PATH = false; + static readonly WEBDAV_DEFAULT_CREATE_FULL_PATH = true; public static readonly instance: ConfigService = new ConfigService(); /** diff --git a/src/types/drive.types.ts b/src/types/drive.types.ts index f1e0527b..7d5f241e 100644 --- a/src/types/drive.types.ts +++ b/src/types/drive.types.ts @@ -28,3 +28,5 @@ export type DriveFolderItem = Pick; + }): Promise; static async getDriveItemFromResource({ resource, @@ -98,8 +98,8 @@ export class WebDavUtils { resource: WebDavRequestedResource; driveFolderService?: DriveFolderService; driveFileService?: DriveFileService; - }): Promise { - let item: DriveFileItem | DriveFolderItem | undefined = undefined; + }): Promise { + let item: DriveItem | undefined = undefined; try { if (resource.type === 'folder') {