Skip to content
File

Blob: src/worker/dav/discovery/index.ts

typescript304 lines
1import type { DavPatAuth } from "@/worker/auth/basic";
2import { hasDavScope } from "@/worker/auth/scopes";
3import {
4 addressbookChildResources,
5 addressbookResourceForPath,
6 isAddressbookDiscoveryPath,
7} from "@/worker/dav/discovery/addressbooks";
8import {
9 calendarChildResources,
10 calendarResourceForPath,
11 isCalendarDiscoveryPath,
12} from "@/worker/dav/discovery/calendars";
13import {
14 isPrincipalDiscoveryPath,
15 principalChildResources,
16 principalHref,
17 principalResource,
18} from "@/worker/dav/discovery/principals";
19import type { DiscoveryResource } from "@/worker/dav/discovery/resources";
20import { davHtmlListingResponse, type DavListingEntry, type DavListingKind } from "@/worker/dav/html-listing";
21import { isRootPath, ROOT_RESOURCE, rootChildResources } from "@/worker/dav/discovery/root";
22import { davError, davResponse, emptyResponse, methodNotAllowed, parseDepth } from "@/worker/dav/http";
23import { maxXmlBodyBytes } from "@/worker/dav/limits";
24import { buildMultistatus, emptyProp, escapeXml, type PropValue } from "@/worker/dav/multistatus";
25import { davProp } from "@/worker/dav/props";
26import type { SubjectRow } from "@/worker/db/d1/schema";
27import type { DavScope } from "@/worker/db/types";
28import type { AppContext } from "@/worker/types";
29import { parsePropfindXml, type PropfindRequest } from "@/worker/xml/dav-request";
30import { CALDAV_NS, CARDDAV_NS, qname, sameQName, type QName } from "@/worker/xml/namespaces";
31 
32export { isDavWellKnownPath } from "@/worker/dav/discovery/well-known";
33 
34export const DAV_DISCOVERY_ALLOW = "OPTIONS, PROPFIND, GET, HEAD";
35 
36export const DAV_DISCOVERY_OPTIONS_HEADERS = {
37 Allow: DAV_DISCOVERY_ALLOW,
38 DAV: "1, 2",
39 "MS-Author-Via": "DAV",
40} as const;
41 
42function caldavProp(localName: string): QName {
43 return qname(CALDAV_NS, localName);
44}
45 
46function carddavProp(localName: string): QName {
47 return qname(CARDDAV_NS, localName);
48}
49 
50function hasScope(auth: DavPatAuth, scope: DavScope): boolean {
51 return hasDavScope(auth.scopes, scope);
52}
53 
54function hrefXml(href: string): string {
55 return `<D:href>${escapeXml(href)}</D:href>`;
56}
57 
58function readPrivilegeXml(): string {
59 return "<D:privilege><D:read/></D:privilege><D:privilege><D:read-current-user-privilege-set/></D:privilege>";
60}
61 
62function writePrivilegeXml(): string {
63 return "<D:privilege><D:write/></D:privilege><D:privilege><D:write-content/></D:privilege><D:privilege><D:write-properties/></D:privilege><D:privilege><D:bind/></D:privilege><D:privilege><D:unbind/></D:privilege>";
64}
65 
66function supportedPrivilegeXml(): string {
67 return `<D:supported-privilege><D:privilege><D:all/></D:privilege><D:supported-privilege><D:privilege><D:read/></D:privilege><D:supported-privilege><D:privilege><D:read-current-user-privilege-set/></D:privilege></D:supported-privilege></D:supported-privilege><D:supported-privilege><D:privilege><D:write/></D:privilege><D:supported-privilege><D:privilege><D:write-content/></D:privilege></D:supported-privilege><D:supported-privilege><D:privilege><D:write-properties/></D:privilege></D:supported-privilege><D:supported-privilege><D:privilege><D:bind/></D:privilege></D:supported-privilege><D:supported-privilege><D:privilege><D:unbind/></D:privilege></D:supported-privilege></D:supported-privilege></D:supported-privilege>`;
68}
69 
70function resourceTypeXml(resource: DiscoveryResource): string {
71 switch (resource.kind) {
72 case "principal":
73 return "<D:collection/><D:principal/>";
74 case "calendar-default":
75 return `<D:collection/><C:calendar xmlns:C="${CALDAV_NS}"/>`;
76 case "addressbook-default":
77 return `<D:collection/><CARD:addressbook xmlns:CARD="${CARDDAV_NS}"/>`;
78 default:
79 return "<D:collection/>";
80 }
81}
82 
83function currentPrivilegeXml(auth: DavPatAuth, resource: DiscoveryResource): string {
84 if (resource.protocol === "caldav") {
85 if (!hasScope(auth, "dav:caldav:read")) return "";
86 return readPrivilegeXml() + (hasScope(auth, "dav:caldav:write") ? writePrivilegeXml() : "");
87 }
88 if (resource.protocol === "carddav") {
89 if (!hasScope(auth, "dav:carddav:read")) return "";
90 return readPrivilegeXml() + (hasScope(auth, "dav:carddav:write") ? writePrivilegeXml() : "");
91 }
92 if (resource.protocol === "files") {
93 if (!hasScope(auth, "dav:files:read")) return "";
94 return readPrivilegeXml() + (hasScope(auth, "dav:files:write") ? writePrivilegeXml() : "");
95 }
96 return readPrivilegeXml();
97}
98 
99function supportedProps(auth: DavPatAuth): QName[] {
100 const props = [
101 davProp("resourcetype"),
102 davProp("displayname"),
103 davProp("current-user-principal"),
104 davProp("principal-URL"),
105 davProp("owner"),
106 davProp("current-user-privilege-set"),
107 davProp("supported-privilege-set"),
108 davProp("principal-collection-set"),
109 ];
110 if (hasScope(auth, "dav:caldav:read")) {
111 props.push(caldavProp("calendar-home-set"), caldavProp("calendar-user-address-set"));
112 }
113 if (hasScope(auth, "dav:carddav:read")) props.push(carddavProp("addressbook-home-set"));
114 return props;
115}
116 
117function propValue(resource: DiscoveryResource, subject: SubjectRow, auth: DavPatAuth, prop: QName): PropValue | null {
118 const canonicalPrincipal = principalHref(subject);
119 if (sameQName(prop, davProp("resourcetype"))) return { qname: prop, valueXml: resourceTypeXml(resource) };
120 if (sameQName(prop, davProp("displayname"))) return { qname: prop, valueXml: escapeXml(resource.displayName) };
121 if (sameQName(prop, davProp("current-user-principal"))) return { qname: prop, valueXml: hrefXml("/principals/me/") };
122 if (sameQName(prop, davProp("principal-URL"))) return { qname: prop, valueXml: hrefXml(canonicalPrincipal) };
123 if (sameQName(prop, davProp("owner"))) return { qname: prop, valueXml: hrefXml(canonicalPrincipal) };
124 if (sameQName(prop, davProp("current-user-privilege-set"))) {
125 return { qname: prop, valueXml: currentPrivilegeXml(auth, resource) };
126 }
127 if (sameQName(prop, davProp("supported-privilege-set"))) return { qname: prop, valueXml: supportedPrivilegeXml() };
128 if (sameQName(prop, davProp("principal-collection-set"))) return { qname: prop, valueXml: hrefXml("/principals/") };
129 if (sameQName(prop, caldavProp("calendar-home-set")) && hasScope(auth, "dav:caldav:read")) {
130 return {
131 qname: prop,
132 valueXml: `<C:calendar-home-set xmlns:C="${CALDAV_NS}">${hrefXml("/calendars/")}</C:calendar-home-set>`,
133 };
134 }
135 if (sameQName(prop, caldavProp("calendar-user-address-set")) && hasScope(auth, "dav:caldav:read")) {
136 return {
137 qname: prop,
138 valueXml: `<C:calendar-user-address-set xmlns:C="${CALDAV_NS}">${hrefXml(canonicalPrincipal)}</C:calendar-user-address-set>`,
139 };
140 }
141 if (sameQName(prop, carddavProp("addressbook-home-set")) && hasScope(auth, "dav:carddav:read")) {
142 return {
143 qname: prop,
144 valueXml: `<CARD:addressbook-home-set xmlns:CARD="${CARDDAV_NS}">${hrefXml(
145 "/addressbooks/",
146 )}</CARD:addressbook-home-set>`,
147 };
148 }
149 return null;
150}
151 
152function propsForRequest(request: PropfindRequest, auth: DavPatAuth): QName[] {
153 if (request.kind === "prop") return request.props;
154 if (request.kind === "allprop") return [...supportedProps(auth), ...request.includeProps];
155 return supportedProps(auth);
156}
157 
158function propstats(resource: DiscoveryResource, subject: SubjectRow, auth: DavPatAuth, request: PropfindRequest) {
159 const names = propsForRequest(request, auth);
160 if (request.kind === "propname") return [{ status: 200, props: names.map(emptyProp) }];
161 
162 const ok: PropValue[] = [];
163 const missing: PropValue[] = [];
164 for (const prop of names) {
165 const value = propValue(resource, subject, auth, prop);
166 if (value) ok.push(value);
167 else missing.push(emptyProp(prop));
168 }
169 return [
170 { status: 200, props: ok },
171 { status: 404, props: missing },
172 ];
173}
174 
175function resourceForPath(pathname: string, subject: SubjectRow): DiscoveryResource | null {
176 return (
177 (isRootPath(pathname) ? ROOT_RESOURCE : null) ??
178 calendarResourceForPath(pathname) ??
179 addressbookResourceForPath(pathname) ??
180 principalResource(pathname, subject)
181 );
182}
183 
184function childResources(resource: DiscoveryResource, auth: DavPatAuth, subject: SubjectRow): DiscoveryResource[] {
185 if (resource.kind === "root") return rootChildResources(auth);
186 if (resource.kind === "principal-collection") return principalChildResources(subject);
187 if (resource.kind === "calendar-home") return calendarChildResources(auth);
188 if (resource.kind === "addressbook-home") return addressbookChildResources(auth);
189 return [];
190}
191 
192function canAccessResource(auth: DavPatAuth, resource: DiscoveryResource): boolean {
193 if (resource.protocol === "caldav") return hasScope(auth, "dav:caldav:read");
194 if (resource.protocol === "carddav") return hasScope(auth, "dav:carddav:read");
195 if (resource.protocol === "files") return hasScope(auth, "dav:files:read");
196 return true;
197}
198 
199function discoveryListingKind(resource: DiscoveryResource): DavListingKind {
200 if (resource.kind === "principal" || resource.kind === "principal-collection") return "principal";
201 if (resource.protocol === "files") return "collection";
202 if (resource.protocol === "caldav") return "calendar";
203 if (resource.protocol === "carddav") return "addressbook";
204 return "collection";
205}
206 
207function discoveryDescription(resource: DiscoveryResource): string | null {
208 switch (resource.kind) {
209 case "files-home":
210 return "WebDAV file storage";
211 case "calendar-home":
212 return "CalDAV calendar home";
213 case "calendar-default":
214 return "Default calendar collection";
215 case "addressbook-home":
216 return "CardDAV address book home";
217 case "addressbook-default":
218 return "Default address book collection";
219 case "principal-collection":
220 return "DAV principal discovery";
221 case "principal":
222 return "Current subject principal";
223 default:
224 return null;
225 }
226}
227 
228function discoveryParentHref(resource: DiscoveryResource): string | null {
229 if (resource.kind === "root") return null;
230 if (resource.kind === "principal") return "/principals/";
231 return "/";
232}
233 
234function discoveryEntry(resource: DiscoveryResource): DavListingEntry {
235 return {
236 href: resource.href,
237 name: resource.displayName,
238 kind: discoveryListingKind(resource),
239 description: discoveryDescription(resource),
240 };
241}
242 
243function handleDiscoveryGetHead(
244 c: AppContext,
245 subject: SubjectRow,
246 auth: DavPatAuth,
247 resource: DiscoveryResource,
248): Promise<Response> {
249 return davHtmlListingResponse(
250 c,
251 {
252 title: resource.displayName,
253 href: resource.href,
254 parentHref: discoveryParentHref(resource),
255 entries: childResources(resource, auth, subject).map(discoveryEntry),
256 },
257 c.req.method,
258 );
259}
260 
261export function isDavDiscoveryPath(pathname: string): boolean {
262 return (
263 isRootPath(pathname) ||
264 isPrincipalDiscoveryPath(pathname) ||
265 isCalendarDiscoveryPath(pathname) ||
266 isAddressbookDiscoveryPath(pathname)
267 );
268}
269 
270export async function handleDavDiscovery(c: AppContext, subject: SubjectRow, auth: DavPatAuth): Promise<Response> {
271 if (c.req.method === "OPTIONS") return emptyResponse(204, DAV_DISCOVERY_OPTIONS_HEADERS);
272 
273 const pathname = new URL(c.req.url).pathname;
274 const resource = resourceForPath(pathname, subject);
275 if (!resource) return davError(404, "Not found");
276 if (!canAccessResource(auth, resource)) return davError(403, "PAT scope does not allow this collection");
277 
278 if (c.req.method === "GET" || c.req.method === "HEAD")
279 return await handleDiscoveryGetHead(c, subject, auth, resource);
280 if (c.req.method !== "PROPFIND") return methodNotAllowed(DAV_DISCOVERY_ALLOW);
281 
282 const depth = parseDepth(c.req.header("depth") ?? null, "0");
283 if (!depth) return davError(400, "Invalid Depth header");
284 
285 let request: PropfindRequest;
286 try {
287 request = parsePropfindXml(await c.req.text(), maxXmlBodyBytes(c.env));
288 } catch (cause) {
289 return davError(400, cause instanceof Error ? cause.message : "Invalid PROPFIND body");
290 }
291 
292 const resources = [resource];
293 if (depth !== "0") resources.push(...childResources(resource, auth, subject));
294 return davResponse(
295 buildMultistatus(
296 resources.map((entry) => ({
297 href: entry.href,
298 propstats: propstats(entry, subject, auth, request),
299 })),
300 ),
301 207,
302 );
303}