Skip to content
File

Blob: src/worker/caldav/reports.ts

typescript197 lines
1import { davProp } from "@/worker/dav/props";
2import type { CalendarSearchFilter, CalendarTimeRange } from "@/worker/caldav/ical/types";
3import type { CalendarComponentType } from "@/worker/db/types";
4import { CALDAV_NS, DAV_NS, qname, sameQName, type QName } from "@/worker/xml/namespaces";
5import { parseSafeXml, type ParsedXmlElement } from "@/worker/xml/parser";
6import { elementsByQName, firstTextByQName, namedElement, namedElementChildren, textContent } from "@/worker/xml/tree";
7 
8export interface ReportRequest {
9 kind: "calendar-query" | "calendar-multiget" | "sync-collection";
10 props: QName[];
11 calendarData: CalendarDataRequest | null;
12 hrefs: string[];
13 filters: CalendarSearchFilter[];
14 syncToken: string | null;
15}
16 
17export interface CalendarDataRequest {
18 expand: CalendarTimeRange | null;
19}
20 
21const supportedComponentFilters = ["VEVENT", "VTODO", "VJOURNAL"] as const;
22const maxTextMatchBytes = 1024;
23 
24function calProp(localName: string): QName {
25 return qname(CALDAV_NS, localName);
26}
27 
28function dav(localName: string): QName {
29 return qname(DAV_NS, localName);
30}
31 
32export function reportRequest(xml: string, maxBytes: number): ReportRequest {
33 const { root: reportElement } = parseSafeXml(xml, maxBytes);
34 const report = namedElement(reportElement);
35 const root = report.qname.localName;
36 const caldavReport = report.qname.nsUri === CALDAV_NS && (root === "calendar-query" || root === "calendar-multiget");
37 const syncReport = report.qname.nsUri === DAV_NS && root === "sync-collection";
38 if (!caldavReport && !syncReport) {
39 throw new Error("Unsupported REPORT body");
40 }
41 const propSelection = reportPropSelection(report.element);
42 const hrefs = elementsByQName(report.element, dav("href"), report.namespaces).map((entry) =>
43 textContent(entry.element),
44 );
45 const timezone = root === "calendar-query" ? calendarQueryTimezone(report.element, report.namespaces) : null;
46 const filters = reportFilters(report.element, timezone);
47 const syncToken = firstTextByQName(report.element, dav("sync-token"), report.namespaces);
48 
49 // RFC 4791 7.9: calendar-multiget must include at least one DAV:href child.
50 if (root === "calendar-multiget" && hrefs.length === 0) {
51 throw new Error("calendar-multiget requires at least one DAV:href");
52 }
53 if (root === "calendar-query") {
54 // RFC 4791 9.5: calendar-query must include a CALDAV:filter element.
55 const hasFilter = elementsByQName(report.element, calProp("filter"), report.namespaces).length > 0;
56 if (!hasFilter) throw new Error("calendar-query requires a CALDAV:filter element");
57 }
58 if (root === "sync-collection") {
59 // RFC 6578 3.5: sync-collection body must contain DAV:sync-token, DAV:sync-level, and DAV:prop.
60 const hasSyncToken = elementsByQName(report.element, dav("sync-token"), report.namespaces).length > 0;
61 const hasSyncLevel = elementsByQName(report.element, dav("sync-level"), report.namespaces).length > 0;
62 const hasProp = elementsByQName(report.element, dav("prop"), report.namespaces).length > 0;
63 if (!hasSyncToken) throw new Error("sync-collection requires DAV:sync-token");
64 if (!hasSyncLevel) throw new Error("sync-collection requires DAV:sync-level");
65 if (!hasProp) throw new Error("sync-collection requires DAV:prop");
66 }
67 
68 return {
69 kind: root,
70 props: propSelection.props,
71 calendarData: propSelection.calendarData,
72 hrefs,
73 filters,
74 syncToken,
75 };
76}
77 
78function reportPropSelection(report: ParsedXmlElement): { props: QName[]; calendarData: CalendarDataRequest | null } {
79 const prop = elementsByQName(report, dav("prop")).at(0);
80 if (!prop) return { props: [davProp("getetag")], calendarData: null };
81 let calendarData: CalendarDataRequest | null = null;
82 const props = namedElementChildren(prop.element, prop.namespaces)
83 .map((child) => {
84 if (sameQName(child.qname, dav("getetag"))) return davProp("getetag");
85 if (sameQName(child.qname, calProp("calendar-data"))) {
86 calendarData = parseCalendarDataRequest(child.element, child.namespaces);
87 return calProp("calendar-data");
88 }
89 return child.qname;
90 })
91 .filter((prop): prop is QName => prop !== null);
92 return { props: props.length > 0 ? props : [davProp("getetag")], calendarData };
93}
94 
95function parseCalendarDataRequest(element: ParsedXmlElement, namespaces: Record<string, string>): CalendarDataRequest {
96 const expand = elementsByQName(element, calProp("expand"), namespaces).at(0)?.element;
97 return {
98 expand: expand ? parseTimeRange(expand) : null,
99 };
100}
101 
102function parseTimeValue(value: string): number {
103 const dateTime = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(value);
104 if (!dateTime) throw new Error("Invalid CalDAV time-range value");
105 return Date.UTC(
106 Number(dateTime[1]),
107 Number(dateTime[2]) - 1,
108 Number(dateTime[3]),
109 Number(dateTime[4]),
110 Number(dateTime[5]),
111 Number(dateTime[6]),
112 );
113}
114 
115function parseTimeRange(element: ParsedXmlElement, floatingTimeZone: string | null = null): CalendarTimeRange {
116 const range = {
117 startMs: element.attributes.start ? parseTimeValue(element.attributes.start) : null,
118 endMs: element.attributes.end ? parseTimeValue(element.attributes.end) : null,
119 floatingTimeZone,
120 };
121 if (range.startMs === null && range.endMs === null) throw new Error("CalDAV time-range requires start or end");
122 if (range.startMs !== null && range.endMs !== null && range.endMs <= range.startMs) {
123 throw new Error("CalDAV time-range end must be after start");
124 }
125 return range;
126}
127 
128function calendarQueryTimezone(report: ParsedXmlElement, namespaces: Record<string, string>): string | null {
129 const timezone = elementsByQName(report, calProp("timezone"), namespaces).at(0);
130 if (!timezone) return null;
131 const body = textContent(timezone.element).trim();
132 if (!body) throw new Error("CALDAV:timezone must not be empty");
133 const lines = body
134 .replace(/\r\n/g, "\n")
135 .replace(/\r/g, "\n")
136 .split("\n")
137 .map((line) => line.trim());
138 if (!lines.some((line) => line.toUpperCase() === "BEGIN:VTIMEZONE")) {
139 throw new Error("CALDAV:timezone must contain a VTIMEZONE component");
140 }
141 const tzid = lines
142 .map((line) => /^TZID(?:;[^:]*)?:(.+)$/i.exec(line)?.[1]?.trim())
143 .find((value): value is string => Boolean(value));
144 if (!tzid) throw new Error("CALDAV:timezone VTIMEZONE must include TZID");
145 try {
146 new Intl.DateTimeFormat("en-US", { timeZone: tzid }).format(new Date(0));
147 } catch {
148 throw new Error(`Unsupported CALDAV:timezone TZID ${tzid}`);
149 }
150 return tzid;
151}
152 
153function reportFilters(report: ParsedXmlElement, floatingTimeZone: string | null): CalendarSearchFilter[] {
154 const filters: CalendarSearchFilter[] = [];
155 if (elementsByQName(report, calProp("param-filter")).length > 0) {
156 throw new Error("Unsupported CalDAV param-filter");
157 }
158 for (const comp of elementsByQName(report, calProp("comp-filter"))) {
159 const name = comp.element.attributes.name?.toUpperCase();
160 if (name === "VCALENDAR") {
161 // VCALENDAR is the required container filter; indexed searches operate on primary components.
162 } else if (supportedComponentFilters.includes(name as (typeof supportedComponentFilters)[number])) {
163 filters.push({ kind: "component", component: name as CalendarComponentType });
164 } else {
165 throw new Error(`Unsupported CalDAV comp-filter ${name ?? "(missing)"}`);
166 }
167 const timeRange = namedElementChildren(comp.element, comp.namespaces).find((child) =>
168 sameQName(child.qname, calProp("time-range")),
169 )?.element;
170 if (timeRange) {
171 filters.push({ kind: "time-range", range: parseTimeRange(timeRange, floatingTimeZone) });
172 }
173 }
174 for (const propFilter of elementsByQName(report, calProp("prop-filter"))) {
175 const name = propFilter.element.attributes.name?.toUpperCase();
176 if (!name) throw new Error("CalDAV prop-filter requires a name");
177 const children = namedElementChildren(propFilter.element, propFilter.namespaces);
178 if (children.some((child) => sameQName(child.qname, calProp("is-not-defined")))) {
179 filters.push({ kind: "property-defined", name, defined: false });
180 continue;
181 }
182 const textMatch = children.find((child) => sameQName(child.qname, calProp("text-match")))?.element;
183 if (!textMatch) {
184 filters.push({ kind: "property-defined", name, defined: true });
185 continue;
186 }
187 const text = textContent(textMatch);
188 if (new TextEncoder().encode(text).byteLength > maxTextMatchBytes) {
189 throw new Error(`CalDAV text-match is limited to ${maxTextMatchBytes} bytes.`);
190 }
191 if (name === "UID") filters.push({ kind: "uid", text });
192 else if (name === "SUMMARY") filters.push({ kind: "summary", text });
193 else filters.push({ kind: "property-text", name, text });
194 }
195 return filters;
196}