325 lines
8.4 KiB
TypeScript
325 lines
8.4 KiB
TypeScript
|
import { ffetchBase, type FfetchResult } from '@fuman/fetch'
|
||
|
import { asNonNull, assert, base64, utf8 } from '@fuman/utils'
|
||
|
import { Parser } from 'htmlparser2'
|
||
|
import { z } from 'zod'
|
||
|
|
||
|
const XML_HEADER = '<?xml version="1.0" encoding="utf-8" ?>'
|
||
|
|
||
|
export interface WebdavClientOptions {
|
||
|
baseUrl: string
|
||
|
username?: string
|
||
|
password?: string
|
||
|
headers?: Record<string, string>
|
||
|
}
|
||
|
|
||
|
export interface WebdavResourceBase {
|
||
|
href: string
|
||
|
name: string
|
||
|
status: string
|
||
|
lastModified?: Date
|
||
|
raw: Record<string, unknown>
|
||
|
// todo: lockdiscovery
|
||
|
// todo: supportedlock
|
||
|
}
|
||
|
|
||
|
export interface WebdavCollection extends WebdavResourceBase {
|
||
|
type: 'collection'
|
||
|
}
|
||
|
|
||
|
export interface WebdavFile extends WebdavResourceBase {
|
||
|
type: 'file'
|
||
|
size: number
|
||
|
etag?: string
|
||
|
contentType?: string
|
||
|
}
|
||
|
|
||
|
export type WebdavResource = WebdavCollection | WebdavFile
|
||
|
|
||
|
const DResponseSchema = z.object({
|
||
|
'd:href': z.string(),
|
||
|
'd:propstat': z.object({
|
||
|
'd:prop': z.object({
|
||
|
'd:resourcetype': z.union([
|
||
|
z.literal(true),
|
||
|
z.object({
|
||
|
'd:collection': z.literal(true),
|
||
|
}),
|
||
|
]),
|
||
|
'd:displayname': z.union([z.literal(true), z.string()]),
|
||
|
'd:getcontentlength': z.coerce.number().optional(),
|
||
|
'd:getlastmodified': z.string().transform(v => new Date(v)).optional(),
|
||
|
'd:getetag': z.string().optional(),
|
||
|
'd:getcontenttype': z.string().optional(),
|
||
|
}).passthrough(),
|
||
|
'd:status': z.string(),
|
||
|
}),
|
||
|
})
|
||
|
|
||
|
const DMultistatusSchema = z.object({
|
||
|
'd:multistatus': z.tuple([z.object({
|
||
|
'd:response': z.array(DResponseSchema),
|
||
|
})]),
|
||
|
})
|
||
|
|
||
|
function escapeXml(str: string) {
|
||
|
return str.replace(/</g, '<').replace(/>/g, '>')
|
||
|
}
|
||
|
|
||
|
function xmlToJson(xml: string) {
|
||
|
const res: Record<string, any[]> = {}
|
||
|
|
||
|
const stack: any[] = [res]
|
||
|
|
||
|
const parser = new Parser({
|
||
|
onopentag(name) {
|
||
|
name = name.toLowerCase()
|
||
|
|
||
|
const node: any = {}
|
||
|
const top = stack[stack.length - 1]
|
||
|
if (!top[name]) {
|
||
|
top[name] = []
|
||
|
}
|
||
|
top[name].push(node)
|
||
|
stack.push(node)
|
||
|
},
|
||
|
onclosetag(name) {
|
||
|
const obj = stack.pop()
|
||
|
const top = stack[stack.length - 1]
|
||
|
const ourIdx = top[name].length - 1
|
||
|
|
||
|
const keys = Object.keys(obj)
|
||
|
if (keys.length === 1 && keys[0] === '_text') {
|
||
|
top[name][ourIdx] = obj._text
|
||
|
} else if (keys.length === 0) {
|
||
|
top[name][ourIdx] = true
|
||
|
} else {
|
||
|
// replace one-element arrays with the element itself
|
||
|
for (const key of keys) {
|
||
|
if (key === 'd:response') continue
|
||
|
const val = obj[key]
|
||
|
if (Array.isArray(val) && val.length === 1) {
|
||
|
obj[key] = val[0]
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
ontext(text) {
|
||
|
const top = stack[stack.length - 1]
|
||
|
if (top._text === undefined) {
|
||
|
top._text = ''
|
||
|
}
|
||
|
top._text += text
|
||
|
},
|
||
|
})
|
||
|
|
||
|
parser.write(xml)
|
||
|
parser.end()
|
||
|
|
||
|
return res
|
||
|
}
|
||
|
|
||
|
export class WebdavClient {
|
||
|
readonly ffetch: typeof ffetchBase
|
||
|
readonly basePath
|
||
|
|
||
|
constructor(options: WebdavClientOptions) {
|
||
|
const headers: Record<string, string> = {
|
||
|
'Content-Type': 'application/xml; charset="utf-8"',
|
||
|
...options.headers,
|
||
|
}
|
||
|
if (options.username) {
|
||
|
let authStr = options.username
|
||
|
if (options.password) {
|
||
|
authStr += `:${options.password}`
|
||
|
}
|
||
|
headers.Authorization = `Basic ${base64.encode(utf8.encoder.encode(authStr))}`
|
||
|
}
|
||
|
|
||
|
this.ffetch = ffetchBase.extend({
|
||
|
baseUrl: options.baseUrl,
|
||
|
headers,
|
||
|
})
|
||
|
this.basePath = new URL(options.baseUrl).pathname
|
||
|
if (this.basePath[this.basePath.length - 1] !== '/') {
|
||
|
this.basePath += '/'
|
||
|
}
|
||
|
}
|
||
|
|
||
|
mapPropfindResponse = (obj: z.infer<typeof DResponseSchema>): WebdavResource => {
|
||
|
const name = obj['d:propstat']['d:prop']['d:displayname']
|
||
|
const base: WebdavResourceBase = {
|
||
|
href: obj['d:href'],
|
||
|
name: name === true ? '' : name,
|
||
|
status: obj['d:propstat']['d:status'],
|
||
|
lastModified: obj['d:propstat']['d:prop']['d:getlastmodified'],
|
||
|
raw: obj['d:propstat']['d:prop'],
|
||
|
}
|
||
|
if (base.href.startsWith(this.basePath)) {
|
||
|
base.href = base.href.slice(this.basePath.length)
|
||
|
if (base.href !== '/') {
|
||
|
base.href = `/${base.href}`
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (typeof obj['d:propstat']['d:prop']['d:resourcetype'] === 'object' && obj['d:propstat']['d:prop']['d:resourcetype']['d:collection']) {
|
||
|
const res = base as WebdavCollection
|
||
|
res.type = 'collection'
|
||
|
return res
|
||
|
} else {
|
||
|
const res = base as WebdavFile
|
||
|
res.type = 'file'
|
||
|
res.size = asNonNull(obj['d:propstat']['d:prop']['d:getcontentlength'])
|
||
|
res.etag = obj['d:propstat']['d:prop']['d:getetag']
|
||
|
res.contentType = obj['d:propstat']['d:prop']['d:getcontenttype']
|
||
|
return res
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async propfind(
|
||
|
path: string,
|
||
|
params?: {
|
||
|
depth?: number | 'infinity'
|
||
|
properties?: string[]
|
||
|
},
|
||
|
): Promise<WebdavResource[]> {
|
||
|
const body = params?.properties
|
||
|
? [
|
||
|
XML_HEADER,
|
||
|
'<d:propfind xmlns:D="DAV:">',
|
||
|
'<d:prop>',
|
||
|
...params.properties.map(prop => `<${prop}/>`),
|
||
|
'</d:prop>',
|
||
|
'</d:propfind>',
|
||
|
].join('\n')
|
||
|
: undefined
|
||
|
const res = await this.ffetch(path, {
|
||
|
method: 'PROPFIND',
|
||
|
headers: {
|
||
|
Depth: params?.depth ? String(params.depth) : '1',
|
||
|
},
|
||
|
body,
|
||
|
}).text()
|
||
|
|
||
|
const json = DMultistatusSchema.parse(xmlToJson(res))
|
||
|
return json['d:multistatus'][0]['d:response'].map(this.mapPropfindResponse)
|
||
|
}
|
||
|
|
||
|
async proppatch(path: string, params: {
|
||
|
set?: Record<string, string | { _xml: string }>
|
||
|
remove?: string[]
|
||
|
}): Promise<void> {
|
||
|
if (!params.set && !params.remove) return
|
||
|
|
||
|
const lines: string[] = [
|
||
|
XML_HEADER,
|
||
|
'<d:propertyupdate xmlns:D="DAV:">',
|
||
|
]
|
||
|
if (params.set) {
|
||
|
lines.push('<d:set>')
|
||
|
for (const [key, value] of Object.entries(params.set ?? {})) {
|
||
|
lines.push(`<d:prop><${key}>${
|
||
|
typeof value === 'object' ? value._xml : escapeXml(value)
|
||
|
}</${key}></d:prop>`)
|
||
|
}
|
||
|
lines.push('</d:set>')
|
||
|
}
|
||
|
if (params.remove) {
|
||
|
lines.push('<d:remove>')
|
||
|
for (const key of params.remove) {
|
||
|
lines.push(`<d:prop><${key}/></d:prop>`)
|
||
|
}
|
||
|
lines.push('</d:remove>')
|
||
|
}
|
||
|
lines.push('</d:propertyupdate>')
|
||
|
|
||
|
const body = lines.join('\n')
|
||
|
await this.ffetch(path, {
|
||
|
method: 'PROPPATCH',
|
||
|
body,
|
||
|
})
|
||
|
}
|
||
|
|
||
|
async mkcol(path: string): Promise<void> {
|
||
|
const res = await this.ffetch(path, {
|
||
|
method: 'MKCOL',
|
||
|
})
|
||
|
if (res.status !== 201) throw new Error(`mkcol failed: ${res.status}`)
|
||
|
}
|
||
|
|
||
|
async delete(path: string): Promise<void> {
|
||
|
const res = await this.ffetch(path, {
|
||
|
method: 'DELETE',
|
||
|
})
|
||
|
if (res.status !== 204) throw new Error(`delete failed: ${res.status}`)
|
||
|
}
|
||
|
|
||
|
get(path: string): FfetchResult {
|
||
|
return this.ffetch(path, {
|
||
|
method: 'GET',
|
||
|
})
|
||
|
}
|
||
|
|
||
|
async put(path: string, body: BodyInit): Promise<void> {
|
||
|
await this.ffetch(path, {
|
||
|
method: 'PUT',
|
||
|
body,
|
||
|
})
|
||
|
}
|
||
|
|
||
|
async copy(
|
||
|
source: string,
|
||
|
destination: string,
|
||
|
params?: {
|
||
|
/** whether to overwrite the destination if it exists */
|
||
|
overwrite?: boolean
|
||
|
depth?: number | 'infinity'
|
||
|
},
|
||
|
): Promise<void> {
|
||
|
if (destination[0] === '/') destination = destination.slice(1)
|
||
|
if (this.basePath) destination = this.basePath + destination
|
||
|
const headers: Record<string, string> = {
|
||
|
Destination: destination,
|
||
|
}
|
||
|
if (params?.overwrite !== true) {
|
||
|
headers.Overwrite = 'F'
|
||
|
}
|
||
|
if (params?.depth) {
|
||
|
headers.Depth = String(params.depth)
|
||
|
}
|
||
|
|
||
|
const res = await this.ffetch(source, {
|
||
|
method: 'COPY',
|
||
|
headers,
|
||
|
})
|
||
|
if (res.status !== 201) throw new Error(`copy failed: ${res.status}`)
|
||
|
}
|
||
|
|
||
|
async move(
|
||
|
source: string,
|
||
|
destination: string,
|
||
|
params?: {
|
||
|
/** whether to overwrite the destination if it exists */
|
||
|
overwrite?: boolean
|
||
|
depth?: number | 'infinity'
|
||
|
},
|
||
|
): Promise<void> {
|
||
|
if (destination[0] === '/') destination = destination.slice(1)
|
||
|
if (this.basePath) destination = this.basePath + destination
|
||
|
const headers: Record<string, string> = {
|
||
|
Destination: destination,
|
||
|
}
|
||
|
if (params?.overwrite !== true) {
|
||
|
headers.Overwrite = 'F'
|
||
|
}
|
||
|
if (params?.depth) {
|
||
|
headers.Depth = String(params.depth)
|
||
|
}
|
||
|
|
||
|
const res = await this.ffetch(source, {
|
||
|
method: 'MOVE',
|
||
|
headers,
|
||
|
})
|
||
|
if (res.status !== 201) throw new Error(`move failed: ${res.status}`)
|
||
|
}
|
||
|
}
|