File
Blob: src/worker/xml/dav-request.ts
| 1 | import { parseSafeXml, type ParsedXmlElement, type ParsedXmlNode } from "@/worker/xml/parser"; |
| 2 | import type { QName } from "@/worker/xml/namespaces"; |
| 3 | import { DAV_NS } from "@/worker/xml/namespaces"; |
| 4 | import { escapeXml } from "@/worker/dav/multistatus"; |
| 5 | |
| 6 | export type PropfindRequest = |
| 7 | | { kind: "allprop"; includeProps: QName[] } |
| 8 | | { kind: "propname" } |
| 9 | | { kind: "prop"; props: QName[] }; |
| 10 | |
| 11 | export type ProppatchInstruction = |
| 12 | | { kind: "set"; prop: QName; xmlValue: string } |
| 13 | | { kind: "remove"; prop: QName; xmlValue: string }; |
| 14 | |
| 15 | interface NamedElement { |
| 16 | element: ParsedXmlElement; |
| 17 | qname: QName; |
| 18 | namespaces: Record<string, string>; |
| 19 | } |
| 20 | |
| 21 | function splitName(name: string): { prefix: string; localName: string } { |
| 22 | const index = name.indexOf(":"); |
| 23 | if (index === -1) return { prefix: "", localName: name }; |
| 24 | return { prefix: name.slice(0, index), localName: name.slice(index + 1) }; |
| 25 | } |
| 26 | |
| 27 | function namespaceMap(element: ParsedXmlElement, parent: Record<string, string>): Record<string, string> { |
| 28 | const namespaces = { ...parent }; |
| 29 | for (const [name, value] of Object.entries(element.attributes)) { |
| 30 | if (name === "xmlns") namespaces[""] = value; |
| 31 | if (name.startsWith("xmlns:")) namespaces[name.slice("xmlns:".length)] = value; |
| 32 | } |
| 33 | return namespaces; |
| 34 | } |
| 35 | |
| 36 | function qnameFor(element: ParsedXmlElement, namespaces: Record<string, string>): QName { |
| 37 | const { prefix, localName } = splitName(element.name); |
| 38 | return { nsUri: namespaces[prefix] ?? "", localName }; |
| 39 | } |
| 40 | |
| 41 | function elementChildren(element: ParsedXmlElement, parentNamespaces: Record<string, string>): NamedElement[] { |
| 42 | const namespaces = namespaceMap(element, parentNamespaces); |
| 43 | return element.children |
| 44 | .filter((child): child is ParsedXmlElement => typeof child !== "string") |
| 45 | .map((child) => { |
| 46 | const childNamespaces = namespaceMap(child, namespaces); |
| 47 | return { element: child, qname: qnameFor(child, childNamespaces), namespaces: childNamespaces }; |
| 48 | }); |
| 49 | } |
| 50 | |
| 51 | function childElement( |
| 52 | element: ParsedXmlElement, |
| 53 | namespaces: Record<string, string>, |
| 54 | localName: string, |
| 55 | ): NamedElement | null { |
| 56 | return ( |
| 57 | elementChildren(element, namespaces).find( |
| 58 | (child) => child.qname.nsUri === DAV_NS && child.qname.localName === localName, |
| 59 | ) ?? null |
| 60 | ); |
| 61 | } |
| 62 | |
| 63 | function parseDocument(xml: string, maxBytes: number): NamedElement { |
| 64 | const { root } = parseSafeXml(xml, maxBytes); |
| 65 | const namespaces = namespaceMap(root, {}); |
| 66 | return { element: root, qname: qnameFor(root, namespaces), namespaces }; |
| 67 | } |
| 68 | |
| 69 | export function parsePropfindXml(xml: string, maxBytes: number): PropfindRequest { |
| 70 | // RFC 4918 14.20: empty body means "allprop". |
| 71 | if (xml.trim() === "") return { kind: "allprop", includeProps: [] }; |
| 72 | |
| 73 | const root = parseDocument(xml, maxBytes); |
| 74 | if (root.qname.nsUri !== DAV_NS || root.qname.localName !== "propfind") { |
| 75 | throw new Error("Expected DAV:propfind request body"); |
| 76 | } |
| 77 | |
| 78 | // RFC 4918 9.1: propfind body must contain exactly one of propname, allprop, |
| 79 | // or prop. allprop MAY be combined with include; other combinations are invalid. |
| 80 | const propname = childElement(root.element, root.namespaces, "propname"); |
| 81 | const allprop = childElement(root.element, root.namespaces, "allprop"); |
| 82 | const prop = childElement(root.element, root.namespaces, "prop"); |
| 83 | const include = childElement(root.element, root.namespaces, "include"); |
| 84 | |
| 85 | const modeCount = (propname ? 1 : 0) + (allprop ? 1 : 0) + (prop ? 1 : 0); |
| 86 | if (modeCount > 1) { |
| 87 | throw new Error("PROPFIND body must contain exactly one of propname, allprop, or prop"); |
| 88 | } |
| 89 | if (include && !allprop) { |
| 90 | throw new Error("PROPFIND include is only valid alongside allprop"); |
| 91 | } |
| 92 | |
| 93 | if (propname) return { kind: "propname" }; |
| 94 | if (allprop) { |
| 95 | const includeProps = include |
| 96 | ? elementChildren(include.element, include.namespaces).map((child) => child.qname) |
| 97 | : []; |
| 98 | return { kind: "allprop", includeProps }; |
| 99 | } |
| 100 | if (prop) { |
| 101 | return { kind: "prop", props: elementChildren(prop.element, prop.namespaces).map((child) => child.qname) }; |
| 102 | } |
| 103 | // No recognized mode element: default to allprop per the lenient client norm. |
| 104 | return { kind: "allprop", includeProps: [] }; |
| 105 | } |
| 106 | |
| 107 | function serializeNode(node: ParsedXmlNode, namespaces: Record<string, string>): string { |
| 108 | if (typeof node === "string") return node; |
| 109 | return serializeElement(node, namespaces); |
| 110 | } |
| 111 | |
| 112 | function serializeElement(element: ParsedXmlElement, parentNamespaces: Record<string, string>): string { |
| 113 | const namespaces = namespaceMap(element, parentNamespaces); |
| 114 | const attributes = { ...element.attributes }; |
| 115 | const { prefix } = splitName(element.name); |
| 116 | if (prefix && !Object.hasOwn(attributes, `xmlns:${prefix}`) && namespaces[prefix]) { |
| 117 | attributes[`xmlns:${prefix}`] = namespaces[prefix]; |
| 118 | } |
| 119 | if (!prefix && !Object.hasOwn(attributes, "xmlns") && namespaces[""]) { |
| 120 | attributes.xmlns = namespaces[""]; |
| 121 | } |
| 122 | |
| 123 | const attributeXml = Object.entries(attributes) |
| 124 | .map(([name, value]) => ` ${name}="${escapeXml(value)}"`) |
| 125 | .join(""); |
| 126 | if (element.children.length === 0) return `<${element.name}${attributeXml}/>`; |
| 127 | return `<${element.name}${attributeXml}>${element.children.map((child) => serializeNode(child, namespaces)).join("")}</${ |
| 128 | element.name |
| 129 | }>`; |
| 130 | } |
| 131 | |
| 132 | export function parseProppatchXml(xml: string, maxBytes: number): ProppatchInstruction[] { |
| 133 | const root = parseDocument(xml, maxBytes); |
| 134 | if (root.qname.nsUri !== DAV_NS || root.qname.localName !== "propertyupdate") { |
| 135 | throw new Error("Expected DAV:propertyupdate request body"); |
| 136 | } |
| 137 | |
| 138 | const instructions: ProppatchInstruction[] = []; |
| 139 | for (const op of elementChildren(root.element, root.namespaces)) { |
| 140 | if (op.qname.nsUri !== DAV_NS || (op.qname.localName !== "set" && op.qname.localName !== "remove")) continue; |
| 141 | const prop = childElement(op.element, op.namespaces, "prop"); |
| 142 | if (!prop) continue; |
| 143 | for (const child of elementChildren(prop.element, prop.namespaces)) { |
| 144 | instructions.push({ |
| 145 | kind: op.qname.localName, |
| 146 | prop: child.qname, |
| 147 | xmlValue: serializeElement(child.element, prop.namespaces), |
| 148 | }); |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | if (instructions.length === 0) throw new Error("PROPPATCH has no property instructions"); |
| 153 | return instructions; |
| 154 | } |
| 155 | |
| 156 | export function parseLockInfoXml( |
| 157 | xml: string, |
| 158 | maxBytes: number, |
| 159 | ): { |
| 160 | scope: "exclusive" | "shared"; |
| 161 | ownerXml: string; |
| 162 | } { |
| 163 | const root = parseDocument(xml, maxBytes); |
| 164 | if (root.qname.nsUri !== DAV_NS || root.qname.localName !== "lockinfo") { |
| 165 | throw new Error("Expected DAV:lockinfo request body"); |
| 166 | } |
| 167 | |
| 168 | const scopeElement = childElement(root.element, root.namespaces, "lockscope"); |
| 169 | const scopeChild = scopeElement ? elementChildren(scopeElement.element, scopeElement.namespaces)[0] : undefined; |
| 170 | const scope = scopeChild?.qname.localName === "shared" ? "shared" : "exclusive"; |
| 171 | |
| 172 | const owner = childElement(root.element, root.namespaces, "owner"); |
| 173 | return { |
| 174 | scope, |
| 175 | ownerXml: owner ? serializeElement(owner.element, root.namespaces) : '<D:owner xmlns:D="DAV:"/>', |
| 176 | }; |
| 177 | } |