Skip to content
File

Blob: src/worker/caldav/handler.ts

typescript341 lines
1import type { DavPatAuth } from "@/worker/auth/basic";
2import { hasDavScope } from "@/worker/auth/scopes";
3import { calendarCollectionCreateRequest } from "@/worker/caldav/collections";
4import { parseContentTypeHeader, utf8CharsetOk } from "@/worker/dav/content-type";
5import { calendarHref, calendarObjectHref } from "@/worker/caldav/paths";
6import { multistatusForResources, propstats } from "@/worker/caldav/props";
7import { reportRequest, type ReportRequest } from "@/worker/caldav/reports";
8import {
9 acceptedDavOrigins,
10 collectionDavHref,
11 type CollectionDavPath,
12 parseCollectionDavPath,
13} from "@/worker/dav/collection-handler";
14import type { DavListingEntry } from "@/worker/dav/html-listing";
15import {
16 handleCollectionGetHead,
17 handleCollectionObjectDelete,
18 handleCollectionObjectPut,
19 handleCollectionPropfind,
20 handleCollectionProppatch,
21 type CollectionHtmlListingAdapter,
22 type CollectionObjectAdapter,
23} from "@/worker/dav/collection-object";
24import { handleCollectionReport, type CollectionReportAdapter } from "@/worker/dav/collection-report";
25import { davError, emptyResponse, methodNotAllowed } from "@/worker/dav/http";
26import {
27 maxCalendarObjectBytes,
28 maxRecurrenceInstances,
29 maxRecurrenceYears,
30 maxReportResults,
31 maxXmlBodyBytes,
32} from "@/worker/dav/limits";
33import type { DavRequestContext } from "@/worker/dav/runtime";
34import type { SubjectRow } from "@/worker/db/d1/schema";
35import type { AppContext } from "@/worker/types";
36 
37// ADR: handle MKCALENDAR when the runtime delivers it, and also support the
38// RFC 5689 MKCOL calendar-collection alternative. Local Miniflare/workerd
39// dispatch rejects MKCALENDAR before Worker code runs; configuring Vite's proxy,
40// CORS, or middleware hooks does not change that runtime dispatch path.
41// Therefore, MKCOL remains the local end-to-end validation path for
42// client-created calendar collections.
43export const CAL_DAV_ALLOW = "OPTIONS, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT, GET, HEAD, PUT, DELETE";
44// RFC 5689 advertises extended-mkcol when the server supports MKCOL with a
45// resourcetype body. We support both MKCALENDAR and the MKCOL fallback. The
46// service exposes calendar-access (RFC 4791) but does not implement the full
47// RFC 3744 ACL method, so DAV class 3 is intentionally omitted.
48export const CAL_DAV_OPTIONS_HEADERS = { Allow: CAL_DAV_ALLOW, DAV: "1, calendar-access, extended-mkcol" } as const;
49 
50type CalendarPath = CollectionDavPath<"calendar">;
51type CalendarResource = Parameters<typeof propstats>[0];
52type CalendarObjectBody = { body: string; size: number; etag: string };
53 
54function calObject(env: Env, storageId: string) {
55 return env.CAL_DAV.getByName(storageId);
56}
57 
58function validateCalendarContentType(value: string): { ok: true } | { ok: false; status: number; message: string } {
59 const { media, params } = parseContentTypeHeader(value);
60 if (media !== "text/calendar") {
61 return { ok: false, status: 415, message: "CalDAV PUT requires text/calendar" };
62 }
63 if (!utf8CharsetOk(params)) {
64 return { ok: false, status: 415, message: "CalDAV PUT requires utf-8 charset" };
65 }
66 return { ok: true };
67}
68 
69function parseCalendarPath(pathname: string): CalendarPath | null {
70 return parseCollectionDavPath(pathname, "/calendars", "calendar");
71}
72 
73function requiresWrite(method: string): boolean {
74 return (
75 method === "PUT" || method === "DELETE" || method === "PROPPATCH" || method === "MKCALENDAR" || method === "MKCOL"
76 );
77}
78 
79function canUseCalDav(auth: DavPatAuth, method: string): boolean {
80 return hasDavScope(auth.scopes, requiresWrite(method) ? "dav:caldav:write" : "dav:caldav:read");
81}
82 
83function hrefForPath(path: CalendarPath): string {
84 return collectionDavHref(path, {
85 homeHref: "/calendars/",
86 collectionHref: calendarHref,
87 objectHref: calendarObjectHref,
88 });
89}
90 
91function resourceKindForPath(path: CalendarPath) {
92 return path.kind === "home" ? "calendar-home" : path.kind === "calendar" ? "calendar-collection" : "calendar-object";
93}
94 
95function prepareCalendarPut(context: DavRequestContext) {
96 // RFC 4791 4.3: calendar object resources use text/calendar with UTF-8.
97 const contentType = context.c.req.header("content-type");
98 if (contentType !== undefined) {
99 const validation = validateCalendarContentType(contentType);
100 if (!validation.ok) return { ok: false as const, response: davError(validation.status, validation.message) };
101 }
102 return { ok: true as const, value: {} };
103}
104 
105function reportPropstats(context: DavRequestContext, report: ReportRequest, resource: Parameters<typeof propstats>[0]) {
106 return propstats(
107 resource,
108 context.subject,
109 context.auth,
110 { kind: "prop", props: report.props },
111 {
112 maxBytes: maxCalendarObjectBytes(context.c.env),
113 allowCalendarData: true,
114 calendarData: report.calendarData,
115 recurrenceBounds: {
116 maxYears: maxRecurrenceYears(context.c.env),
117 maxInstances: maxRecurrenceInstances(context.c.env),
118 },
119 },
120 );
121}
122 
123function reportCollectionName(path: CalendarPath): string {
124 if (!("collectionName" in path)) throw new Error("REPORT path must include a calendar name");
125 return path.collectionName;
126}
127 
128function calendarListingTitle(resource: CalendarResource): string {
129 if (resource.kind === "home") return "Calendars";
130 return resource.calendar?.displayName ?? resource.calendar?.name ?? resource.href;
131}
132 
133function calendarListingDescription(resource: CalendarResource): string | null {
134 if (resource.kind === "calendar") return resource.calendar?.description ?? null;
135 if (resource.kind !== "object") return null;
136 const details = [resource.index?.componentType, resource.index?.summary].filter(Boolean);
137 return details.length > 0 ? details.join(" - ") : null;
138}
139 
140function calendarListingEntry(resource: CalendarResource): DavListingEntry {
141 if (resource.kind === "object") {
142 return {
143 href: resource.href,
144 name: resource.object?.name ?? resource.href,
145 kind: "calendar-object",
146 description: calendarListingDescription(resource),
147 size: resource.object?.size,
148 modifiedAtMs: resource.object?.modifiedAtMs,
149 };
150 }
151 return {
152 href: resource.href,
153 name: calendarListingTitle(resource),
154 kind: "calendar",
155 description: calendarListingDescription(resource),
156 modifiedAtMs: resource.calendar?.modifiedAtMs,
157 };
158}
159 
160const calendarListingAdapter = {
161 title: calendarListingTitle,
162 parentHref: (path) => (path.kind === "home" ? "/" : "/calendars/"),
163 entry: calendarListingEntry,
164} satisfies CollectionHtmlListingAdapter<"calendar", CalendarResource>;
165 
166const calendarAdapter = {
167 objectContentType: "text/calendar; charset=utf-8",
168 objectName: "a calendar object",
169 objectPathName: "calendar object",
170 putObjectPathName: "a calendar object path",
171 deleteObjectPathName: "a calendar object or collection path",
172 hrefForPath,
173 resourceKindForPath,
174 maxObjectBytes: (context) => maxCalendarObjectBytes(context.c.env),
175 preparePut: prepareCalendarPut,
176 describe: async (context, path, depth) =>
177 await calObject(context.c.env, context.subject.storageId).describe({
178 subjectId: context.subject.id,
179 kind: path.kind,
180 calendarName: "collectionName" in path ? path.collectionName : undefined,
181 objectName: "objectName" in path ? path.objectName : undefined,
182 depth,
183 nowMs: Date.now(),
184 maxResults: maxReportResults(context.c.env),
185 }),
186 propfindMultistatus: (context, resources, request) =>
187 multistatusForResources(resources, context.subject, context.auth, request, {
188 maxBytes: maxCalendarObjectBytes(context.c.env),
189 }),
190 proppatch: async (context, path, input) =>
191 await calObject(context.c.env, context.subject.storageId).proppatch({
192 subjectId: context.subject.id,
193 kind: path.kind,
194 resourceId: null,
195 calendarName: "collectionName" in path ? path.collectionName : undefined,
196 objectName: "objectName" in path ? path.objectName : undefined,
197 instructions: input.instructions,
198 ...input.conditions,
199 nowMs: input.nowMs,
200 }),
201 getObject: async (context, path) =>
202 await calObject(context.c.env, context.subject.storageId).getObject({
203 subjectId: context.subject.id,
204 calendarName: path.collectionName,
205 objectName: path.objectName,
206 nowMs: Date.now(),
207 }),
208 putObject: async (context, path, input) =>
209 await calObject(context.c.env, context.subject.storageId).putObject({
210 subjectId: context.subject.id,
211 calendarName: path.collectionName,
212 objectName: path.objectName,
213 body: input.body,
214 ...input.conditions,
215 nowMs: input.nowMs,
216 maxBytes: input.maxBytes,
217 maxRecurrenceYears: maxRecurrenceYears(context.c.env),
218 maxRecurrenceInstances: maxRecurrenceInstances(context.c.env),
219 }),
220 deleteObject: async (context, path, input) =>
221 await calObject(context.c.env, context.subject.storageId).deleteObject({
222 subjectId: context.subject.id,
223 calendarName: path.collectionName,
224 objectName: path.objectName,
225 ...input.conditions,
226 nowMs: input.nowMs,
227 }),
228} satisfies CollectionObjectAdapter<"calendar", CalendarResource, CalendarObjectBody>;
229 
230const calendarReportAdapter = {
231 invalidBodyMessage: "Invalid REPORT body",
232 validatePath: (_context, path) =>
233 path.kind === "calendar" || path.kind === "object"
234 ? null
235 : davError(405, "REPORT requires a calendar collection or object"),
236 validateReportPath: (_context, path, report) =>
237 path.kind === "object" && report.kind !== "calendar-multiget"
238 ? davError(405, "Only calendar-multiget is allowed on a calendar object")
239 : null,
240 parseReport: reportRequest,
241 operationForReport: (report) =>
242 report.kind === "sync-collection" ? "sync" : report.kind === "calendar-multiget" ? "multiget" : "query",
243 depthFallback: () => "0",
244 sync: async (context, path, report) =>
245 await calObject(context.c.env, context.subject.storageId).syncCollection({
246 subjectId: context.subject.id,
247 calendarName: reportCollectionName(path),
248 token: report.syncToken,
249 nowMs: Date.now(),
250 maxResults: maxReportResults(context.c.env),
251 }),
252 multiget: async (context, path, report) =>
253 await calObject(context.c.env, context.subject.storageId).calendarMultiget({
254 subjectId: context.subject.id,
255 calendarName: reportCollectionName(path),
256 hrefs: report.hrefs,
257 nowMs: Date.now(),
258 maxResults: maxReportResults(context.c.env),
259 acceptedOrigins: acceptedDavOrigins(context.c),
260 }),
261 query: async (context, path, report) =>
262 await calObject(context.c.env, context.subject.storageId).calendarQuery({
263 subjectId: context.subject.id,
264 calendarName: reportCollectionName(path),
265 filters: report.filters,
266 nowMs: Date.now(),
267 maxResults: maxReportResults(context.c.env),
268 maxRecurrenceYears: maxRecurrenceYears(context.c.env),
269 maxRecurrenceInstances: maxRecurrenceInstances(context.c.env),
270 }),
271 propstatsForResource: (context, _path, report, resource) => reportPropstats(context, report, resource),
272} satisfies CollectionReportAdapter<CalendarPath, ReportRequest, CalendarResource>;
273 
274async function handleCreateCalendarCollection(context: DavRequestContext, path: CalendarPath) {
275 if (path.kind !== "calendar") return davError(405, `${context.c.req.method} requires a calendar collection path`);
276 let request: ReturnType<typeof calendarCollectionCreateRequest>;
277 try {
278 request = calendarCollectionCreateRequest(
279 await context.c.req.text(),
280 maxXmlBodyBytes(context.c.env),
281 context.c.req.method as "MKCALENDAR" | "MKCOL",
282 );
283 } catch (cause) {
284 return davError(400, cause instanceof Error ? cause.message : `Invalid ${context.c.req.method} body`);
285 }
286 const result = await calObject(context.c.env, context.subject.storageId).createCalendar({
287 subjectId: context.subject.id,
288 name: path.collectionName,
289 displayName: request.displayName ?? path.collectionName,
290 timezoneIcal: request.timezoneIcal,
291 nowMs: Date.now(),
292 });
293 return result.ok ? emptyResponse(201, { "Cache-Control": "no-cache" }) : davError(result.status, result.message);
294}
295 
296async function handleDelete(context: DavRequestContext, path: CalendarPath) {
297 if (path.kind === "calendar") {
298 const result = await calObject(context.c.env, context.subject.storageId).deleteCalendarByName({
299 subjectId: context.subject.id,
300 name: path.collectionName,
301 nowMs: Date.now(),
302 });
303 return result.ok ? emptyResponse(204) : davError(result.status, result.message);
304 }
305 return await handleCollectionObjectDelete(context, path, calendarAdapter);
306}
307 
308async function handleReport(context: DavRequestContext, path: CalendarPath) {
309 return await handleCollectionReport(context, path, maxXmlBodyBytes(context.c.env), calendarReportAdapter);
310}
311 
312export async function handleCalDav(c: AppContext, subject: SubjectRow, auth: DavPatAuth): Promise<Response> {
313 if (!canUseCalDav(auth, c.req.method)) return davError(403, "PAT scope does not allow this method");
314 const pathname = new URL(c.req.url).pathname;
315 const path = parseCalendarPath(pathname);
316 if (!path) return davError(404, "Not found");
317 const context: DavRequestContext = { c, subject, auth, pathname };
318 switch (c.req.method) {
319 case "OPTIONS":
320 return emptyResponse(204, CAL_DAV_OPTIONS_HEADERS);
321 case "PROPFIND":
322 return await handleCollectionPropfind(context, path, calendarAdapter);
323 case "PROPPATCH":
324 return await handleCollectionProppatch(context, path, calendarAdapter);
325 case "MKCALENDAR":
326 case "MKCOL":
327 return await handleCreateCalendarCollection(context, path);
328 case "GET":
329 case "HEAD":
330 return await handleCollectionGetHead(context, path, calendarAdapter, calendarListingAdapter);
331 case "PUT":
332 return await handleCollectionObjectPut(context, path, calendarAdapter);
333 case "DELETE":
334 return await handleDelete(context, path);
335 case "REPORT":
336 return await handleReport(context, path);
337 default:
338 return methodNotAllowed(CAL_DAV_ALLOW);
339 }
340}