import type { DavPatAuth } from "@/worker/auth/basic"; import { hasDavScope } from "@/worker/auth/scopes"; import { addressbookChildResources, addressbookResourceForPath, isAddressbookDiscoveryPath, } from "@/worker/dav/discovery/addressbooks"; import { calendarChildResources, calendarResourceForPath, isCalendarDiscoveryPath, } from "@/worker/dav/discovery/calendars"; import { isPrincipalDiscoveryPath, principalChildResources, principalHref, principalResource, } from "@/worker/dav/discovery/principals"; import type { DiscoveryResource } from "@/worker/dav/discovery/resources"; import { davHtmlListingResponse, type DavListingEntry, type DavListingKind } from "@/worker/dav/html-listing"; import { isRootPath, ROOT_RESOURCE, rootChildResources } from "@/worker/dav/discovery/root"; import { davError, davResponse, emptyResponse, methodNotAllowed, parseDepth } from "@/worker/dav/http"; import { maxXmlBodyBytes } from "@/worker/dav/limits"; import { buildMultistatus, emptyProp, escapeXml, type PropValue } from "@/worker/dav/multistatus"; import { davProp } from "@/worker/dav/props"; import type { SubjectRow } from "@/worker/db/d1/schema"; import type { DavScope } from "@/worker/db/types"; import type { AppContext } from "@/worker/types"; import { parsePropfindXml, type PropfindRequest } from "@/worker/xml/dav-request"; import { CALDAV_NS, CARDDAV_NS, qname, sameQName, type QName } from "@/worker/xml/namespaces"; export { isDavWellKnownPath } from "@/worker/dav/discovery/well-known"; export const DAV_DISCOVERY_ALLOW = "OPTIONS, PROPFIND, GET, HEAD"; export const DAV_DISCOVERY_OPTIONS_HEADERS = { Allow: DAV_DISCOVERY_ALLOW, DAV: "1, 2", "MS-Author-Via": "DAV", } as const; function caldavProp(localName: string): QName { return qname(CALDAV_NS, localName); } function carddavProp(localName: string): QName { return qname(CARDDAV_NS, localName); } function hasScope(auth: DavPatAuth, scope: DavScope): boolean { return hasDavScope(auth.scopes, scope); } function hrefXml(href: string): string { return `${escapeXml(href)}`; } function readPrivilegeXml(): string { return ""; } function writePrivilegeXml(): string { return ""; } function supportedPrivilegeXml(): string { return ``; } function resourceTypeXml(resource: DiscoveryResource): string { switch (resource.kind) { case "principal": return ""; case "calendar-default": return ``; case "addressbook-default": return ``; default: return ""; } } function currentPrivilegeXml(auth: DavPatAuth, resource: DiscoveryResource): string { if (resource.protocol === "caldav") { if (!hasScope(auth, "dav:caldav:read")) return ""; return readPrivilegeXml() + (hasScope(auth, "dav:caldav:write") ? writePrivilegeXml() : ""); } if (resource.protocol === "carddav") { if (!hasScope(auth, "dav:carddav:read")) return ""; return readPrivilegeXml() + (hasScope(auth, "dav:carddav:write") ? writePrivilegeXml() : ""); } if (resource.protocol === "files") { if (!hasScope(auth, "dav:files:read")) return ""; return readPrivilegeXml() + (hasScope(auth, "dav:files:write") ? writePrivilegeXml() : ""); } return readPrivilegeXml(); } function supportedProps(auth: DavPatAuth): QName[] { const props = [ davProp("resourcetype"), davProp("displayname"), davProp("current-user-principal"), davProp("principal-URL"), davProp("owner"), davProp("current-user-privilege-set"), davProp("supported-privilege-set"), davProp("principal-collection-set"), ]; if (hasScope(auth, "dav:caldav:read")) { props.push(caldavProp("calendar-home-set"), caldavProp("calendar-user-address-set")); } if (hasScope(auth, "dav:carddav:read")) props.push(carddavProp("addressbook-home-set")); return props; } function propValue(resource: DiscoveryResource, subject: SubjectRow, auth: DavPatAuth, prop: QName): PropValue | null { const canonicalPrincipal = principalHref(subject); if (sameQName(prop, davProp("resourcetype"))) return { qname: prop, valueXml: resourceTypeXml(resource) }; if (sameQName(prop, davProp("displayname"))) return { qname: prop, valueXml: escapeXml(resource.displayName) }; if (sameQName(prop, davProp("current-user-principal"))) return { qname: prop, valueXml: hrefXml("/principals/me/") }; if (sameQName(prop, davProp("principal-URL"))) return { qname: prop, valueXml: hrefXml(canonicalPrincipal) }; if (sameQName(prop, davProp("owner"))) return { qname: prop, valueXml: hrefXml(canonicalPrincipal) }; if (sameQName(prop, davProp("current-user-privilege-set"))) { return { qname: prop, valueXml: currentPrivilegeXml(auth, resource) }; } if (sameQName(prop, davProp("supported-privilege-set"))) return { qname: prop, valueXml: supportedPrivilegeXml() }; if (sameQName(prop, davProp("principal-collection-set"))) return { qname: prop, valueXml: hrefXml("/principals/") }; if (sameQName(prop, caldavProp("calendar-home-set")) && hasScope(auth, "dav:caldav:read")) { return { qname: prop, valueXml: `${hrefXml("/calendars/")}`, }; } if (sameQName(prop, caldavProp("calendar-user-address-set")) && hasScope(auth, "dav:caldav:read")) { return { qname: prop, valueXml: `${hrefXml(canonicalPrincipal)}`, }; } if (sameQName(prop, carddavProp("addressbook-home-set")) && hasScope(auth, "dav:carddav:read")) { return { qname: prop, valueXml: `${hrefXml( "/addressbooks/", )}`, }; } return null; } function propsForRequest(request: PropfindRequest, auth: DavPatAuth): QName[] { if (request.kind === "prop") return request.props; if (request.kind === "allprop") return [...supportedProps(auth), ...request.includeProps]; return supportedProps(auth); } function propstats(resource: DiscoveryResource, subject: SubjectRow, auth: DavPatAuth, request: PropfindRequest) { const names = propsForRequest(request, auth); if (request.kind === "propname") return [{ status: 200, props: names.map(emptyProp) }]; const ok: PropValue[] = []; const missing: PropValue[] = []; for (const prop of names) { const value = propValue(resource, subject, auth, prop); if (value) ok.push(value); else missing.push(emptyProp(prop)); } return [ { status: 200, props: ok }, { status: 404, props: missing }, ]; } function resourceForPath(pathname: string, subject: SubjectRow): DiscoveryResource | null { return ( (isRootPath(pathname) ? ROOT_RESOURCE : null) ?? calendarResourceForPath(pathname) ?? addressbookResourceForPath(pathname) ?? principalResource(pathname, subject) ); } function childResources(resource: DiscoveryResource, auth: DavPatAuth, subject: SubjectRow): DiscoveryResource[] { if (resource.kind === "root") return rootChildResources(auth); if (resource.kind === "principal-collection") return principalChildResources(subject); if (resource.kind === "calendar-home") return calendarChildResources(auth); if (resource.kind === "addressbook-home") return addressbookChildResources(auth); return []; } function canAccessResource(auth: DavPatAuth, resource: DiscoveryResource): boolean { if (resource.protocol === "caldav") return hasScope(auth, "dav:caldav:read"); if (resource.protocol === "carddav") return hasScope(auth, "dav:carddav:read"); if (resource.protocol === "files") return hasScope(auth, "dav:files:read"); return true; } function discoveryListingKind(resource: DiscoveryResource): DavListingKind { if (resource.kind === "principal" || resource.kind === "principal-collection") return "principal"; if (resource.protocol === "files") return "collection"; if (resource.protocol === "caldav") return "calendar"; if (resource.protocol === "carddav") return "addressbook"; return "collection"; } function discoveryDescription(resource: DiscoveryResource): string | null { switch (resource.kind) { case "files-home": return "WebDAV file storage"; case "calendar-home": return "CalDAV calendar home"; case "calendar-default": return "Default calendar collection"; case "addressbook-home": return "CardDAV address book home"; case "addressbook-default": return "Default address book collection"; case "principal-collection": return "DAV principal discovery"; case "principal": return "Current subject principal"; default: return null; } } function discoveryParentHref(resource: DiscoveryResource): string | null { if (resource.kind === "root") return null; if (resource.kind === "principal") return "/principals/"; return "/"; } function discoveryEntry(resource: DiscoveryResource): DavListingEntry { return { href: resource.href, name: resource.displayName, kind: discoveryListingKind(resource), description: discoveryDescription(resource), }; } function handleDiscoveryGetHead( c: AppContext, subject: SubjectRow, auth: DavPatAuth, resource: DiscoveryResource, ): Promise { return davHtmlListingResponse( c, { title: resource.displayName, href: resource.href, parentHref: discoveryParentHref(resource), entries: childResources(resource, auth, subject).map(discoveryEntry), }, c.req.method, ); } export function isDavDiscoveryPath(pathname: string): boolean { return ( isRootPath(pathname) || isPrincipalDiscoveryPath(pathname) || isCalendarDiscoveryPath(pathname) || isAddressbookDiscoveryPath(pathname) ); } export async function handleDavDiscovery(c: AppContext, subject: SubjectRow, auth: DavPatAuth): Promise { if (c.req.method === "OPTIONS") return emptyResponse(204, DAV_DISCOVERY_OPTIONS_HEADERS); const pathname = new URL(c.req.url).pathname; const resource = resourceForPath(pathname, subject); if (!resource) return davError(404, "Not found"); if (!canAccessResource(auth, resource)) return davError(403, "PAT scope does not allow this collection"); if (c.req.method === "GET" || c.req.method === "HEAD") return await handleDiscoveryGetHead(c, subject, auth, resource); if (c.req.method !== "PROPFIND") return methodNotAllowed(DAV_DISCOVERY_ALLOW); const depth = parseDepth(c.req.header("depth") ?? null, "0"); if (!depth) return davError(400, "Invalid Depth header"); let request: PropfindRequest; try { request = parsePropfindXml(await c.req.text(), maxXmlBodyBytes(c.env)); } catch (cause) { return davError(400, cause instanceof Error ? cause.message : "Invalid PROPFIND body"); } const resources = [resource]; if (depth !== "0") resources.push(...childResources(resource, auth, subject)); return davResponse( buildMultistatus( resources.map((entry) => ({ href: entry.href, propstats: propstats(entry, subject, auth, request), })), ), 207, ); }