import { davProp } from "@/worker/dav/props"; import type { CardDavFilterTest, CardDavSearchFilter, ContactFilterProp } from "@/worker/carddav/types"; import { CARDDAV_NS, DAV_NS, qname, sameQName, type QName } from "@/worker/xml/namespaces"; import { parseSafeXml, type ParsedXmlElement } from "@/worker/xml/parser"; import { elementsByQName, firstTextByQName, namedElement, namedElementChildren, textContent } from "@/worker/xml/tree"; export interface ReportRequest { kind: "addressbook-query" | "addressbook-multiget" | "sync-collection"; props: QName[]; hrefs: string[]; filters: CardDavSearchFilter[]; filterTest: CardDavFilterTest; syncToken: string | null; } const supportedFilterProps = ["FN", "N", "EMAIL", "TEL", "UID", "ORG"] as const; const maxTextMatchBytes = 1024; function cardProp(localName: string): QName { return qname(CARDDAV_NS, localName); } function dav(localName: string): QName { return qname(DAV_NS, localName); } export function reportRequest(xml: string, maxBytes: number): ReportRequest { const { root: reportElement } = parseSafeXml(xml, maxBytes); const report = namedElement(reportElement); const root = report.qname.localName; const carddavReport = report.qname.nsUri === CARDDAV_NS && (root === "addressbook-query" || root === "addressbook-multiget"); const syncReport = report.qname.nsUri === DAV_NS && root === "sync-collection"; if (!carddavReport && !syncReport) { throw new Error("Unsupported REPORT body"); } const hrefs = elementsByQName(report.element, dav("href"), report.namespaces).map((entry) => textContent(entry.element), ); const { filters, filterTest } = parseFilterRoot(report.element, report.namespaces); const syncToken = firstTextByQName(report.element, dav("sync-token"), report.namespaces); // RFC 6352 8.7: addressbook-multiget must include at least one DAV:href child. if (root === "addressbook-multiget" && hrefs.length === 0) { throw new Error("addressbook-multiget requires at least one DAV:href"); } if (root === "addressbook-query") { // RFC 6352 8.6: addressbook-query must include a CARDDAV:filter element. const hasFilter = elementsByQName(report.element, cardProp("filter"), report.namespaces).length > 0; if (!hasFilter) throw new Error("addressbook-query requires a CARDDAV:filter element"); } if (root === "sync-collection") { // RFC 6578 3.5: sync-collection body must contain DAV:sync-token, DAV:sync-level, and DAV:prop. const hasSyncToken = elementsByQName(report.element, dav("sync-token"), report.namespaces).length > 0; const hasSyncLevel = elementsByQName(report.element, dav("sync-level"), report.namespaces).length > 0; const hasProp = elementsByQName(report.element, dav("prop"), report.namespaces).length > 0; if (!hasSyncToken) throw new Error("sync-collection requires DAV:sync-token"); if (!hasSyncLevel) throw new Error("sync-collection requires DAV:sync-level"); if (!hasProp) throw new Error("sync-collection requires DAV:prop"); } return { kind: root, props: reportProps(report.element), hrefs, filters, filterTest, syncToken, }; } function reportProps(report: ParsedXmlElement): QName[] { const prop = elementsByQName(report, dav("prop")).at(0); if (!prop) return [davProp("getetag")]; const props = namedElementChildren(prop.element, prop.namespaces).map((child) => { if (sameQName(child.qname, dav("getetag"))) return davProp("getetag"); if (sameQName(child.qname, cardProp("address-data"))) return cardProp("address-data"); return child.qname; }); return props.length > 0 ? props : [davProp("getetag")]; } function parseFilterRoot( report: ParsedXmlElement, namespaces: Record, ): { filters: CardDavSearchFilter[]; filterTest: CardDavFilterTest } { const filterRoot = elementsByQName(report, cardProp("filter"), namespaces).at(0); // RFC 6352 10.5: the filter root's "test" attribute controls how prop-filter // children combine. Default is "anyof"; "allof" is also supported. const testAttr = filterRoot?.element.attributes.test?.toLowerCase(); if (testAttr && testAttr !== "anyof" && testAttr !== "allof") { throw new Error(`Unsupported CardDAV filter test ${testAttr}`); } const filterTest: CardDavFilterTest = testAttr === "allof" ? "allof" : "anyof"; const propFilterScope = filterRoot?.element ?? report; const filters = elementsByQName(propFilterScope, cardProp("prop-filter"), namespaces).map((filter) => parsePropFilter(filter), ); return { filters, filterTest }; } function parsePropFilter(filter: { element: ParsedXmlElement; namespaces: Record; }): CardDavSearchFilter { const prop = filter.element.attributes.name?.toUpperCase(); if (!prop || !supportedFilterProps.includes(prop as (typeof supportedFilterProps)[number])) { throw new Error(`Unsupported CardDAV filter property ${prop ?? "(missing)"}`); } const typedProp = prop as ContactFilterProp; const children = namedElementChildren(filter.element, filter.namespaces); // RFC 6352 10.5.1: a prop-filter with no child or only is-not-defined is a // property-exists query, where the property is required or required absent. if (children.some((child) => sameQName(child.qname, cardProp("is-not-defined")))) { return { prop: typedProp, kind: "exists", defined: false }; } const textMatch = children.find((child) => sameQName(child.qname, cardProp("text-match")))?.element; if (!textMatch) { return { prop: typedProp, kind: "exists", defined: true }; } const collation = textMatch.attributes.collation; if (collation && collation !== "i;unicode-casemap") { throw new Error("Unsupported CardDAV text-match collation. Only i;unicode-casemap is supported."); } const matchType = textMatch.attributes["match-type"]; if (matchType && matchType !== "contains") { throw new Error("Unsupported CardDAV text-match type. Only contains is supported."); } if (textMatch.attributes["negate-condition"] === "yes") { throw new Error("Unsupported CardDAV text-match negate-condition."); } const text = textContent(textMatch); if (new TextEncoder().encode(text).byteLength > maxTextMatchBytes) { throw new Error(`CardDAV text-match is limited to ${maxTextMatchBytes} bytes.`); } return { prop: typedProp, kind: "text", text }; }