128 lines
4.3 KiB
TypeScript
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)
|
|
}
|
|
}
|