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,
);
}