initial commit
This commit is contained in:
127
src/adapters/client/webdav.ts
Normal file
127
src/adapters/client/webdav.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user