File
Blob: src/worker/caldav/reports.ts
| 1 | import { davProp } from "@/worker/dav/props"; |
| 2 | import type { CalendarSearchFilter, CalendarTimeRange } from "@/worker/caldav/ical/types"; |
| 3 | import type { CalendarComponentType } from "@/worker/db/types"; |
| 4 | import { CALDAV_NS, DAV_NS, qname, sameQName, type QName } from "@/worker/xml/namespaces"; |
| 5 | import { parseSafeXml, type ParsedXmlElement } from "@/worker/xml/parser"; |
| 6 | import { elementsByQName, firstTextByQName, namedElement, namedElementChildren, textContent } from "@/worker/xml/tree"; |
| 7 | |
| 8 | export 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 | |
| 17 | export interface CalendarDataRequest { |
| 18 | expand: CalendarTimeRange | null; |
| 19 | } |
| 20 | |
| 21 | const supportedComponentFilters = ["VEVENT", "VTODO", "VJOURNAL"] as const; |
| 22 | const maxTextMatchBytes = 1024; |
| 23 | |
| 24 | function calProp(localName: string): QName { |
| 25 | return qname(CALDAV_NS, localName); |
| 26 | } |
| 27 | |
| 28 | function dav(localName: string): QName { |
| 29 | return qname(DAV_NS, localName); |
| 30 | } |
| 31 | |
| 32 | export 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 | |
| 78 | function 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 | |
| 95 | function 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 | |
| 102 | function 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 | |
| 115 | function 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 | |
| 128 | function 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 | |
| 153 | function 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 | } |