import { Readable } from 'node:stream' import { posix } from 'node:path' import { createClient, type WebDAVClient, type FileStat } from 'webdav' import type { GenericNode, ClientConfig } from '../../types.js' import type { BaseCache } from '../cache/base.js' import { BaseClient } from './base.js' import { logger } from '../../logger.js' /** * WebDAV remote storage client. * * Uses `PROPFIND Depth: 1` for listings — never assumes `Depth: infinity` is supported. * Never fetches file contents during sync, only metadata. */ export class WebDAVClientAdapter extends BaseClient { private webdav: WebDAVClient constructor(config: ClientConfig, cache: BaseCache) { super(config, cache) this.webdav = createClient(config.url, { username: config.username, password: config.password, }) logger.info(`WebDAV client created for ${config.url} (mount: ${config.mountPath})`) } /** Maps a webdav FileStat to our GenericNode format. */ private toNode(stat: FileStat, parentPath: string): GenericNode { const nodePath = posix.join(parentPath, stat.basename) return { path: nodePath, name: stat.basename, isDir: stat.type === 'directory', size: stat.size, modified: new Date(stat.lastmod), etag: stat.etag ?? undefined, } } async list(path: string): Promise { const remotePath = this.resolvePath(path) const contents = await this.webdav.getDirectoryContents(remotePath, { deep: false }) as FileStat[] // The webdav package's built-in self-reference filter breaks when the server // has a non-root base path (e.g. Nextcloud's /remote.php/dav/files/user). // It compares stat.filename (relativized) against the request path (absolute), // so they never match and the directory itself leaks into the results. // // stat.filename is either: // - absolute like "/Media" (when serverBase is "/") // - relative like "Media" (when serverBase is a deeper path) // // We normalise both sides and also compare basenames to catch all variants. const normRemote = remotePath.replace(/\/+$/, '') || '/' const parentBasename = posix.basename(normRemote) const children = contents.filter(stat => { // Empty basename = root self-reference if (!stat.basename) return false const fn = (stat.filename || '').replace(/\/+$/, '') || '/' // Absolute match (serverBase is "/") if (fn === normRemote) return false // Relative match (serverBase is deeper — filename has no leading slash) if ('/' + fn === normRemote) return false if (fn === normRemote.slice(1)) return false // Directory whose basename matches the listed directory AND whose // filename doesn't contain a slash (i.e. it's a single path segment, // meaning it's the directory itself, not a nested child) if ( stat.type === 'directory' && stat.basename === parentBasename && !stat.filename.includes('/') ) return false return true }) return children.map(stat => this.toNode(stat, path)) } async stat(path: string): Promise { const remotePath = this.resolvePath(path) const result = await this.webdav.stat(remotePath) as FileStat return { path, name: posix.basename(path) || '/', isDir: result.type === 'directory', size: result.size, modified: new Date(result.lastmod), etag: result.etag ?? undefined, } } async read(path: string): Promise { const remotePath = this.resolvePath(path) const stream = this.webdav.createReadStream(remotePath) return Readable.from(stream) } async write(path: string, stream: Readable): Promise { const remotePath = this.resolvePath(path) await this.webdav.putFileContents(remotePath, stream) } async mkdir(path: string): Promise { const remotePath = this.resolvePath(path) await this.webdav.createDirectory(remotePath) } async delete(path: string): Promise { const remotePath = this.resolvePath(path) await this.webdav.deleteFile(remotePath) } async rename(from: string, to: string): Promise { const remoteFrom = this.resolvePath(from) const remoteTo = this.resolvePath(to) await this.webdav.moveFile(remoteFrom, remoteTo) } }