Skip to content
File

Blob: src/worker/carddav/reports.ts

typescript138 lines
1import { davProp } from "@/worker/dav/props";
2import type { CardDavFilterTest, CardDavSearchFilter, ContactFilterProp } from "@/worker/carddav/types";
3import { CARDDAV_NS, DAV_NS, qname, sameQName, type QName } from "@/worker/xml/namespaces";
4import { parseSafeXml, type ParsedXmlElement } from "@/worker/xml/parser";
5import { elementsByQName, firstTextByQName, namedElement, namedElementChildren, textContent } from "@/worker/xml/tree";
6 
7export interface ReportRequest {
8 kind: "addressbook-query" | "addressbook-multiget" | "sync-collection";
9 props: QName[];
10 hrefs: string[];
11 filters: CardDavSearchFilter[];
12 filterTest: CardDavFilterTest;
13 syncToken: string | null;
14}
15 
16const supportedFilterProps = ["FN", "N", "EMAIL", "TEL", "UID", "ORG"] as const;
17const maxTextMatchBytes = 1024;
18 
19function cardProp(localName: string): QName {
20 return qname(CARDDAV_NS, localName);
21}
22 
23function dav(localName: string): QName {
24 return qname(DAV_NS, localName);
25}
26 
27export function reportRequest(xml: string, maxBytes: number): ReportRequest {
28 const { root: reportElement } = parseSafeXml(xml, maxBytes);
29 const report = namedElement(reportElement);
30 const root = report.qname.localName;
31 const carddavReport =
32 report.qname.nsUri === CARDDAV_NS && (root === "addressbook-query" || root === "addressbook-multiget");
33 const syncReport = report.qname.nsUri === DAV_NS && root === "sync-collection";
34 if (!carddavReport && !syncReport) {
35 throw new Error("Unsupported REPORT body");
36 }
37 const hrefs = elementsByQName(report.element, dav("href"), report.namespaces).map((entry) =>
38 textContent(entry.element),
39 );
40 const { filters, filterTest } = parseFilterRoot(report.element, report.namespaces);
41 const syncToken = firstTextByQName(report.element, dav("sync-token"), report.namespaces);
42 
43 // RFC 6352 8.7: addressbook-multiget must include at least one DAV:href child.
44 if (root === "addressbook-multiget" && hrefs.length === 0) {
45 throw new Error("addressbook-multiget requires at least one DAV:href");
46 }
47 if (root === "addressbook-query") {
48 // RFC 6352 8.6: addressbook-query must include a CARDDAV:filter element.
49 const hasFilter = elementsByQName(report.element, cardProp("filter"), report.namespaces).length > 0;
50 if (!hasFilter) throw new Error("addressbook-query requires a CARDDAV:filter element");
51 }
52 if (root === "sync-collection") {
53 // RFC 6578 3.5: sync-collection body must contain DAV:sync-token, DAV:sync-level, and DAV:prop.
54 const hasSyncToken = elementsByQName(report.element, dav("sync-token"), report.namespaces).length > 0;
55 const hasSyncLevel = elementsByQName(report.element, dav("sync-level"), report.namespaces).length > 0;
56 const hasProp = elementsByQName(report.element, dav("prop"), report.namespaces).length > 0;
57 if (!hasSyncToken) throw new Error("sync-collection requires DAV:sync-token");
58 if (!hasSyncLevel) throw new Error("sync-collection requires DAV:sync-level");
59 if (!hasProp) throw new Error("sync-collection requires DAV:prop");
60 }
61 
62 return {
63 kind: root,
64 props: reportProps(report.element),
65 hrefs,
66 filters,
67 filterTest,
68 syncToken,
69 };
70}
71 
72function reportProps(report: ParsedXmlElement): QName[] {
73 const prop = elementsByQName(report, dav("prop")).at(0);
74 if (!prop) return [davProp("getetag")];
75 const props = namedElementChildren(prop.element, prop.namespaces).map((child) => {
76 if (sameQName(child.qname, dav("getetag"))) return davProp("getetag");
77 if (sameQName(child.qname, cardProp("address-data"))) return cardProp("address-data");
78 return child.qname;
79 });
80 return props.length > 0 ? props : [davProp("getetag")];
81}
82 
83function parseFilterRoot(
84 report: ParsedXmlElement,
85 namespaces: Record<string, string>,
86): { filters: CardDavSearchFilter[]; filterTest: CardDavFilterTest } {
87 const filterRoot = elementsByQName(report, cardProp("filter"), namespaces).at(0);
88 // RFC 6352 10.5: the filter root's "test" attribute controls how prop-filter
89 // children combine. Default is "anyof"; "allof" is also supported.
90 const testAttr = filterRoot?.element.attributes.test?.toLowerCase();
91 if (testAttr && testAttr !== "anyof" && testAttr !== "allof") {
92 throw new Error(`Unsupported CardDAV filter test ${testAttr}`);
93 }
94 const filterTest: CardDavFilterTest = testAttr === "allof" ? "allof" : "anyof";
95 const propFilterScope = filterRoot?.element ?? report;
96 const filters = elementsByQName(propFilterScope, cardProp("prop-filter"), namespaces).map((filter) =>
97 parsePropFilter(filter),
98 );
99 return { filters, filterTest };
100}
101 
102function parsePropFilter(filter: {
103 element: ParsedXmlElement;
104 namespaces: Record<string, string>;
105}): CardDavSearchFilter {
106 const prop = filter.element.attributes.name?.toUpperCase();
107 if (!prop || !supportedFilterProps.includes(prop as (typeof supportedFilterProps)[number])) {
108 throw new Error(`Unsupported CardDAV filter property ${prop ?? "(missing)"}`);
109 }
110 const typedProp = prop as ContactFilterProp;
111 const children = namedElementChildren(filter.element, filter.namespaces);
112 // RFC 6352 10.5.1: a prop-filter with no child or only is-not-defined is a
113 // property-exists query, where the property is required or required absent.
114 if (children.some((child) => sameQName(child.qname, cardProp("is-not-defined")))) {
115 return { prop: typedProp, kind: "exists", defined: false };
116 }
117 const textMatch = children.find((child) => sameQName(child.qname, cardProp("text-match")))?.element;
118 if (!textMatch) {
119 return { prop: typedProp, kind: "exists", defined: true };
120 }
121 const collation = textMatch.attributes.collation;
122 if (collation && collation !== "i;unicode-casemap") {
123 throw new Error("Unsupported CardDAV text-match collation. Only i;unicode-casemap is supported.");
124 }
125 const matchType = textMatch.attributes["match-type"];
126 if (matchType && matchType !== "contains") {
127 throw new Error("Unsupported CardDAV text-match type. Only contains is supported.");
128 }
129 if (textMatch.attributes["negate-condition"] === "yes") {
130 throw new Error("Unsupported CardDAV text-match negate-condition.");
131 }
132 const text = textContent(textMatch);
133 if (new TextEncoder().encode(text).byteLength > maxTextMatchBytes) {
134 throw new Error(`CardDAV text-match is limited to ${maxTextMatchBytes} bytes.`);
135 }
136 return { prop: typedProp, kind: "text", text };
137}