File
Blob: src/worker/dav/collection-props.ts
| 1 | import { buildMultistatus, emptyProp, escapeXml, type PropValue, type Propstat } from "@/worker/dav/multistatus"; |
| 2 | import { davProp, isProtectedLivePropForResource, type DavResourcePropKind } from "@/worker/dav/props"; |
| 3 | import type { AppContext } from "@/worker/types"; |
| 4 | import { parseProppatchXml, type PropfindRequest, type ProppatchInstruction } from "@/worker/xml/dav-request"; |
| 5 | import { qname, sameQName, type QName } from "@/worker/xml/namespaces"; |
| 6 | |
| 7 | export interface DeadPropRow { |
| 8 | nsUri: string; |
| 9 | localName: string; |
| 10 | xmlValue: string; |
| 11 | } |
| 12 | |
| 13 | export interface ResourceWithDeadProps { |
| 14 | href: string; |
| 15 | deadProps: DeadPropRow[]; |
| 16 | } |
| 17 | |
| 18 | export const COLLECTION_DAV_PROPS = [ |
| 19 | davProp("resourcetype"), |
| 20 | davProp("displayname"), |
| 21 | davProp("owner"), |
| 22 | davProp("current-user-privilege-set"), |
| 23 | davProp("supported-privilege-set"), |
| 24 | ] as const; |
| 25 | |
| 26 | export function csProp(localName: string): QName { |
| 27 | return qname("http://calendarserver.org/ns/", localName); |
| 28 | } |
| 29 | |
| 30 | export function hrefXml(href: string): string { |
| 31 | return `<D:href>${escapeXml(href)}</D:href>`; |
| 32 | } |
| 33 | |
| 34 | export function supportedPrivilegeXml(): string { |
| 35 | 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>`; |
| 36 | } |
| 37 | |
| 38 | export function currentUserPrivilegeXml(canWrite: boolean): string { |
| 39 | const read = "<D:privilege><D:read/></D:privilege><D:privilege><D:read-current-user-privilege-set/></D:privilege>"; |
| 40 | const write = |
| 41 | "<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>"; |
| 42 | return read + (canWrite ? write : ""); |
| 43 | } |
| 44 | |
| 45 | export function commonCollectionPropValue(prop: QName, ownerHref: string, canWrite: boolean): PropValue | null { |
| 46 | if (sameQName(prop, davProp("owner"))) return { qname: prop, valueXml: hrefXml(ownerHref) }; |
| 47 | if (sameQName(prop, davProp("current-user-privilege-set"))) { |
| 48 | return { qname: prop, valueXml: currentUserPrivilegeXml(canWrite) }; |
| 49 | } |
| 50 | if (sameQName(prop, davProp("supported-privilege-set"))) return { qname: prop, valueXml: supportedPrivilegeXml() }; |
| 51 | return null; |
| 52 | } |
| 53 | |
| 54 | export function objectEntityPropValue( |
| 55 | prop: QName, |
| 56 | object: { etag: string; size: number } | null | undefined, |
| 57 | contentType: string, |
| 58 | ): PropValue | null { |
| 59 | if (!object) return null; |
| 60 | if (sameQName(prop, davProp("getetag"))) return { qname: prop, valueXml: object.etag }; |
| 61 | if (sameQName(prop, davProp("getcontentlength"))) return { qname: prop, valueXml: String(object.size) }; |
| 62 | if (sameQName(prop, davProp("getcontenttype"))) return { qname: prop, valueXml: contentType }; |
| 63 | return null; |
| 64 | } |
| 65 | |
| 66 | export function supportedReportSetPropValue( |
| 67 | prop: QName, |
| 68 | valueXml: string, |
| 69 | objectValueXml?: string | null, |
| 70 | ): PropValue | null { |
| 71 | if (!sameQName(prop, davProp("supported-report-set"))) return null; |
| 72 | return { qname: prop, valueXml: objectValueXml ?? valueXml }; |
| 73 | } |
| 74 | |
| 75 | export function syncTokenPropValue(prop: QName, syncToken: string | null | undefined): PropValue | null { |
| 76 | if (!syncToken || !sameQName(prop, davProp("sync-token"))) return null; |
| 77 | return { qname: prop, valueXml: syncToken }; |
| 78 | } |
| 79 | |
| 80 | export function getctagPropValue(prop: QName, syncSeq: number | null | undefined): PropValue | null { |
| 81 | if (syncSeq === null || syncSeq === undefined || !sameQName(prop, csProp("getctag"))) return null; |
| 82 | return { |
| 83 | qname: prop, |
| 84 | valueXml: `<CS:getctag xmlns:CS="http://calendarserver.org/ns/">sync:${syncSeq}</CS:getctag>`, |
| 85 | }; |
| 86 | } |
| 87 | |
| 88 | export function deadPropValue(deadProps: DeadPropRow[], prop: QName): PropValue | null { |
| 89 | const dead = deadProps.find((row) => row.nsUri === prop.nsUri && row.localName === prop.localName); |
| 90 | return dead ? { qname: prop, valueXml: dead.xmlValue } : null; |
| 91 | } |
| 92 | |
| 93 | export function buildPropstats<TResource extends ResourceWithDeadProps>( |
| 94 | resource: TResource, |
| 95 | request: PropfindRequest, |
| 96 | defaultProps: (resource: TResource) => QName[], |
| 97 | propValue: (resource: TResource, prop: QName) => PropValue | null, |
| 98 | ): Propstat[] { |
| 99 | const deadPropNames = resource.deadProps.map((row) => qname(row.nsUri, row.localName)); |
| 100 | const props = |
| 101 | request.kind === "prop" |
| 102 | ? request.props |
| 103 | : request.kind === "allprop" |
| 104 | ? [...defaultProps(resource), ...request.includeProps, ...deadPropNames] |
| 105 | : [...defaultProps(resource), ...deadPropNames]; |
| 106 | if (request.kind === "propname") return [{ status: 200, props: props.map(emptyProp) }]; |
| 107 | |
| 108 | const ok: PropValue[] = []; |
| 109 | const missing: PropValue[] = []; |
| 110 | for (const prop of props) { |
| 111 | const value = propValue(resource, prop) ?? deadPropValue(resource.deadProps, prop); |
| 112 | if (value) ok.push(value); |
| 113 | else missing.push(emptyProp(prop)); |
| 114 | } |
| 115 | return [ |
| 116 | { status: 200, props: ok }, |
| 117 | { status: 404, props: missing }, |
| 118 | ]; |
| 119 | } |
| 120 | |
| 121 | export function multistatusForResources<TResource extends ResourceWithDeadProps>( |
| 122 | resources: TResource[], |
| 123 | propstats: (resource: TResource) => Propstat[], |
| 124 | ): string { |
| 125 | return buildMultistatus(resources.map((resource) => ({ href: resource.href, propstats: propstats(resource) }))); |
| 126 | } |
| 127 | |
| 128 | export async function parseDeadPropInstructions( |
| 129 | c: AppContext, |
| 130 | maxXmlBytes: number, |
| 131 | resourceKind: DavResourcePropKind, |
| 132 | ): Promise<ProppatchInstruction[]> { |
| 133 | const instructions = parseProppatchXml(await c.req.text(), maxXmlBytes); |
| 134 | const protectedProp = instructions.find((instruction) => |
| 135 | isProtectedLivePropForResource(instruction.prop, resourceKind), |
| 136 | ); |
| 137 | if (protectedProp) throw new Error(`Cannot set protected property ${protectedProp.prop.localName}`); |
| 138 | return instructions; |
| 139 | } |