File
Blob: src/worker/dav/discovery/index.ts
| 1 | import type { DavPatAuth } from "@/worker/auth/basic"; |
| 2 | import { hasDavScope } from "@/worker/auth/scopes"; |
| 3 | import { |
| 4 | addressbookChildResources, |
| 5 | addressbookResourceForPath, |
| 6 | isAddressbookDiscoveryPath, |
| 7 | } from "@/worker/dav/discovery/addressbooks"; |
| 8 | import { |
| 9 | calendarChildResources, |
| 10 | calendarResourceForPath, |
| 11 | isCalendarDiscoveryPath, |
| 12 | } from "@/worker/dav/discovery/calendars"; |
| 13 | import { |
| 14 | isPrincipalDiscoveryPath, |
| 15 | principalChildResources, |
| 16 | principalHref, |
| 17 | principalResource, |
| 18 | } from "@/worker/dav/discovery/principals"; |
| 19 | import type { DiscoveryResource } from "@/worker/dav/discovery/resources"; |
| 20 | import { davHtmlListingResponse, type DavListingEntry, type DavListingKind } from "@/worker/dav/html-listing"; |
| 21 | import { isRootPath, ROOT_RESOURCE, rootChildResources } from "@/worker/dav/discovery/root"; |
| 22 | import { davError, davResponse, emptyResponse, methodNotAllowed, parseDepth } from "@/worker/dav/http"; |
| 23 | import { maxXmlBodyBytes } from "@/worker/dav/limits"; |
| 24 | import { buildMultistatus, emptyProp, escapeXml, type PropValue } from "@/worker/dav/multistatus"; |
| 25 | import { davProp } from "@/worker/dav/props"; |
| 26 | import type { SubjectRow } from "@/worker/db/d1/schema"; |
| 27 | import type { DavScope } from "@/worker/db/types"; |
| 28 | import type { AppContext } from "@/worker/types"; |
| 29 | import { parsePropfindXml, type PropfindRequest } from "@/worker/xml/dav-request"; |
| 30 | import { CALDAV_NS, CARDDAV_NS, qname, sameQName, type QName } from "@/worker/xml/namespaces"; |
| 31 | |
| 32 | export { isDavWellKnownPath } from "@/worker/dav/discovery/well-known"; |
| 33 | |
| 34 | export const DAV_DISCOVERY_ALLOW = "OPTIONS, PROPFIND, GET, HEAD"; |
| 35 | |
| 36 | export const DAV_DISCOVERY_OPTIONS_HEADERS = { |
| 37 | Allow: DAV_DISCOVERY_ALLOW, |
| 38 | DAV: "1, 2", |
| 39 | "MS-Author-Via": "DAV", |
| 40 | } as const; |
| 41 | |
| 42 | function caldavProp(localName: string): QName { |
| 43 | return qname(CALDAV_NS, localName); |
| 44 | } |
| 45 | |
| 46 | function carddavProp(localName: string): QName { |
| 47 | return qname(CARDDAV_NS, localName); |
| 48 | } |
| 49 | |
| 50 | function hasScope(auth: DavPatAuth, scope: DavScope): boolean { |
| 51 | return hasDavScope(auth.scopes, scope); |
| 52 | } |
| 53 | |
| 54 | function hrefXml(href: string): string { |
| 55 | return `<D:href>${escapeXml(href)}</D:href>`; |
| 56 | } |
| 57 | |
| 58 | function readPrivilegeXml(): string { |
| 59 | return "<D:privilege><D:read/></D:privilege><D:privilege><D:read-current-user-privilege-set/></D:privilege>"; |
| 60 | } |
| 61 | |
| 62 | function 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 | |
| 66 | function 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 | |
| 70 | function 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 | |
| 83 | function 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 | |
| 99 | function 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 | |
| 117 | function 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 | |
| 152 | function 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 | |
| 158 | function 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 | |
| 175 | function 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 | |
| 184 | function 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 | |
| 192 | function 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 | |
| 199 | function 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 | |
| 207 | function 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 | |
| 228 | function discoveryParentHref(resource: DiscoveryResource): string | null { |
| 229 | if (resource.kind === "root") return null; |
| 230 | if (resource.kind === "principal") return "/principals/"; |
| 231 | return "/"; |
| 232 | } |
| 233 | |
| 234 | function 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 | |
| 243 | function 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 | |
| 261 | export function isDavDiscoveryPath(pathname: string): boolean { |
| 262 | return ( |
| 263 | isRootPath(pathname) || |
| 264 | isPrincipalDiscoveryPath(pathname) || |
| 265 | isCalendarDiscoveryPath(pathname) || |
| 266 | isAddressbookDiscoveryPath(pathname) |
| 267 | ); |
| 268 | } |
| 269 | |
| 270 | export 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 | } |