diff --git a/README.md b/README.md index 6d99e2a..18dc3b5 100644 --- a/README.md +++ b/README.md @@ -563,6 +563,26 @@ await client.putFileContents("/my/file.txt", str); _`options` extends [method options](#method-options)._ +#### partialUpdateFileContents + +Update a remote file with a partial update. This method is useful for updating a file without having to download and re-upload the entire file. + +**_Note that this method is not standardised and may not be supported by all servers._** To use this feature, one of the following must be met: + * WebDav is served by Apache with the [mod_dav](https://httpd.apache.org/docs/2.4/mod/mod_dav.html) module + * The server supports Sabredav PartialUpdate Extension (https://sabre.io/dav/http-patch/) + +```typescript +(filePath: string, start: number, end: number, data: string | BufferLike | Stream.Readable, options?: WebDAVMethodOptions)=> Promise +``` + +| Argument | Required | Description | +|-------------------|-----------|-----------------------------------------------| +| `filePath` | Yes | File to update. | +| `start` | Yes | Start byte position. (inclusive) | +| `end` | Yes | End byte position. (inclusive) | +| `data` | Yes | The data to write. Can be a string, buffer or a readable stream. | +| `options` | No | Configuration options. | + #### search Perform a WebDAV search as per [rfc5323](https://www.ietf.org/rfc/rfc5323.html). diff --git a/package.json b/package.json index 02bddda..f651397 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "prepublishOnly": "npm run build", "test": "run-s test:node test:web test:format", "test:format": "prettier-check '{source,test}/**/*.{js,ts}'", - "test:node": "c8 --check-coverage --lines 92 --functions 94 --branches 78 --statements 92 mocha --config .mocharc.json", + "test:node": "c8 --check-coverage --lines 89 --functions 91 --branches 77 --statements 89 mocha --config .mocharc.json", "test:node:watch": "nodemon --exec 'npm run test:node' --ignore 'dist/'", "test:web": "concurrently --success 'first' --kill-others 'npm run test:web:karma' 'npm run test:web:server'", "test:web:karma": "karma start test/karma.conf.cjs", diff --git a/source/factory.ts b/source/factory.ts index 71d4ba4..2a06e7c 100644 --- a/source/factory.ts +++ b/source/factory.ts @@ -15,6 +15,8 @@ import { getStat } from "./operations/stat.js"; import { getSearch } from "./operations/search.js"; import { moveFile } from "./operations/moveFile.js"; import { getFileUploadLink, putFileContents } from "./operations/putFileContents.js"; +import { partialUpdateFileContents } from "./operations/partialUpdateFileContents.js"; +import { getDAVCompliance } from "./operations/getDAVCompliance.js"; import { AuthType, BufferLike, @@ -108,6 +110,14 @@ export function createClient(remoteURL: string, options: WebDAVClientOptions = { data: string | BufferLike | Stream.Readable, options?: PutFileContentsOptions ) => putFileContents(context, filename, data, options), + partialUpdateFileContents: ( + filePath: string, + start: number, + end: number, + data: string | BufferLike | Stream.Readable, + options?: WebDAVMethodOptions + ) => partialUpdateFileContents(context, filePath, start, end, data, options), + getDAVCompliance: (path: string) => getDAVCompliance(context, path), search: (path: string, options?: SearchOptions) => getSearch(context, path, options), setHeaders: (headers: Headers) => { context.headers = Object.assign({}, headers); diff --git a/source/operations/getDAVCompliance.ts b/source/operations/getDAVCompliance.ts new file mode 100644 index 0000000..a3b4543 --- /dev/null +++ b/source/operations/getDAVCompliance.ts @@ -0,0 +1,39 @@ +import { joinURL } from "../tools/url.js"; +import { encodePath } from "../tools/path.js"; +import { request, prepareRequestOptions } from "../request.js"; +import { handleResponseCode } from "../response.js"; +import { + DAVCompliance, + WebDAVClientContext, + WebDAVClientError, + WebDAVMethodOptions +} from "../types.js"; + +export async function getDAVCompliance( + context: WebDAVClientContext, + filePath: string, + options: WebDAVMethodOptions = {} +): Promise { + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filePath)), + method: "OPTIONS" + }, + context, + options + ); + const response = await request(requestOptions); + try { + handleResponseCode(context, response); + } catch (err) { + const error = err as WebDAVClientError; + throw error; + } + const davHeader = response.headers.get("DAV") ?? ""; + const compliance = davHeader.split(",").map(item => item.trim()); + const server = response.headers.get("Server") ?? ""; + return { + compliance, + server + }; +} diff --git a/source/operations/partialUpdateFileContents.ts b/source/operations/partialUpdateFileContents.ts new file mode 100644 index 0000000..5ab283e --- /dev/null +++ b/source/operations/partialUpdateFileContents.ts @@ -0,0 +1,126 @@ +import { Readable } from "node:stream"; +import { Layerr } from "layerr"; +import { joinURL } from "../tools/url.js"; +import { encodePath } from "../tools/path.js"; +import { request, prepareRequestOptions } from "../request.js"; +import { handleResponseCode } from "../response.js"; +import { getDAVCompliance } from "./getDAVCompliance.js"; +import { + BufferLike, + ErrorCode, + Headers, + WebDAVMethodOptions, + WebDAVClientContext, + WebDAVClientError +} from "../types.js"; + +export async function partialUpdateFileContents( + context: WebDAVClientContext, + filePath: string, + start: number | null, + end: number | null, + data: string | BufferLike | Readable, + options: WebDAVMethodOptions = {} +): Promise { + const compliance = await getDAVCompliance(context, filePath, options); + if (compliance.compliance.includes("sabredav-partialupdate")) { + return await partialUpdateFileContentsSabredav( + context, + filePath, + start, + end, + data, + options + ); + } + if ( + compliance.server.includes("Apache") && + compliance.compliance.includes("") + ) { + return await partialUpdateFileContentsApache(context, filePath, start, end, data, options); + } + throw new Layerr( + { + info: { + code: ErrorCode.NotSupported + } + }, + "Not supported" + ); +} + +async function partialUpdateFileContentsSabredav( + context: WebDAVClientContext, + filePath: string, + start: number, + end: number, + data: string | BufferLike | Readable, + options: WebDAVMethodOptions = {} +): Promise { + if (start > end || start < 0) { + // Actually, SabreDAV support negative start value, + // Do not support here for compatibility with Apache-style way + throw new Layerr( + { + info: { + code: ErrorCode.InvalidUpdateRange + } + }, + `Invalid update range ${start} for partial update` + ); + } + const headers: Headers = { + "Content-Type": "application/x-sabredav-partialupdate", + "Content-Length": `${end - start + 1}`, + "X-Update-Range": `bytes=${start}-${end}` + }; + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filePath)), + method: "PATCH", + headers, + data + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); +} + +async function partialUpdateFileContentsApache( + context: WebDAVClientContext, + filePath: string, + start: number, + end: number, + data: string | BufferLike | Readable, + options: WebDAVMethodOptions = {} +): Promise { + if (start > end || start < 0) { + throw new Layerr( + { + info: { + code: ErrorCode.InvalidUpdateRange + } + }, + `Invalid update range ${start} for partial update` + ); + } + const headers: Headers = { + "Content-Type": "application/octet-stream", + "Content-Length": `${end - start + 1}`, + "Content-Range": `bytes ${start}-${end}/*` + }; + const requestOptions = prepareRequestOptions( + { + url: joinURL(context.remoteURL, encodePath(filePath)), + method: "PUT", + headers, + data + }, + context, + options + ); + const response = await request(requestOptions); + handleResponseCode(context, response); +} diff --git a/source/types.ts b/source/types.ts index 145f48a..bea4621 100644 --- a/source/types.ts +++ b/source/types.ts @@ -114,7 +114,9 @@ export enum ErrorCode { DataTypeNoLength = "data-type-no-length", InvalidAuthType = "invalid-auth-type", InvalidOutputFormat = "invalid-output-format", - LinkUnsupportedAuthType = "link-unsupported-auth" + LinkUnsupportedAuthType = "link-unsupported-auth", + InvalidUpdateRange = "invalid-update-range", + NotSupported = "not-supported" } export interface FileStat { @@ -133,6 +135,11 @@ export interface SearchResult { results: FileStat[]; } +export interface DAVCompliance { + compliance: string[]; + server: string; +} + export interface GetDirectoryContentsOptions extends WebDAVMethodOptions { deep?: boolean; details?: boolean; @@ -265,6 +272,7 @@ export interface WebDAVClient { customRequest: (path: string, requestOptions: RequestOptionsCustom) => Promise; deleteFile: (filename: string) => Promise; exists: (path: string) => Promise; + getDAVCompliance: (path: string) => Promise; getDirectoryContents: ( path: string, options?: GetDirectoryContentsOptions @@ -290,6 +298,13 @@ export interface WebDAVClient { data: string | BufferLike | Stream.Readable, options?: PutFileContentsOptions ) => Promise; + partialUpdateFileContents: ( + filePath: string, + start: number, + end: number, + data: string | BufferLike | Stream.Readable, + options?: WebDAVMethodOptions + ) => Promise; search: ( path: string, options?: SearchOptions diff --git a/test/node/operations/partialUpdateFileContents.spec.ts b/test/node/operations/partialUpdateFileContents.spec.ts new file mode 100644 index 0000000..955f108 --- /dev/null +++ b/test/node/operations/partialUpdateFileContents.spec.ts @@ -0,0 +1,49 @@ +import path from "path"; +import { fileURLToPath } from "url"; +import fileExists from "exists-file"; +import directoryExists from "directory-exists"; +import { expect } from "chai"; +import { + SERVER_PASSWORD, + SERVER_PORT, + SERVER_USERNAME, + clean, + createWebDAVClient, + createWebDAVServer, + restoreRequests, + useRequestSpy +} from "../../helpers.node.js"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +const TEST_CONTENTS = path.resolve(dirname, "../../testContents"); + +describe("partialUpdateFileContents", function () { + beforeEach(function () { + this.client = createWebDAVClient(`http://localhost:${SERVER_PORT}/webdav/server`, { + username: SERVER_USERNAME, + password: SERVER_PASSWORD + }); + clean(); + this.server = createWebDAVServer(); + this.requestSpy = useRequestSpy(); + return this.server.start(); + }); + + afterEach(function () { + restoreRequests(); + return this.server.stop(); + }); + + it("partial update should be failed with server support", async function () { + let err: Error | null = null; + try { + // the server does not support partial update + await this.client.partialUpdateFileContents("/patch.bin", 1, 3, "foo"); + } catch (error) { + err = error; + } + expect(err).to.be.an.instanceof(Error); + expect(fileExists.sync(path.join(TEST_CONTENTS, "./patch.bin"))).to.be.false; + }); +});