import type { DavPatAuth } from "@/worker/auth/basic"; import { hasDavScope } from "@/worker/auth/scopes"; import { calendarCollectionCreateRequest } from "@/worker/caldav/collections"; import { parseContentTypeHeader, utf8CharsetOk } from "@/worker/dav/content-type"; import { calendarHref, calendarObjectHref } from "@/worker/caldav/paths"; import { multistatusForResources, propstats } from "@/worker/caldav/props"; import { reportRequest, type ReportRequest } from "@/worker/caldav/reports"; import { acceptedDavOrigins, collectionDavHref, type CollectionDavPath, parseCollectionDavPath, } from "@/worker/dav/collection-handler"; import type { DavListingEntry } from "@/worker/dav/html-listing"; import { handleCollectionGetHead, handleCollectionObjectDelete, handleCollectionObjectPut, handleCollectionPropfind, handleCollectionProppatch, type CollectionHtmlListingAdapter, type CollectionObjectAdapter, } from "@/worker/dav/collection-object"; import { handleCollectionReport, type CollectionReportAdapter } from "@/worker/dav/collection-report"; import { davError, emptyResponse, methodNotAllowed } from "@/worker/dav/http"; import { maxCalendarObjectBytes, maxRecurrenceInstances, maxRecurrenceYears, maxReportResults, maxXmlBodyBytes, } from "@/worker/dav/limits"; import type { DavRequestContext } from "@/worker/dav/runtime"; import type { SubjectRow } from "@/worker/db/d1/schema"; import type { AppContext } from "@/worker/types"; // ADR: handle MKCALENDAR when the runtime delivers it, and also support the // RFC 5689 MKCOL calendar-collection alternative. Local Miniflare/workerd // dispatch rejects MKCALENDAR before Worker code runs; configuring Vite's proxy, // CORS, or middleware hooks does not change that runtime dispatch path. // Therefore, MKCOL remains the local end-to-end validation path for // client-created calendar collections. export const CAL_DAV_ALLOW = "OPTIONS, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, REPORT, GET, HEAD, PUT, DELETE"; // RFC 5689 advertises extended-mkcol when the server supports MKCOL with a // resourcetype body. We support both MKCALENDAR and the MKCOL fallback. The // service exposes calendar-access (RFC 4791) but does not implement the full // RFC 3744 ACL method, so DAV class 3 is intentionally omitted. export const CAL_DAV_OPTIONS_HEADERS = { Allow: CAL_DAV_ALLOW, DAV: "1, calendar-access, extended-mkcol" } as const; type CalendarPath = CollectionDavPath<"calendar">; type CalendarResource = Parameters[0]; type CalendarObjectBody = { body: string; size: number; etag: string }; function calObject(env: Env, storageId: string) { return env.CAL_DAV.getByName(storageId); } function validateCalendarContentType(value: string): { ok: true } | { ok: false; status: number; message: string } { const { media, params } = parseContentTypeHeader(value); if (media !== "text/calendar") { return { ok: false, status: 415, message: "CalDAV PUT requires text/calendar" }; } if (!utf8CharsetOk(params)) { return { ok: false, status: 415, message: "CalDAV PUT requires utf-8 charset" }; } return { ok: true }; } function parseCalendarPath(pathname: string): CalendarPath | null { return parseCollectionDavPath(pathname, "/calendars", "calendar"); } function requiresWrite(method: string): boolean { return ( method === "PUT" || method === "DELETE" || method === "PROPPATCH" || method === "MKCALENDAR" || method === "MKCOL" ); } function canUseCalDav(auth: DavPatAuth, method: string): boolean { return hasDavScope(auth.scopes, requiresWrite(method) ? "dav:caldav:write" : "dav:caldav:read"); } function hrefForPath(path: CalendarPath): string { return collectionDavHref(path, { homeHref: "/calendars/", collectionHref: calendarHref, objectHref: calendarObjectHref, }); } function resourceKindForPath(path: CalendarPath) { return path.kind === "home" ? "calendar-home" : path.kind === "calendar" ? "calendar-collection" : "calendar-object"; } function prepareCalendarPut(context: DavRequestContext) { // RFC 4791 4.3: calendar object resources use text/calendar with UTF-8. const contentType = context.c.req.header("content-type"); if (contentType !== undefined) { const validation = validateCalendarContentType(contentType); if (!validation.ok) return { ok: false as const, response: davError(validation.status, validation.message) }; } return { ok: true as const, value: {} }; } function reportPropstats(context: DavRequestContext, report: ReportRequest, resource: Parameters[0]) { return propstats( resource, context.subject, context.auth, { kind: "prop", props: report.props }, { maxBytes: maxCalendarObjectBytes(context.c.env), allowCalendarData: true, calendarData: report.calendarData, recurrenceBounds: { maxYears: maxRecurrenceYears(context.c.env), maxInstances: maxRecurrenceInstances(context.c.env), }, }, ); } function reportCollectionName(path: CalendarPath): string { if (!("collectionName" in path)) throw new Error("REPORT path must include a calendar name"); return path.collectionName; } function calendarListingTitle(resource: CalendarResource): string { if (resource.kind === "home") return "Calendars"; return resource.calendar?.displayName ?? resource.calendar?.name ?? resource.href; } function calendarListingDescription(resource: CalendarResource): string | null { if (resource.kind === "calendar") return resource.calendar?.description ?? null; if (resource.kind !== "object") return null; const details = [resource.index?.componentType, resource.index?.summary].filter(Boolean); return details.length > 0 ? details.join(" - ") : null; } function calendarListingEntry(resource: CalendarResource): DavListingEntry { if (resource.kind === "object") { return { href: resource.href, name: resource.object?.name ?? resource.href, kind: "calendar-object", description: calendarListingDescription(resource), size: resource.object?.size, modifiedAtMs: resource.object?.modifiedAtMs, }; } return { href: resource.href, name: calendarListingTitle(resource), kind: "calendar", description: calendarListingDescription(resource), modifiedAtMs: resource.calendar?.modifiedAtMs, }; } const calendarListingAdapter = { title: calendarListingTitle, parentHref: (path) => (path.kind === "home" ? "/" : "/calendars/"), entry: calendarListingEntry, } satisfies CollectionHtmlListingAdapter<"calendar", CalendarResource>; const calendarAdapter = { objectContentType: "text/calendar; charset=utf-8", objectName: "a calendar object", objectPathName: "calendar object", putObjectPathName: "a calendar object path", deleteObjectPathName: "a calendar object or collection path", hrefForPath, resourceKindForPath, maxObjectBytes: (context) => maxCalendarObjectBytes(context.c.env), preparePut: prepareCalendarPut, describe: async (context, path, depth) => await calObject(context.c.env, context.subject.storageId).describe({ subjectId: context.subject.id, kind: path.kind, calendarName: "collectionName" in path ? path.collectionName : undefined, objectName: "objectName" in path ? path.objectName : undefined, depth, nowMs: Date.now(), maxResults: maxReportResults(context.c.env), }), propfindMultistatus: (context, resources, request) => multistatusForResources(resources, context.subject, context.auth, request, { maxBytes: maxCalendarObjectBytes(context.c.env), }), proppatch: async (context, path, input) => await calObject(context.c.env, context.subject.storageId).proppatch({ subjectId: context.subject.id, kind: path.kind, resourceId: null, calendarName: "collectionName" in path ? path.collectionName : undefined, objectName: "objectName" in path ? path.objectName : undefined, instructions: input.instructions, ...input.conditions, nowMs: input.nowMs, }), getObject: async (context, path) => await calObject(context.c.env, context.subject.storageId).getObject({ subjectId: context.subject.id, calendarName: path.collectionName, objectName: path.objectName, nowMs: Date.now(), }), putObject: async (context, path, input) => await calObject(context.c.env, context.subject.storageId).putObject({ subjectId: context.subject.id, calendarName: path.collectionName, objectName: path.objectName, body: input.body, ...input.conditions, nowMs: input.nowMs, maxBytes: input.maxBytes, maxRecurrenceYears: maxRecurrenceYears(context.c.env), maxRecurrenceInstances: maxRecurrenceInstances(context.c.env), }), deleteObject: async (context, path, input) => await calObject(context.c.env, context.subject.storageId).deleteObject({ subjectId: context.subject.id, calendarName: path.collectionName, objectName: path.objectName, ...input.conditions, nowMs: input.nowMs, }), } satisfies CollectionObjectAdapter<"calendar", CalendarResource, CalendarObjectBody>; const calendarReportAdapter = { invalidBodyMessage: "Invalid REPORT body", validatePath: (_context, path) => path.kind === "calendar" || path.kind === "object" ? null : davError(405, "REPORT requires a calendar collection or object"), validateReportPath: (_context, path, report) => path.kind === "object" && report.kind !== "calendar-multiget" ? davError(405, "Only calendar-multiget is allowed on a calendar object") : null, parseReport: reportRequest, operationForReport: (report) => report.kind === "sync-collection" ? "sync" : report.kind === "calendar-multiget" ? "multiget" : "query", depthFallback: () => "0", sync: async (context, path, report) => await calObject(context.c.env, context.subject.storageId).syncCollection({ subjectId: context.subject.id, calendarName: reportCollectionName(path), token: report.syncToken, nowMs: Date.now(), maxResults: maxReportResults(context.c.env), }), multiget: async (context, path, report) => await calObject(context.c.env, context.subject.storageId).calendarMultiget({ subjectId: context.subject.id, calendarName: reportCollectionName(path), hrefs: report.hrefs, nowMs: Date.now(), maxResults: maxReportResults(context.c.env), acceptedOrigins: acceptedDavOrigins(context.c), }), query: async (context, path, report) => await calObject(context.c.env, context.subject.storageId).calendarQuery({ subjectId: context.subject.id, calendarName: reportCollectionName(path), filters: report.filters, nowMs: Date.now(), maxResults: maxReportResults(context.c.env), maxRecurrenceYears: maxRecurrenceYears(context.c.env), maxRecurrenceInstances: maxRecurrenceInstances(context.c.env), }), propstatsForResource: (context, _path, report, resource) => reportPropstats(context, report, resource), } satisfies CollectionReportAdapter; async function handleCreateCalendarCollection(context: DavRequestContext, path: CalendarPath) { if (path.kind !== "calendar") return davError(405, `${context.c.req.method} requires a calendar collection path`); let request: ReturnType; try { request = calendarCollectionCreateRequest( await context.c.req.text(), maxXmlBodyBytes(context.c.env), context.c.req.method as "MKCALENDAR" | "MKCOL", ); } catch (cause) { return davError(400, cause instanceof Error ? cause.message : `Invalid ${context.c.req.method} body`); } const result = await calObject(context.c.env, context.subject.storageId).createCalendar({ subjectId: context.subject.id, name: path.collectionName, displayName: request.displayName ?? path.collectionName, timezoneIcal: request.timezoneIcal, nowMs: Date.now(), }); return result.ok ? emptyResponse(201, { "Cache-Control": "no-cache" }) : davError(result.status, result.message); } async function handleDelete(context: DavRequestContext, path: CalendarPath) { if (path.kind === "calendar") { const result = await calObject(context.c.env, context.subject.storageId).deleteCalendarByName({ subjectId: context.subject.id, name: path.collectionName, nowMs: Date.now(), }); return result.ok ? emptyResponse(204) : davError(result.status, result.message); } return await handleCollectionObjectDelete(context, path, calendarAdapter); } async function handleReport(context: DavRequestContext, path: CalendarPath) { return await handleCollectionReport(context, path, maxXmlBodyBytes(context.c.env), calendarReportAdapter); } export async function handleCalDav(c: AppContext, subject: SubjectRow, auth: DavPatAuth): Promise { if (!canUseCalDav(auth, c.req.method)) return davError(403, "PAT scope does not allow this method"); const pathname = new URL(c.req.url).pathname; const path = parseCalendarPath(pathname); if (!path) return davError(404, "Not found"); const context: DavRequestContext = { c, subject, auth, pathname }; switch (c.req.method) { case "OPTIONS": return emptyResponse(204, CAL_DAV_OPTIONS_HEADERS); case "PROPFIND": return await handleCollectionPropfind(context, path, calendarAdapter); case "PROPPATCH": return await handleCollectionProppatch(context, path, calendarAdapter); case "MKCALENDAR": case "MKCOL": return await handleCreateCalendarCollection(context, path); case "GET": case "HEAD": return await handleCollectionGetHead(context, path, calendarAdapter, calendarListingAdapter); case "PUT": return await handleCollectionObjectPut(context, path, calendarAdapter); case "DELETE": return await handleDelete(context, path); case "REPORT": return await handleReport(context, path); default: return methodNotAllowed(CAL_DAV_ALLOW); } }