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 = '' export interface WebdavClientOptions { baseUrl: string username?: string password?: string headers?: Record } export interface WebdavResourceBase { href: string name: string status: string lastModified?: Date raw: Record // 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, '>') } function xmlToJson(xml: string) { const res: Record = {} 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 = { '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): 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 { const body = params?.properties ? [ XML_HEADER, '', '', ...params.properties.map(prop => `<${prop}/>`), '', '', ].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 remove?: string[] }): Promise { if (!params.set && !params.remove) return const lines: string[] = [ XML_HEADER, '', ] if (params.set) { lines.push('') for (const [key, value] of Object.entries(params.set ?? {})) { lines.push(`<${key}>${ typeof value === 'object' ? value._xml : escapeXml(value) }`) } lines.push('') } if (params.remove) { lines.push('') for (const key of params.remove) { lines.push(`<${key}/>`) } lines.push('') } lines.push('') const body = lines.join('\n') await this.ffetch(path, { method: 'PROPPATCH', body, }) } async mkcol(path: string): Promise { const res = await this.ffetch(path, { method: 'MKCOL', }) if (res.status !== 201) throw new Error(`mkcol failed: ${res.status}`) } async delete(path: string): Promise { 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 { 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 { if (destination[0] === '/') destination = destination.slice(1) if (this.basePath) destination = this.basePath + destination const headers: Record = { 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 { if (destination[0] === '/') destination = destination.slice(1) if (this.basePath) destination = this.basePath + destination const headers: Record = { 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}`) } }