import { davProp } from "@/worker/dav/props"; import type { CalendarSearchFilter, CalendarTimeRange } from "@/worker/caldav/ical/types"; import type { CalendarComponentType } from "@/worker/db/types"; import { CALDAV_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: "calendar-query" | "calendar-multiget" | "sync-collection"; props: QName[]; calendarData: CalendarDataRequest | null; hrefs: string[]; filters: CalendarSearchFilter[]; syncToken: string | null; } export interface CalendarDataRequest { expand: CalendarTimeRange | null; } const supportedComponentFilters = ["VEVENT", "VTODO", "VJOURNAL"] as const; const maxTextMatchBytes = 1024; function calProp(localName: string): QName { return qname(CALDAV_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 caldavReport = report.qname.nsUri === CALDAV_NS && (root === "calendar-query" || root === "calendar-multiget"); const syncReport = report.qname.nsUri === DAV_NS && root === "sync-collection"; if (!caldavReport && !syncReport) { throw new Error("Unsupported REPORT body"); } const propSelection = reportPropSelection(report.element); const hrefs = elementsByQName(report.element, dav("href"), report.namespaces).map((entry) => textContent(entry.element), ); const timezone = root === "calendar-query" ? calendarQueryTimezone(report.element, report.namespaces) : null; const filters = reportFilters(report.element, timezone); const syncToken = firstTextByQName(report.element, dav("sync-token"), report.namespaces); // RFC 4791 7.9: calendar-multiget must include at least one DAV:href child. if (root === "calendar-multiget" && hrefs.length === 0) { throw new Error("calendar-multiget requires at least one DAV:href"); } if (root === "calendar-query") { // RFC 4791 9.5: calendar-query must include a CALDAV:filter element. const hasFilter = elementsByQName(report.element, calProp("filter"), report.namespaces).length > 0; if (!hasFilter) throw new Error("calendar-query requires a CALDAV: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: propSelection.props, calendarData: propSelection.calendarData, hrefs, filters, syncToken, }; } function reportPropSelection(report: ParsedXmlElement): { props: QName[]; calendarData: CalendarDataRequest | null } { const prop = elementsByQName(report, dav("prop")).at(0); if (!prop) return { props: [davProp("getetag")], calendarData: null }; let calendarData: CalendarDataRequest | null = null; const props = namedElementChildren(prop.element, prop.namespaces) .map((child) => { if (sameQName(child.qname, dav("getetag"))) return davProp("getetag"); if (sameQName(child.qname, calProp("calendar-data"))) { calendarData = parseCalendarDataRequest(child.element, child.namespaces); return calProp("calendar-data"); } return child.qname; }) .filter((prop): prop is QName => prop !== null); return { props: props.length > 0 ? props : [davProp("getetag")], calendarData }; } function parseCalendarDataRequest(element: ParsedXmlElement, namespaces: Record): CalendarDataRequest { const expand = elementsByQName(element, calProp("expand"), namespaces).at(0)?.element; return { expand: expand ? parseTimeRange(expand) : null, }; } function parseTimeValue(value: string): number { const dateTime = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(value); if (!dateTime) throw new Error("Invalid CalDAV time-range value"); return Date.UTC( Number(dateTime[1]), Number(dateTime[2]) - 1, Number(dateTime[3]), Number(dateTime[4]), Number(dateTime[5]), Number(dateTime[6]), ); } function parseTimeRange(element: ParsedXmlElement, floatingTimeZone: string | null = null): CalendarTimeRange { const range = { startMs: element.attributes.start ? parseTimeValue(element.attributes.start) : null, endMs: element.attributes.end ? parseTimeValue(element.attributes.end) : null, floatingTimeZone, }; if (range.startMs === null && range.endMs === null) throw new Error("CalDAV time-range requires start or end"); if (range.startMs !== null && range.endMs !== null && range.endMs <= range.startMs) { throw new Error("CalDAV time-range end must be after start"); } return range; } function calendarQueryTimezone(report: ParsedXmlElement, namespaces: Record): string | null { const timezone = elementsByQName(report, calProp("timezone"), namespaces).at(0); if (!timezone) return null; const body = textContent(timezone.element).trim(); if (!body) throw new Error("CALDAV:timezone must not be empty"); const lines = body .replace(/\r\n/g, "\n") .replace(/\r/g, "\n") .split("\n") .map((line) => line.trim()); if (!lines.some((line) => line.toUpperCase() === "BEGIN:VTIMEZONE")) { throw new Error("CALDAV:timezone must contain a VTIMEZONE component"); } const tzid = lines .map((line) => /^TZID(?:;[^:]*)?:(.+)$/i.exec(line)?.[1]?.trim()) .find((value): value is string => Boolean(value)); if (!tzid) throw new Error("CALDAV:timezone VTIMEZONE must include TZID"); try { new Intl.DateTimeFormat("en-US", { timeZone: tzid }).format(new Date(0)); } catch { throw new Error(`Unsupported CALDAV:timezone TZID ${tzid}`); } return tzid; } function reportFilters(report: ParsedXmlElement, floatingTimeZone: string | null): CalendarSearchFilter[] { const filters: CalendarSearchFilter[] = []; if (elementsByQName(report, calProp("param-filter")).length > 0) { throw new Error("Unsupported CalDAV param-filter"); } for (const comp of elementsByQName(report, calProp("comp-filter"))) { const name = comp.element.attributes.name?.toUpperCase(); if (name === "VCALENDAR") { // VCALENDAR is the required container filter; indexed searches operate on primary components. } else if (supportedComponentFilters.includes(name as (typeof supportedComponentFilters)[number])) { filters.push({ kind: "component", component: name as CalendarComponentType }); } else { throw new Error(`Unsupported CalDAV comp-filter ${name ?? "(missing)"}`); } const timeRange = namedElementChildren(comp.element, comp.namespaces).find((child) => sameQName(child.qname, calProp("time-range")), )?.element; if (timeRange) { filters.push({ kind: "time-range", range: parseTimeRange(timeRange, floatingTimeZone) }); } } for (const propFilter of elementsByQName(report, calProp("prop-filter"))) { const name = propFilter.element.attributes.name?.toUpperCase(); if (!name) throw new Error("CalDAV prop-filter requires a name"); const children = namedElementChildren(propFilter.element, propFilter.namespaces); if (children.some((child) => sameQName(child.qname, calProp("is-not-defined")))) { filters.push({ kind: "property-defined", name, defined: false }); continue; } const textMatch = children.find((child) => sameQName(child.qname, calProp("text-match")))?.element; if (!textMatch) { filters.push({ kind: "property-defined", name, defined: true }); continue; } const text = textContent(textMatch); if (new TextEncoder().encode(text).byteLength > maxTextMatchBytes) { throw new Error(`CalDAV text-match is limited to ${maxTextMatchBytes} bytes.`); } if (name === "UID") filters.push({ kind: "uid", text }); else if (name === "SUMMARY") filters.push({ kind: "summary", text }); else filters.push({ kind: "property-text", name, text }); } return filters; }