Skip to content
File

Blob: src/worker/xml/dav-request.ts

typescript178 lines
1import { parseSafeXml, type ParsedXmlElement, type ParsedXmlNode } from "@/worker/xml/parser";
2import type { QName } from "@/worker/xml/namespaces";
3import { DAV_NS } from "@/worker/xml/namespaces";
4import { escapeXml } from "@/worker/dav/multistatus";
5 
6export type PropfindRequest =
7 | { kind: "allprop"; includeProps: QName[] }
8 | { kind: "propname" }
9 | { kind: "prop"; props: QName[] };
10 
11export type ProppatchInstruction =
12 | { kind: "set"; prop: QName; xmlValue: string }
13 | { kind: "remove"; prop: QName; xmlValue: string };
14 
15interface NamedElement {
16 element: ParsedXmlElement;
17 qname: QName;
18 namespaces: Record<string, string>;
19}
20 
21function 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 
27function 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 
36function qnameFor(element: ParsedXmlElement, namespaces: Record<string, string>): QName {
37 const { prefix, localName } = splitName(element.name);
38 return { nsUri: namespaces[prefix] ?? "", localName };
39}
40 
41function 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 
51function 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 
63function 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 
69export 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 
107function serializeNode(node: ParsedXmlNode, namespaces: Record<string, string>): string {
108 if (typeof node === "string") return node;
109 return serializeElement(node, namespaces);
110}
111 
112function 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 
132export 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 
156export 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}