Repository: perry-mitchell/webdav-client
Pull request #361 (2024-01-10 20:55:29)

enh: Allow to set `overwrite` for copy and move actions and allow shallow copy
* Resolves https://github.com/perry-mitchell/webdav-client/issues/329

1. This allows to set the overwrite header for copy and move actions.
2. This allows to create shallow copies as of RFC4918 section 9.8.3


Patch:
diff --git a/source/factory.ts b/source/factory.ts
index c924e21..71d4ba4 100644
--- a/source/factory.ts
+++ b/source/factory.ts
@@ -18,6 +18,7 @@ import { getFileUploadLink, putFileContents } from "./operations/putFileContents
 import {
     AuthType,
     BufferLike,
+    CopyFileOptions,
     CreateReadStreamOptions,
     CreateWriteStreamCallback,
     CreateWriteStreamOptions,
@@ -26,6 +27,7 @@ import {
     GetQuotaOptions,
     Headers,
     LockOptions,
+    MoveFileOptions,
     PutFileContentsOptions,
     RequestOptionsCustom,
     SearchOptions,
@@ -74,7 +76,7 @@ export function createClient(remoteURL: string, options: WebDAVClientOptions = {
     };
     setupAuth(context, username, password, token, ha1);
     return {
-        copyFile: (filename: string, destination: string, options?: WebDAVMethodOptions) =>
+        copyFile: (filename: string, destination: string, options?: CopyFileOptions) =>
             copyFile(context, filename, destination, options),
         createDirectory: (path: string, options?: WebDAVMethodOptions) =>
             createDirectory(context, path, options),
@@ -99,7 +101,7 @@ export function createClient(remoteURL: string, options: WebDAVClientOptions = {
         getHeaders: () => Object.assign({}, context.headers),
         getQuota: (options?: GetQuotaOptions) => getQuota(context, options),
         lock: (path: string, options?: LockOptions) => lock(context, path, options),
-        moveFile: (filename: string, destinationFilename: string, options?: WebDAVMethodOptions) =>
+        moveFile: (filename: string, destinationFilename: string, options?: MoveFileOptions) =>
             moveFile(context, filename, destinationFilename, options),
         putFileContents: (
             filename: string,
diff --git a/source/operations/copyFile.ts b/source/operations/copyFile.ts
index f197729..283b228 100644
--- a/source/operations/copyFile.ts
+++ b/source/operations/copyFile.ts
@@ -2,20 +2,33 @@ import { joinURL } from "../tools/url.js";
 import { encodePath } from "../tools/path.js";
 import { request, prepareRequestOptions } from "../request.js";
 import { handleResponseCode } from "../response.js";
-import { WebDAVClientContext, WebDAVMethodOptions } from "../types.js";
+import { CopyFileOptions, WebDAVClientContext, WebDAVMethodOptions } from "../types.js";
 
 export async function copyFile(
     context: WebDAVClientContext,
     filename: string,
     destination: string,
-    options: WebDAVMethodOptions = {}
+    options: CopyFileOptions = {}
 ): Promise<void> {
     const requestOptions = prepareRequestOptions(
         {
             url: joinURL(context.remoteURL, encodePath(filename)),
             method: "COPY",
             headers: {
-                Destination: joinURL(context.remoteURL, encodePath(destination))
+                Destination: joinURL(context.remoteURL, encodePath(destination)),
+                /**
+                 * From RFC4918 section 10.6: If the overwrite header is not included in a COPY or MOVE request,
+                 * then the resource MUST treat the request as if it has an overwrite header of value "T".
+                 *
+                 * Meaning the overwrite header is always set to "T" EXCEPT the option is explicitly set to false.
+                 */
+                Overwrite: options.overwrite === false ? "F" : "T",
+                /**
+                 * From RFC4918 section 9.8.3: A client may submit a Depth header on a COPY on a collection with a value of "0"
+                 * or "infinity". The COPY method on a collection without a Depth header MUST act as if
+                 * a Depth header with value "infinity" was included.
+                 */
+                Depth: options.shallow ? "0" : "infinity"
             }
         },
         context,
diff --git a/source/operations/moveFile.ts b/source/operations/moveFile.ts
index 2ffd6fa..aac9654 100644
--- a/source/operations/moveFile.ts
+++ b/source/operations/moveFile.ts
@@ -2,20 +2,27 @@ import { joinURL } from "../tools/url.js";
 import { encodePath } from "../tools/path.js";
 import { request, prepareRequestOptions } from "../request.js";
 import { handleResponseCode } from "../response.js";
-import { WebDAVClientContext, WebDAVMethodOptions } from "../types.js";
+import { MoveFileOptions, WebDAVClientContext, WebDAVMethodOptions } from "../types.js";
 
 export async function moveFile(
     context: WebDAVClientContext,
     filename: string,
     destination: string,
-    options: WebDAVMethodOptions = {}
+    options: MoveFileOptions = {}
 ): Promise<void> {
     const requestOptions = prepareRequestOptions(
         {
             url: joinURL(context.remoteURL, encodePath(filename)),
             method: "MOVE",
             headers: {
-                Destination: joinURL(context.remoteURL, encodePath(destination))
+                Destination: joinURL(context.remoteURL, encodePath(destination)),
+                /**
+                 * From RFC4918 section 10.6: If the overwrite header is not included in a COPY or MOVE request,
+                 * then the resource MUST treat the request as if it has an overwrite header of value "T".
+                 *
+                 * Meaning the overwrite header is always set to "T" EXCEPT the option is explicitly set to false.
+                 */
+                Overwrite: options.overwrite === false ? "F" : "T"
             }
         },
         context,
diff --git a/source/types.ts b/source/types.ts
index 8ca64e6..145f48a 100644
--- a/source/types.ts
+++ b/source/types.ts
@@ -151,6 +151,23 @@ export interface GetQuotaOptions extends WebDAVMethodOptions {
     path?: string;
 }
 
+export interface MoveFileOptions extends WebDAVMethodOptions {
+    /**
+     * Set to false to disable overwriting an existing resource during MOVE or COPY.
+     * @default true
+     */
+    overwrite?: boolean;
+}
+
+export interface CopyFileOptions extends MoveFileOptions {
+    /**
+     * Set to true to create a shallow copy only by only copying the resource with its properties but without its children.
+     * By default the collection itself with all children is copied (WebDAV Depth header of 'infinity')
+     * @default false
+     */
+    shallow?: boolean;
+}
+
 export interface Headers {
     [key: string]: string;
 }
@@ -237,7 +254,7 @@ export type UploadProgress = ProgressEvent;
 export type UploadProgressCallback = ProgressEventCallback;
 
 export interface WebDAVClient {
-    copyFile: (filename: string, destination: string) => Promise<void>;
+    copyFile: (filename: string, destination: string, options?: CopyFileOptions) => Promise<void>;
     createDirectory: (path: string, options?: CreateDirectoryOptions) => Promise<void>;
     createReadStream: (filename: string, options?: CreateReadStreamOptions) => Stream.Readable;
     createWriteStream: (
@@ -263,7 +280,11 @@ export interface WebDAVClient {
         options?: GetQuotaOptions
     ) => Promise<DiskQuota | null | ResponseDataDetailed<DiskQuota | null>>;
     lock: (path: string, options?: LockOptions) => Promise<LockResponse>;
-    moveFile: (filename: string, destinationFilename: string) => Promise<void>;
+    moveFile: (
+        filename: string,
+        destinationFilename: string,
+        options?: MoveFileOptions
+    ) => Promise<void>;
     putFileContents: (
         filename: string,
         data: string | BufferLike | Stream.Readable,


Test patch:
diff --git a/test/node/operations/copyFile.spec.ts b/test/node/operations/copyFile.spec.ts
index 5a6909b..e7af1e6 100644
--- a/test/node/operations/copyFile.spec.ts
+++ b/test/node/operations/copyFile.spec.ts
@@ -64,4 +64,22 @@ describe("copyFile", function () {
         const [, requestOptions] = this.requestSpy.firstCall.args;
         expect(requestOptions).to.have.property("headers").that.has.property("X-test", "test");
     });
+
+    it("creates deep copy by default", async function () {
+        await this.client.copyFile("/alrighty.jpg", "/sub1/alrighty.jpg");
+        const [, requestOptions] = this.requestSpy.firstCall.args;
+        expect(requestOptions).to.have.property("headers").that.has.property("Depth", "infinity");
+    });
+
+    it("creates deep copy if shallow copy is disabled", async function () {
+        await this.client.copyFile("/alrighty.jpg", "/sub1/alrighty.jpg", { shallow: false });
+        const [, requestOptions] = this.requestSpy.firstCall.args;
+        expect(requestOptions).to.have.property("headers").that.has.property("Depth", "infinity");
+    });
+
+    it("creates shallow copy if enabled", async function () {
+        await this.client.copyFile("/alrighty.jpg", "/sub1/alrighty.jpg", { shallow: true });
+        const [, requestOptions] = this.requestSpy.firstCall.args;
+        expect(requestOptions).to.have.property("headers").that.has.property("Depth", "0");
+    });
 });
diff --git a/test/node/operations/moveFile.spec.ts b/test/node/operations/moveFile.spec.ts
index 11d24de..658c45f 100644
--- a/test/node/operations/moveFile.spec.ts
+++ b/test/node/operations/moveFile.spec.ts
@@ -9,7 +9,9 @@ import {
     SERVER_USERNAME,
     clean,
     createWebDAVClient,
-    createWebDAVServer
+    createWebDAVServer,
+    restoreRequests,
+    useRequestSpy
 } from "../../helpers.node.js";
 
 const dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -24,10 +26,12 @@ describe("moveFile", function () {
         });
         clean();
         this.server = createWebDAVServer();
+        this.requestSpy = useRequestSpy();
         return this.server.start();
     });
 
     afterEach(function () {
+        restoreRequests();
         return this.server.stop();
     });
 
@@ -51,4 +55,36 @@ describe("moveFile", function () {
             expect(fileExists.sync(path.join(TEST_CONTENTS, "./renamed.jpg"))).to.be.true;
         });
     });
+
+    it("Overwrite on move by default", async function () {
+        await this.client.moveFile("/two words/file.txt", "/with & in path/files.txt");
+        const [, requestOptions] = this.requestSpy.firstCall.args;
+        expect(requestOptions).to.have.property("headers").that.has.property("Overwrite", "T");
+    });
+
+    it("Overwrite on move if explicitly enabled", async function () {
+        await this.client.moveFile("/two words/file.txt", "/with & in path/files.txt", {
+            overwrite: true
+        });
+        const [, requestOptions] = this.requestSpy.firstCall.args;
+        expect(requestOptions).to.have.property("headers").that.has.property("Overwrite", "T");
+    });
+
+    it("Do not overwrite if disabled", async function () {
+        try {
+            await this.client.moveFile("/two words/file.txt", "/with & in path/files.txt", {
+                overwrite: false
+            });
+        } catch (e) {
+            expect(e).to.have.property("status");
+            expect(e.status).to.equal(412);
+            return;
+        } finally {
+            const [, requestOptions] = this.requestSpy.firstCall.args;
+            expect(requestOptions).to.have.property("headers").that.has.property("Overwrite", "F");
+        }
+
+        // should not happen (reach this) but the webserver implementation is not following RFC
+        // expect("Move file should not work!").to.be.true
+    });
 });