Skip to content
File

Blob: src/worker/carddav/handler.ts

typescript276 lines
1import type { DavPatAuth } from "@/worker/auth/basic";
2import { hasDavScope } from "@/worker/auth/scopes";
3import { addressbookHref, addressObjectHref } from "@/worker/carddav/paths";
4import { multistatusForResources, propstats } from "@/worker/carddav/props";
5import { reportRequest, type ReportRequest } from "@/worker/carddav/reports";
6import { parseContentTypeHeader, utf8CharsetOk } from "@/worker/dav/content-type";
7import {
8 acceptedDavOrigins,
9 collectionDavHref,
10 type CollectionDavPath,
11 parseCollectionDavPath,
12} from "@/worker/dav/collection-handler";
13import type { DavListingEntry } from "@/worker/dav/html-listing";
14import {
15 handleCollectionGetHead,
16 handleCollectionObjectDelete,
17 handleCollectionObjectPut,
18 handleCollectionPropfind,
19 handleCollectionProppatch,
20 type CollectionHtmlListingAdapter,
21 type CollectionObjectAdapter,
22} from "@/worker/dav/collection-object";
23import { handleCollectionReport, type CollectionReportAdapter } from "@/worker/dav/collection-report";
24import { davError, emptyResponse, methodNotAllowed } from "@/worker/dav/http";
25import { maxAddressObjectBytes, maxReportResults, maxXmlBodyBytes } from "@/worker/dav/limits";
26import type { DavRequestContext } from "@/worker/dav/runtime";
27import type { SubjectRow } from "@/worker/db/d1/schema";
28import type { AppContext } from "@/worker/types";
29 
30export const CARD_DAV_ALLOW = "OPTIONS, PROPFIND, PROPPATCH, REPORT, GET, HEAD, PUT, DELETE";
31// RFC 6352 addressbook capability is advertised. DAV class 3 is omitted because
32// dab does not implement the RFC 3744 ACL method.
33export const CARD_DAV_OPTIONS_HEADERS = { Allow: CARD_DAV_ALLOW, DAV: "1, addressbook" } as const;
34 
35type AddressPath = CollectionDavPath<"addressbook">;
36type AddressResource = Parameters<typeof propstats>[0];
37type AddressObjectBody = { body: string; size: number; etag: string };
38type AddressPutExtra = { expectedVersion: string | null };
39 
40function cardObject(env: Env, storageId: string) {
41 return env.CARD_DAV.getByName(storageId);
42}
43 
44// RFC 6352 6.3.2 accepts text/vcard and historical text/x-vcard variants.
45function validateVCardContentType(
46 value: string,
47): { ok: true; version: string | null } | { ok: false; status: number; message: string } {
48 const { media, params } = parseContentTypeHeader(value);
49 if (media !== "text/vcard" && media !== "text/x-vcard") {
50 return { ok: false, status: 415, message: "CardDAV PUT requires text/vcard or text/x-vcard" };
51 }
52 if (!utf8CharsetOk(params)) {
53 return { ok: false, status: 415, message: "CardDAV PUT requires utf-8 charset" };
54 }
55 return { ok: true, version: params.version ?? null };
56}
57 
58function parseAddressPath(pathname: string): AddressPath | null {
59 return parseCollectionDavPath(pathname, "/addressbooks", "addressbook");
60}
61 
62function requiresWrite(method: string): boolean {
63 return method === "PUT" || method === "DELETE" || method === "PROPPATCH";
64}
65 
66function canUseCardDav(auth: DavPatAuth, method: string): boolean {
67 return hasDavScope(auth.scopes, requiresWrite(method) ? "dav:carddav:write" : "dav:carddav:read");
68}
69 
70function hrefForPath(path: AddressPath): string {
71 return collectionDavHref(path, {
72 homeHref: "/addressbooks/",
73 collectionHref: addressbookHref,
74 objectHref: addressObjectHref,
75 });
76}
77 
78function resourceKindForPath(path: AddressPath) {
79 return path.kind === "home"
80 ? "addressbook-home"
81 : path.kind === "addressbook"
82 ? "addressbook-collection"
83 : "addressbook-object";
84}
85 
86function prepareAddressPut(context: DavRequestContext) {
87 // RFC 6352 6.3.2: vCard objects use text/vcard with UTF-8; clients also send text/x-vcard.
88 const contentType = context.c.req.header("content-type");
89 if (contentType === undefined) return { ok: true as const, value: { expectedVersion: null } };
90 const validation = validateVCardContentType(contentType);
91 if (!validation.ok) return { ok: false as const, response: davError(validation.status, validation.message) };
92 return { ok: true as const, value: { expectedVersion: validation.version } };
93}
94 
95function reportPropstats(
96 context: DavRequestContext,
97 report: ReportRequest,
98 resource: Parameters<typeof propstats>[0],
99 maxBytes: number,
100) {
101 return propstats(resource, context.subject, context.auth, { kind: "prop", props: report.props }, maxBytes, true);
102}
103 
104function reportBookName(path: AddressPath): string {
105 if (path.kind !== "addressbook") throw new Error("REPORT path must include an address book name");
106 return path.collectionName;
107}
108 
109function addressListingTitle(resource: AddressResource): string {
110 if (resource.kind === "home") return "Address Books";
111 return resource.book?.displayName ?? resource.book?.name ?? resource.href;
112}
113 
114function addressListingDescription(resource: AddressResource): string | null {
115 if (resource.kind === "addressbook") return resource.book?.description ?? null;
116 if (resource.kind !== "object") return null;
117 const details = [resource.index?.fn, resource.index?.emails[0]].filter(Boolean);
118 return details.length > 0 ? details.join(" - ") : null;
119}
120 
121function addressListingEntry(resource: AddressResource): DavListingEntry {
122 if (resource.kind === "object") {
123 return {
124 href: resource.href,
125 name: resource.object?.name ?? resource.href,
126 kind: "address-object",
127 description: addressListingDescription(resource),
128 size: resource.object?.size,
129 modifiedAtMs: resource.object?.modifiedAtMs,
130 };
131 }
132 return {
133 href: resource.href,
134 name: addressListingTitle(resource),
135 kind: "addressbook",
136 description: addressListingDescription(resource),
137 modifiedAtMs: resource.book?.modifiedAtMs,
138 };
139}
140 
141const addressListingAdapter = {
142 title: addressListingTitle,
143 parentHref: (path) => (path.kind === "home" ? "/" : "/addressbooks/"),
144 entry: addressListingEntry,
145} satisfies CollectionHtmlListingAdapter<"addressbook", AddressResource>;
146 
147const addressAdapter = {
148 objectContentType: "text/vcard; charset=utf-8",
149 objectName: "an address object",
150 objectPathName: "address object",
151 putObjectPathName: "an address object path",
152 deleteObjectPathName: "an address object path",
153 hrefForPath,
154 resourceKindForPath,
155 maxObjectBytes: (context) => maxAddressObjectBytes(context.c.env),
156 preparePut: prepareAddressPut,
157 describe: async (context, path, depth) =>
158 await cardObject(context.c.env, context.subject.storageId).describe({
159 subjectId: context.subject.id,
160 kind: path.kind,
161 bookName: "collectionName" in path ? path.collectionName : undefined,
162 objectName: "objectName" in path ? path.objectName : undefined,
163 depth,
164 nowMs: Date.now(),
165 maxResults: maxReportResults(context.c.env),
166 }),
167 propfindMultistatus: (context, resources, request) =>
168 multistatusForResources(resources, context.subject, context.auth, request, maxAddressObjectBytes(context.c.env)),
169 proppatch: async (context, path, input) =>
170 await cardObject(context.c.env, context.subject.storageId).proppatch({
171 subjectId: context.subject.id,
172 kind: path.kind,
173 resourceId: null,
174 bookName: "collectionName" in path ? path.collectionName : undefined,
175 objectName: "objectName" in path ? path.objectName : undefined,
176 instructions: input.instructions,
177 ...input.conditions,
178 nowMs: input.nowMs,
179 }),
180 getObject: async (context, path) =>
181 await cardObject(context.c.env, context.subject.storageId).getObject({
182 subjectId: context.subject.id,
183 bookName: path.collectionName,
184 objectName: path.objectName,
185 nowMs: Date.now(),
186 }),
187 putObject: async (context, path, input) =>
188 await cardObject(context.c.env, context.subject.storageId).putObject({
189 subjectId: context.subject.id,
190 bookName: path.collectionName,
191 objectName: path.objectName,
192 body: input.body,
193 ...input.conditions,
194 nowMs: input.nowMs,
195 maxBytes: input.maxBytes,
196 expectedVersion: input.expectedVersion,
197 }),
198 deleteObject: async (context, path, input) =>
199 await cardObject(context.c.env, context.subject.storageId).deleteObject({
200 subjectId: context.subject.id,
201 bookName: path.collectionName,
202 objectName: path.objectName,
203 ...input.conditions,
204 nowMs: input.nowMs,
205 }),
206} satisfies CollectionObjectAdapter<"addressbook", AddressResource, AddressObjectBody, AddressPutExtra>;
207 
208const addressReportAdapter = {
209 invalidBodyMessage: "Invalid REPORT body",
210 validatePath: (_context, path) =>
211 path.kind === "addressbook" ? null : davError(405, "REPORT requires an address book collection"),
212 parseReport: reportRequest,
213 operationForReport: (report) =>
214 report.kind === "sync-collection" ? "sync" : report.kind === "addressbook-multiget" ? "multiget" : "query",
215 depthFallback: (report) => (report.kind === "addressbook-query" ? "1" : "0"),
216 sync: async (context, path, report) =>
217 await cardObject(context.c.env, context.subject.storageId).syncCollection({
218 subjectId: context.subject.id,
219 bookName: reportBookName(path),
220 token: report.syncToken,
221 nowMs: Date.now(),
222 maxResults: maxReportResults(context.c.env),
223 }),
224 multiget: async (context, path, report) =>
225 await cardObject(context.c.env, context.subject.storageId).addressbookMultiget({
226 subjectId: context.subject.id,
227 bookName: reportBookName(path),
228 hrefs: report.hrefs,
229 nowMs: Date.now(),
230 maxResults: maxReportResults(context.c.env),
231 acceptedOrigins: acceptedDavOrigins(context.c),
232 }),
233 query: async (context, path, report) =>
234 await cardObject(context.c.env, context.subject.storageId).addressbookQuery({
235 subjectId: context.subject.id,
236 bookName: reportBookName(path),
237 filters: report.filters,
238 filterTest: report.filterTest,
239 nowMs: Date.now(),
240 maxResults: maxReportResults(context.c.env),
241 }),
242 propstatsForResource: (context, _path, report, resource) =>
243 reportPropstats(context, report, resource, maxAddressObjectBytes(context.c.env)),
244} satisfies CollectionReportAdapter<AddressPath, ReportRequest, AddressResource>;
245 
246async function handleReport(context: DavRequestContext, path: AddressPath) {
247 return await handleCollectionReport(context, path, maxXmlBodyBytes(context.c.env), addressReportAdapter);
248}
249 
250export async function handleCardDav(c: AppContext, subject: SubjectRow, auth: DavPatAuth): Promise<Response> {
251 if (!canUseCardDav(auth, c.req.method)) return davError(403, "PAT scope does not allow this method");
252 const pathname = new URL(c.req.url).pathname;
253 const path = parseAddressPath(pathname);
254 if (!path) return davError(404, "Not found");
255 const context: DavRequestContext = { c, subject, auth, pathname };
256 switch (c.req.method) {
257 case "OPTIONS":
258 return emptyResponse(204, CARD_DAV_OPTIONS_HEADERS);
259 case "PROPFIND":
260 return await handleCollectionPropfind(context, path, addressAdapter);
261 case "PROPPATCH":
262 return await handleCollectionProppatch(context, path, addressAdapter);
263 case "GET":
264 case "HEAD":
265 return await handleCollectionGetHead(context, path, addressAdapter, addressListingAdapter);
266 case "PUT":
267 return await handleCollectionObjectPut(context, path, addressAdapter);
268 case "DELETE":
269 return await handleCollectionObjectDelete(context, path, addressAdapter);
270 case "REPORT":
271 return await handleReport(context, path);
272 default:
273 return methodNotAllowed(CARD_DAV_ALLOW);
274 }
275}