Files
sftp-proxy/src/adapters/client/webdav.ts
2026-02-20 17:14:35 +11:00

128 lines
4.3 KiB
TypeScript

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<GenericNode[]> {
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<GenericNode> {
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<Readable> {
const remotePath = this.resolvePath(path)
const stream = this.webdav.createReadStream(remotePath)
return Readable.from(stream)
}
async write(path: string, stream: Readable): Promise<void> {
const remotePath = this.resolvePath(path)
await this.webdav.putFileContents(remotePath, stream)
}
async mkdir(path: string): Promise<void> {
const remotePath = this.resolvePath(path)
await this.webdav.createDirectory(remotePath)
}
async delete(path: string): Promise<void> {
const remotePath = this.resolvePath(path)
await this.webdav.deleteFile(remotePath)
}
async rename(from: string, to: string): Promise<void> {
const remoteFrom = this.resolvePath(from)
const remoteTo = this.resolvePath(to)
await this.webdav.moveFile(remoteFrom, remoteTo)
}
}