import type { DavPatAuth } from "@/worker/auth/basic"; import { hasDavScope } from "@/worker/auth/scopes"; import { addressbookHref, addressObjectHref } from "@/worker/carddav/paths"; import { multistatusForResources, propstats } from "@/worker/carddav/props"; import { reportRequest, type ReportRequest } from "@/worker/carddav/reports"; import { parseContentTypeHeader, utf8CharsetOk } from "@/worker/dav/content-type"; 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 { maxAddressObjectBytes, 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"; export const CARD_DAV_ALLOW = "OPTIONS, PROPFIND, PROPPATCH, REPORT, GET, HEAD, PUT, DELETE"; // RFC 6352 addressbook capability is advertised. DAV class 3 is omitted because // dab does not implement the RFC 3744 ACL method. export const CARD_DAV_OPTIONS_HEADERS = { Allow: CARD_DAV_ALLOW, DAV: "1, addressbook" } as const; type AddressPath = CollectionDavPath<"addressbook">; type AddressResource = Parameters[0]; type AddressObjectBody = { body: string; size: number; etag: string }; type AddressPutExtra = { expectedVersion: string | null }; function cardObject(env: Env, storageId: string) { return env.CARD_DAV.getByName(storageId); } // RFC 6352 6.3.2 accepts text/vcard and historical text/x-vcard variants. function validateVCardContentType( value: string, ): { ok: true; version: string | null } | { ok: false; status: number; message: string } { const { media, params } = parseContentTypeHeader(value); if (media !== "text/vcard" && media !== "text/x-vcard") { return { ok: false, status: 415, message: "CardDAV PUT requires text/vcard or text/x-vcard" }; } if (!utf8CharsetOk(params)) { return { ok: false, status: 415, message: "CardDAV PUT requires utf-8 charset" }; } return { ok: true, version: params.version ?? null }; } function parseAddressPath(pathname: string): AddressPath | null { return parseCollectionDavPath(pathname, "/addressbooks", "addressbook"); } function requiresWrite(method: string): boolean { return method === "PUT" || method === "DELETE" || method === "PROPPATCH"; } function canUseCardDav(auth: DavPatAuth, method: string): boolean { return hasDavScope(auth.scopes, requiresWrite(method) ? "dav:carddav:write" : "dav:carddav:read"); } function hrefForPath(path: AddressPath): string { return collectionDavHref(path, { homeHref: "/addressbooks/", collectionHref: addressbookHref, objectHref: addressObjectHref, }); } function resourceKindForPath(path: AddressPath) { return path.kind === "home" ? "addressbook-home" : path.kind === "addressbook" ? "addressbook-collection" : "addressbook-object"; } function prepareAddressPut(context: DavRequestContext) { // RFC 6352 6.3.2: vCard objects use text/vcard with UTF-8; clients also send text/x-vcard. const contentType = context.c.req.header("content-type"); if (contentType === undefined) return { ok: true as const, value: { expectedVersion: null } }; const validation = validateVCardContentType(contentType); if (!validation.ok) return { ok: false as const, response: davError(validation.status, validation.message) }; return { ok: true as const, value: { expectedVersion: validation.version } }; } function reportPropstats( context: DavRequestContext, report: ReportRequest, resource: Parameters[0], maxBytes: number, ) { return propstats(resource, context.subject, context.auth, { kind: "prop", props: report.props }, maxBytes, true); } function reportBookName(path: AddressPath): string { if (path.kind !== "addressbook") throw new Error("REPORT path must include an address book name"); return path.collectionName; } function addressListingTitle(resource: AddressResource): string { if (resource.kind === "home") return "Address Books"; return resource.book?.displayName ?? resource.book?.name ?? resource.href; } function addressListingDescription(resource: AddressResource): string | null { if (resource.kind === "addressbook") return resource.book?.description ?? null; if (resource.kind !== "object") return null; const details = [resource.index?.fn, resource.index?.emails[0]].filter(Boolean); return details.length > 0 ? details.join(" - ") : null; } function addressListingEntry(resource: AddressResource): DavListingEntry { if (resource.kind === "object") { return { href: resource.href, name: resource.object?.name ?? resource.href, kind: "address-object", description: addressListingDescription(resource), size: resource.object?.size, modifiedAtMs: resource.object?.modifiedAtMs, }; } return { href: resource.href, name: addressListingTitle(resource), kind: "addressbook", description: addressListingDescription(resource), modifiedAtMs: resource.book?.modifiedAtMs, }; } const addressListingAdapter = { title: addressListingTitle, parentHref: (path) => (path.kind === "home" ? "/" : "/addressbooks/"), entry: addressListingEntry, } satisfies CollectionHtmlListingAdapter<"addressbook", AddressResource>; const addressAdapter = { objectContentType: "text/vcard; charset=utf-8", objectName: "an address object", objectPathName: "address object", putObjectPathName: "an address object path", deleteObjectPathName: "an address object path", hrefForPath, resourceKindForPath, maxObjectBytes: (context) => maxAddressObjectBytes(context.c.env), preparePut: prepareAddressPut, describe: async (context, path, depth) => await cardObject(context.c.env, context.subject.storageId).describe({ subjectId: context.subject.id, kind: path.kind, bookName: "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, maxAddressObjectBytes(context.c.env)), proppatch: async (context, path, input) => await cardObject(context.c.env, context.subject.storageId).proppatch({ subjectId: context.subject.id, kind: path.kind, resourceId: null, bookName: "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 cardObject(context.c.env, context.subject.storageId).getObject({ subjectId: context.subject.id, bookName: path.collectionName, objectName: path.objectName, nowMs: Date.now(), }), putObject: async (context, path, input) => await cardObject(context.c.env, context.subject.storageId).putObject({ subjectId: context.subject.id, bookName: path.collectionName, objectName: path.objectName, body: input.body, ...input.conditions, nowMs: input.nowMs, maxBytes: input.maxBytes, expectedVersion: input.expectedVersion, }), deleteObject: async (context, path, input) => await cardObject(context.c.env, context.subject.storageId).deleteObject({ subjectId: context.subject.id, bookName: path.collectionName, objectName: path.objectName, ...input.conditions, nowMs: input.nowMs, }), } satisfies CollectionObjectAdapter<"addressbook", AddressResource, AddressObjectBody, AddressPutExtra>; const addressReportAdapter = { invalidBodyMessage: "Invalid REPORT body", validatePath: (_context, path) => path.kind === "addressbook" ? null : davError(405, "REPORT requires an address book collection"), parseReport: reportRequest, operationForReport: (report) => report.kind === "sync-collection" ? "sync" : report.kind === "addressbook-multiget" ? "multiget" : "query", depthFallback: (report) => (report.kind === "addressbook-query" ? "1" : "0"), sync: async (context, path, report) => await cardObject(context.c.env, context.subject.storageId).syncCollection({ subjectId: context.subject.id, bookName: reportBookName(path), token: report.syncToken, nowMs: Date.now(), maxResults: maxReportResults(context.c.env), }), multiget: async (context, path, report) => await cardObject(context.c.env, context.subject.storageId).addressbookMultiget({ subjectId: context.subject.id, bookName: reportBookName(path), hrefs: report.hrefs, nowMs: Date.now(), maxResults: maxReportResults(context.c.env), acceptedOrigins: acceptedDavOrigins(context.c), }), query: async (context, path, report) => await cardObject(context.c.env, context.subject.storageId).addressbookQuery({ subjectId: context.subject.id, bookName: reportBookName(path), filters: report.filters, filterTest: report.filterTest, nowMs: Date.now(), maxResults: maxReportResults(context.c.env), }), propstatsForResource: (context, _path, report, resource) => reportPropstats(context, report, resource, maxAddressObjectBytes(context.c.env)), } satisfies CollectionReportAdapter; async function handleReport(context: DavRequestContext, path: AddressPath) { return await handleCollectionReport(context, path, maxXmlBodyBytes(context.c.env), addressReportAdapter); } export async function handleCardDav(c: AppContext, subject: SubjectRow, auth: DavPatAuth): Promise { if (!canUseCardDav(auth, c.req.method)) return davError(403, "PAT scope does not allow this method"); const pathname = new URL(c.req.url).pathname; const path = parseAddressPath(pathname); if (!path) return davError(404, "Not found"); const context: DavRequestContext = { c, subject, auth, pathname }; switch (c.req.method) { case "OPTIONS": return emptyResponse(204, CARD_DAV_OPTIONS_HEADERS); case "PROPFIND": return await handleCollectionPropfind(context, path, addressAdapter); case "PROPPATCH": return await handleCollectionProppatch(context, path, addressAdapter); case "GET": case "HEAD": return await handleCollectionGetHead(context, path, addressAdapter, addressListingAdapter); case "PUT": return await handleCollectionObjectPut(context, path, addressAdapter); case "DELETE": return await handleCollectionObjectDelete(context, path, addressAdapter); case "REPORT": return await handleReport(context, path); default: return methodNotAllowed(CARD_DAV_ALLOW); } }