import { buildMultistatus, emptyProp, escapeXml, type PropValue, type Propstat } from "@/worker/dav/multistatus";
import { davProp, isProtectedLivePropForResource, type DavResourcePropKind } from "@/worker/dav/props";
import type { AppContext } from "@/worker/types";
import { parseProppatchXml, type PropfindRequest, type ProppatchInstruction } from "@/worker/xml/dav-request";
import { qname, sameQName, type QName } from "@/worker/xml/namespaces";
export interface DeadPropRow {
nsUri: string;
localName: string;
xmlValue: string;
}
export interface ResourceWithDeadProps {
href: string;
deadProps: DeadPropRow[];
}
export const COLLECTION_DAV_PROPS = [
davProp("resourcetype"),
davProp("displayname"),
davProp("owner"),
davProp("current-user-privilege-set"),
davProp("supported-privilege-set"),
] as const;
export function csProp(localName: string): QName {
return qname("http://calendarserver.org/ns/", localName);
}
export function hrefXml(href: string): string {
return `${escapeXml(href)}`;
}
export function supportedPrivilegeXml(): string {
return ``;
}
export function currentUserPrivilegeXml(canWrite: boolean): string {
const read = "";
const write =
"";
return read + (canWrite ? write : "");
}
export function commonCollectionPropValue(prop: QName, ownerHref: string, canWrite: boolean): PropValue | null {
if (sameQName(prop, davProp("owner"))) return { qname: prop, valueXml: hrefXml(ownerHref) };
if (sameQName(prop, davProp("current-user-privilege-set"))) {
return { qname: prop, valueXml: currentUserPrivilegeXml(canWrite) };
}
if (sameQName(prop, davProp("supported-privilege-set"))) return { qname: prop, valueXml: supportedPrivilegeXml() };
return null;
}
export function objectEntityPropValue(
prop: QName,
object: { etag: string; size: number } | null | undefined,
contentType: string,
): PropValue | null {
if (!object) return null;
if (sameQName(prop, davProp("getetag"))) return { qname: prop, valueXml: object.etag };
if (sameQName(prop, davProp("getcontentlength"))) return { qname: prop, valueXml: String(object.size) };
if (sameQName(prop, davProp("getcontenttype"))) return { qname: prop, valueXml: contentType };
return null;
}
export function supportedReportSetPropValue(
prop: QName,
valueXml: string,
objectValueXml?: string | null,
): PropValue | null {
if (!sameQName(prop, davProp("supported-report-set"))) return null;
return { qname: prop, valueXml: objectValueXml ?? valueXml };
}
export function syncTokenPropValue(prop: QName, syncToken: string | null | undefined): PropValue | null {
if (!syncToken || !sameQName(prop, davProp("sync-token"))) return null;
return { qname: prop, valueXml: syncToken };
}
export function getctagPropValue(prop: QName, syncSeq: number | null | undefined): PropValue | null {
if (syncSeq === null || syncSeq === undefined || !sameQName(prop, csProp("getctag"))) return null;
return {
qname: prop,
valueXml: `sync:${syncSeq}`,
};
}
export function deadPropValue(deadProps: DeadPropRow[], prop: QName): PropValue | null {
const dead = deadProps.find((row) => row.nsUri === prop.nsUri && row.localName === prop.localName);
return dead ? { qname: prop, valueXml: dead.xmlValue } : null;
}
export function buildPropstats(
resource: TResource,
request: PropfindRequest,
defaultProps: (resource: TResource) => QName[],
propValue: (resource: TResource, prop: QName) => PropValue | null,
): Propstat[] {
const deadPropNames = resource.deadProps.map((row) => qname(row.nsUri, row.localName));
const props =
request.kind === "prop"
? request.props
: request.kind === "allprop"
? [...defaultProps(resource), ...request.includeProps, ...deadPropNames]
: [...defaultProps(resource), ...deadPropNames];
if (request.kind === "propname") return [{ status: 200, props: props.map(emptyProp) }];
const ok: PropValue[] = [];
const missing: PropValue[] = [];
for (const prop of props) {
const value = propValue(resource, prop) ?? deadPropValue(resource.deadProps, prop);
if (value) ok.push(value);
else missing.push(emptyProp(prop));
}
return [
{ status: 200, props: ok },
{ status: 404, props: missing },
];
}
export function multistatusForResources(
resources: TResource[],
propstats: (resource: TResource) => Propstat[],
): string {
return buildMultistatus(resources.map((resource) => ({ href: resource.href, propstats: propstats(resource) })));
}
export async function parseDeadPropInstructions(
c: AppContext,
maxXmlBytes: number,
resourceKind: DavResourcePropKind,
): Promise {
const instructions = parseProppatchXml(await c.req.text(), maxXmlBytes);
const protectedProp = instructions.find((instruction) =>
isProtectedLivePropForResource(instruction.prop, resourceKind),
);
if (protectedProp) throw new Error(`Cannot set protected property ${protectedProp.prop.localName}`);
return instructions;
}