scripts/utils/webdav.ts

324 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, '&lt;').replace(/>/g, '&gt;')
}
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}`)
}
}