/* * Copyright (c) 2025, Salesforce, Inc. * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ /** * WebDAV client for B2C Commerce file operations. * * Provides typed methods for common WebDAV operations like file upload, * download, directory creation, and listing. * * @module clients/webdav */ import {parseStringPromise} from 'xml2js'; import type {Dispatcher} from 'undici'; import type {AuthStrategy} from '../auth/types.js'; import {HTTPError} from '../errors/http-error.js'; import {wrapNetworkError} from '../errors/network-error.js'; import {getLogger} from '../logging/logger.js'; import {globalMiddlewareRegistry, type MiddlewareRegistry, type UnifiedMiddleware} from './middleware-registry.js'; /** * Result of a PROPFIND operation. */ export interface PropfindEntry { href: string; displayName?: string; isCollection: boolean; contentLength?: number; lastModified?: Date; contentType?: string; } /** * WebDAV client for B2C Commerce instance file operations. * * Handles WebDAV requests with proper authentication and provides * typed methods for common operations. * * **Note:** This client is typically accessed via `B2CInstance.webdav` rather * than instantiated directly. The `B2CInstance` class handles authentication setup. * * @example * // Via B2CInstance (recommended) * import { resolveConfig } from '@salesforce/b2c-tooling-sdk/config'; * const config = resolveConfig(); * const instance = config.createB2CInstance(); * await instance.webdav.mkcol('Cartridges/v1'); * await instance.webdav.put('Cartridges/v1/app.zip', zipBuffer); * * @example * // Direct instantiation (advanced) * const client = new WebDavClient('sandbox.demandware.net', authStrategy); * await client.mkcol('Cartridges/v1'); * await client.put('Cartridges/v1/app_storefront/cartridge.zip', zipBuffer); */ /** * Options for creating a WebDAV client. */ export interface WebDavClientOptions { /** * Middleware registry to use for this client. * If not specified, uses the global middleware registry. */ middlewareRegistry?: MiddlewareRegistry; /** * undici dispatcher for custom TLS options (mTLS, self-signed certs). * Use createTlsDispatcher() to create one with client certificates. */ dispatcher?: Dispatcher; } export class WebDavClient { private baseUrl: string; private middlewareRegistry: MiddlewareRegistry; private dispatcher?: Dispatcher; /** * Creates a new WebDAV client. * * @param hostname - WebDAV hostname (may differ from API hostname) * @param auth - Authentication strategy to use for requests * @param options - Optional configuration including middleware registry and TLS dispatcher */ constructor( hostname: string, private auth: AuthStrategy, options?: WebDavClientOptions, ) { this.baseUrl = `https://${hostname}/on/demandware.servlet/webdav/Sites`; this.middlewareRegistry = options?.middlewareRegistry ?? globalMiddlewareRegistry; this.dispatcher = options?.dispatcher; } /** * Builds the full URL for a WebDAV path. * * @param path - Path relative to /webdav/Sites/ * @returns Full URL */ buildUrl(path: string): string { const cleanPath = path.startsWith('/') ? path.slice(1) : path; return `${this.baseUrl}/${cleanPath}`; } /** * Collects middleware from the registry for WebDAV client. */ private getMiddleware(): UnifiedMiddleware[] { return this.middlewareRegistry.getMiddleware('webdav'); } /** * Makes a raw WebDAV request. * * @param path - Path relative to /webdav/Sites/ * @param init - Fetch init options * @returns Response from the server */ async request(path: string, init?: RequestInit): Promise { const logger = getLogger(); const url = this.buildUrl(path); // Build initial request object let request = new Request(url, init); // Apply onRequest middleware (in registration order) // We construct a compatible params object for openapi-fetch middleware const middleware = this.getMiddleware(); const middlewareParams = { request, schemaPath: path, // Minimal compatibility fields for openapi-fetch middleware options: {baseUrl: this.baseUrl}, params: {}, id: 'webdav', }; for (const m of middleware) { if (m.onRequest) { // openapi-fetch's MiddlewareCallbackParams has many internal fields we // don't reproduce here; the cast is intentional and tightening the type // would require pulling those into our own contract. // @ts-expect-error — see comment above const result = await m.onRequest(middlewareParams); if (result instanceof Request) { request = result; middlewareParams.request = request; } } } // Debug: Log request start logger.debug({method: request.method, url: request.url}, `[WebDAV REQ] ${request.method} ${request.url}`); // Trace: Log request details logger.trace( { method: request.method, url: request.url, headers: this.headersToObject(request.headers), body: this.formatBody(init?.body), }, `[WebDAV REQ BODY] ${request.method} ${request.url}`, ); const startTime = Date.now(); // Use auth.fetch with the (potentially modified) request // Pass dispatcher for TLS/mTLS support let response: Response; try { response = await this.auth.fetch(request.url, { method: request.method, headers: request.headers, body: init?.body, // Use original body since Request body may have been consumed dispatcher: this.dispatcher, }); } catch (err) { const host = new URL(this.baseUrl).host; throw wrapNetworkError(err, {operation: `WebDAV ${request.method}`, host}); } const duration = Date.now() - startTime; // Apply onResponse middleware (in registration order) const responseParams = { ...middlewareParams, request, response, }; for (const m of middleware) { if (m.onResponse) { // See note on the onRequest cast above; same rationale applies. // @ts-expect-error — partial reimplementation of openapi-fetch's MiddlewareCallbackParams const result = await m.onResponse(responseParams); if (result instanceof Response) { response = result; responseParams.response = response; } } } // Debug: Log response summary logger.debug( {method: request.method, url: request.url, status: response.status, duration}, `[WebDAV RESP] ${request.method} ${request.url} ${response.status} ${duration}ms`, ); // Trace: Log response details const responseHeaders = this.headersToObject(response.headers); let responseBody: string | undefined; if (response.headers.get('content-type')?.includes('xml')) { const clonedResponse = response.clone(); responseBody = await clonedResponse.text(); } logger.trace( {method: request.method, url: request.url, headers: responseHeaders, body: responseBody}, `[WebDAV RESP BODY] ${request.method} ${request.url}`, ); return response; } /** * Converts Headers to a plain object for logging. */ private headersToObject(headers?: RequestInit['headers'] | Headers): Record | undefined { if (!headers) return undefined; const result: Record = {}; if (headers instanceof Headers) { headers.forEach((value, key) => { result[key] = value; }); } else if (Array.isArray(headers)) { for (const [key, value] of headers) { result[key] = value; } } else { Object.assign(result, headers); } return result; } /** * Formats body for logging, describing binary data. */ private formatBody(body?: RequestInit['body']): string | undefined { if (!body) return undefined; if (typeof body === 'string') { return body; } if (body instanceof Buffer || body instanceof ArrayBuffer) { return `[Binary: ${body instanceof Buffer ? body.length : body.byteLength} bytes]`; } if (body instanceof Blob) { return `[Blob: ${body.size} bytes]`; } return '[Body]'; } /** * Creates a directory (collection). * * @param path - Path to create * @throws Error if creation fails (except 405 which means already exists) * * @example * await client.mkcol('Cartridges/v1'); */ async mkcol(path: string): Promise { const response = await this.request(path, {method: 'MKCOL'}); // 201 = created, 405 = already exists (acceptable) if (!response.ok && response.status !== 405) { throw new HTTPError(`MKCOL failed: ${response.status} ${response.statusText}`, response, 'MKCOL'); } } /** * Uploads a file. * * @param path - Destination path * @param content - File content as Buffer, Blob, or string * @param contentType - Optional content type header * * @example * await client.put('Cartridges/v1/app.zip', zipBuffer, 'application/zip'); */ async put(path: string, content: Buffer | Blob | string, contentType?: string): Promise { const headers: Record = {}; if (contentType) { headers['Content-Type'] = contentType; } const response = await this.request(path, {method: 'PUT', headers, body: content}); if (!response.ok) { const hints: Record = { 413: '(sandbox may be stopped or unavailable)', }; const hint = hints[response.status]; throw new HTTPError( `PUT failed: ${response.status} ${response.statusText}${hint ? ` ${hint}` : ''}`, response, 'PUT', ); } } /** * Downloads a file. * * @param path - Path to download * @returns File content as ArrayBuffer * * @example * const content = await client.get('Cartridges/v1/app.zip'); */ async get(path: string): Promise { const response = await this.request(path, {method: 'GET'}); if (!response.ok) { throw new HTTPError(`GET failed: ${response.status} ${response.statusText}`, response, 'GET'); } return response.arrayBuffer(); } /** * Deletes a file or directory. * * @param path - Path to delete * @throws Error if the path doesn't exist (404) or deletion fails * * @example * await client.delete('Cartridges/v1/old-cartridge'); */ async delete(path: string): Promise { const response = await this.request(path, {method: 'DELETE'}); if (!response.ok) { throw new HTTPError(`DELETE failed: ${response.status} ${response.statusText}`, response, 'DELETE'); } } /** * Lists directory contents. * * @param path - Directory path * @param depth - PROPFIND depth (0, 1, or 'infinity') * @returns Array of entries in the directory * * @example * const entries = await client.propfind('Cartridges'); * for (const entry of entries) { * console.log(entry.displayName, entry.isCollection); * } */ async propfind(path: string, depth: '0' | '1' | 'infinity' = '1'): Promise { const response = await this.request(path, { method: 'PROPFIND', headers: { Depth: depth, 'Content-Type': 'application/xml', }, body: ` `, }); if (!response.ok) { throw new HTTPError(`PROPFIND failed: ${response.status} ${response.statusText}`, response, 'PROPFIND'); } const xml = await response.text(); return await this.parsePropfindResponse(xml); } /** * Copies a file or directory. * * @param source - Source path relative to /webdav/Sites/ * @param destination - Destination path relative to /webdav/Sites/ * @param overwrite - Whether to overwrite if destination exists (default: true) * * @example * await client.copy('Cartridges/v1/cartridge', 'Cartridges/v2/cartridge'); */ async copy(source: string, destination: string, overwrite = true): Promise { const destUrl = this.buildUrl(destination); const response = await this.request(source, { method: 'COPY', headers: { Destination: new URL(destUrl).pathname, Overwrite: overwrite ? 'T' : 'F', }, }); if (!response.ok) { throw new HTTPError(`COPY failed: ${response.status} ${response.statusText}`, response, 'COPY'); } } /** * Moves (renames) a file or directory. * * @param source - Source path relative to /webdav/Sites/ * @param destination - Destination path relative to /webdav/Sites/ * @param overwrite - Whether to overwrite if destination exists (default: true) * * @example * await client.move('Cartridges/v1/old-name', 'Cartridges/v1/new-name'); */ async move(source: string, destination: string, overwrite = true): Promise { const destUrl = this.buildUrl(destination); const response = await this.request(source, { method: 'MOVE', headers: { Destination: new URL(destUrl).pathname, Overwrite: overwrite ? 'T' : 'F', }, }); if (!response.ok) { throw new HTTPError(`MOVE failed: ${response.status} ${response.statusText}`, response, 'MOVE'); } } /** * Checks if a path exists. * * @param path - Path to check * @returns true if exists, false otherwise */ async exists(path: string): Promise { const response = await this.request(path, {method: 'HEAD'}); return response.ok; } /** * Parses PROPFIND XML response into structured entries using xml2js. */ private async parsePropfindResponse(xml: string): Promise { const entries: PropfindEntry[] = []; // Parse with xml2js, stripping namespace prefixes for easier access const result = await parseStringPromise(xml, { tagNameProcessors: [(name: string) => name.replace(/^[^:]+:/, '')], // Strip namespace prefix explicitArray: false, }); // Get the multistatus root - may be 'multistatus' or 'D:multistatus' after processing const multistatus = result.multistatus; if (!multistatus) { return entries; } // Get response array - may be single object or array const responses = Array.isArray(multistatus.response) ? multistatus.response : [multistatus.response]; for (const response of responses) { if (!response) continue; const href = this.getXmlText(response.href) || ''; const propstat = response.propstat; const prop = propstat?.prop; if (!prop) continue; const displayName = this.getXmlText(prop.displayname); const isCollection = prop.resourcetype?.collection !== undefined; const contentLength = this.getXmlText(prop.getcontentlength); const lastModified = this.getXmlText(prop.getlastmodified); const contentType = this.getXmlText(prop.getcontenttype); entries.push({ href, displayName, isCollection, contentLength: contentLength ? parseInt(contentLength, 10) : undefined, lastModified: lastModified ? new Date(lastModified) : undefined, contentType: contentType && contentType !== 'null' ? contentType : undefined, }); } return entries; } /** * Extracts text content from an xml2js parsed value. * Handles both string values and objects with '_' text content. */ private getXmlText(value: unknown): string | undefined { if (value === undefined || value === null) return undefined; if (typeof value === 'string') return value || undefined; if (typeof value === 'object' && '_' in (value as Record)) { return (value as Record)._ || undefined; } return undefined; } }