import { parseSafeXml, type ParsedXmlElement, type ParsedXmlNode } from "@/worker/xml/parser"; import type { QName } from "@/worker/xml/namespaces"; import { DAV_NS } from "@/worker/xml/namespaces"; import { escapeXml } from "@/worker/dav/multistatus"; export type PropfindRequest = | { kind: "allprop"; includeProps: QName[] } | { kind: "propname" } | { kind: "prop"; props: QName[] }; export type ProppatchInstruction = | { kind: "set"; prop: QName; xmlValue: string } | { kind: "remove"; prop: QName; xmlValue: string }; interface NamedElement { element: ParsedXmlElement; qname: QName; namespaces: Record; } function splitName(name: string): { prefix: string; localName: string } { const index = name.indexOf(":"); if (index === -1) return { prefix: "", localName: name }; return { prefix: name.slice(0, index), localName: name.slice(index + 1) }; } function namespaceMap(element: ParsedXmlElement, parent: Record): Record { const namespaces = { ...parent }; for (const [name, value] of Object.entries(element.attributes)) { if (name === "xmlns") namespaces[""] = value; if (name.startsWith("xmlns:")) namespaces[name.slice("xmlns:".length)] = value; } return namespaces; } function qnameFor(element: ParsedXmlElement, namespaces: Record): QName { const { prefix, localName } = splitName(element.name); return { nsUri: namespaces[prefix] ?? "", localName }; } function elementChildren(element: ParsedXmlElement, parentNamespaces: Record): NamedElement[] { const namespaces = namespaceMap(element, parentNamespaces); return element.children .filter((child): child is ParsedXmlElement => typeof child !== "string") .map((child) => { const childNamespaces = namespaceMap(child, namespaces); return { element: child, qname: qnameFor(child, childNamespaces), namespaces: childNamespaces }; }); } function childElement( element: ParsedXmlElement, namespaces: Record, localName: string, ): NamedElement | null { return ( elementChildren(element, namespaces).find( (child) => child.qname.nsUri === DAV_NS && child.qname.localName === localName, ) ?? null ); } function parseDocument(xml: string, maxBytes: number): NamedElement { const { root } = parseSafeXml(xml, maxBytes); const namespaces = namespaceMap(root, {}); return { element: root, qname: qnameFor(root, namespaces), namespaces }; } export function parsePropfindXml(xml: string, maxBytes: number): PropfindRequest { // RFC 4918 14.20: empty body means "allprop". if (xml.trim() === "") return { kind: "allprop", includeProps: [] }; const root = parseDocument(xml, maxBytes); if (root.qname.nsUri !== DAV_NS || root.qname.localName !== "propfind") { throw new Error("Expected DAV:propfind request body"); } // RFC 4918 9.1: propfind body must contain exactly one of propname, allprop, // or prop. allprop MAY be combined with include; other combinations are invalid. const propname = childElement(root.element, root.namespaces, "propname"); const allprop = childElement(root.element, root.namespaces, "allprop"); const prop = childElement(root.element, root.namespaces, "prop"); const include = childElement(root.element, root.namespaces, "include"); const modeCount = (propname ? 1 : 0) + (allprop ? 1 : 0) + (prop ? 1 : 0); if (modeCount > 1) { throw new Error("PROPFIND body must contain exactly one of propname, allprop, or prop"); } if (include && !allprop) { throw new Error("PROPFIND include is only valid alongside allprop"); } if (propname) return { kind: "propname" }; if (allprop) { const includeProps = include ? elementChildren(include.element, include.namespaces).map((child) => child.qname) : []; return { kind: "allprop", includeProps }; } if (prop) { return { kind: "prop", props: elementChildren(prop.element, prop.namespaces).map((child) => child.qname) }; } // No recognized mode element: default to allprop per the lenient client norm. return { kind: "allprop", includeProps: [] }; } function serializeNode(node: ParsedXmlNode, namespaces: Record): string { if (typeof node === "string") return node; return serializeElement(node, namespaces); } function serializeElement(element: ParsedXmlElement, parentNamespaces: Record): string { const namespaces = namespaceMap(element, parentNamespaces); const attributes = { ...element.attributes }; const { prefix } = splitName(element.name); if (prefix && !Object.hasOwn(attributes, `xmlns:${prefix}`) && namespaces[prefix]) { attributes[`xmlns:${prefix}`] = namespaces[prefix]; } if (!prefix && !Object.hasOwn(attributes, "xmlns") && namespaces[""]) { attributes.xmlns = namespaces[""]; } const attributeXml = Object.entries(attributes) .map(([name, value]) => ` ${name}="${escapeXml(value)}"`) .join(""); if (element.children.length === 0) return `<${element.name}${attributeXml}/>`; return `<${element.name}${attributeXml}>${element.children.map((child) => serializeNode(child, namespaces)).join("")}`; } export function parseProppatchXml(xml: string, maxBytes: number): ProppatchInstruction[] { const root = parseDocument(xml, maxBytes); if (root.qname.nsUri !== DAV_NS || root.qname.localName !== "propertyupdate") { throw new Error("Expected DAV:propertyupdate request body"); } const instructions: ProppatchInstruction[] = []; for (const op of elementChildren(root.element, root.namespaces)) { if (op.qname.nsUri !== DAV_NS || (op.qname.localName !== "set" && op.qname.localName !== "remove")) continue; const prop = childElement(op.element, op.namespaces, "prop"); if (!prop) continue; for (const child of elementChildren(prop.element, prop.namespaces)) { instructions.push({ kind: op.qname.localName, prop: child.qname, xmlValue: serializeElement(child.element, prop.namespaces), }); } } if (instructions.length === 0) throw new Error("PROPPATCH has no property instructions"); return instructions; } export function parseLockInfoXml( xml: string, maxBytes: number, ): { scope: "exclusive" | "shared"; ownerXml: string; } { const root = parseDocument(xml, maxBytes); if (root.qname.nsUri !== DAV_NS || root.qname.localName !== "lockinfo") { throw new Error("Expected DAV:lockinfo request body"); } const scopeElement = childElement(root.element, root.namespaces, "lockscope"); const scopeChild = scopeElement ? elementChildren(scopeElement.element, scopeElement.namespaces)[0] : undefined; const scope = scopeChild?.qname.localName === "shared" ? "shared" : "exclusive"; const owner = childElement(root.element, root.namespaces, "owner"); return { scope, ownerXml: owner ? serializeElement(owner.element, root.namespaces) : '', }; }