diff --git a/src/index.js b/src/index.js index 82e0d11a..d02ae9f6 100644 --- a/src/index.js +++ b/src/index.js @@ -485,6 +485,191 @@ export default class DavClient { return this._request.pathname(response.body['{DAV:}current-user-principal'].href) } + /** + * Creates a CalendarHome instance for an arbitrary calendar home URL. + * + * This can be used to access calendars that are not in the current user's + * own calendar home – for example when acting as a calendar proxy (delegate) + * for another user. + * + * @param {string} calendarHomeUrl Absolute URL of the calendar home + * @return {CalendarHome} + */ + getCalendarHomeForUrl(calendarHomeUrl) { + const url = this._request.pathname(calendarHomeUrl) + return new CalendarHome(this, this._request, url, {}) + } + + /** + * Fetches the group-member-set of a principal collection (e.g. a calendar-proxy group). + * @see https://tools.ietf.org/html/rfc3744#section-4.3 + * + * @param {string} groupUrl Absolute URL of the proxy group principal + * @return {Promise} Absolute URLs of member principals + */ + async getGroupMemberSet(groupUrl) { + const { body } = await this._request.propFind(groupUrl, [ + [NS.DAV, 'group-member-set'], + ]) + const members = body[`{${NS.DAV}}group-member-set`] ?? [] + return members.map((href) => this._request.absoluteUrl(href)) + } + + /** + * Sets the group-member-set of a principal collection (e.g. a calendar-proxy group). + * @see https://tools.ietf.org/html/rfc3744#section-4.3 + * + * @param {string} groupUrl Absolute URL of the proxy group principal + * @param {string[]} memberUrls Absolute URLs of the new member set + * @return {Promise} + */ + async setGroupMemberSet(groupUrl, memberUrls) { + const [skeleton] = XMLUtility.getRootSkeleton([NS.DAV, 'propertyupdate']) + skeleton.children.push({ + name: [NS.DAV, 'set'], + children: [{ + name: [NS.DAV, 'prop'], + children: [{ + name: [NS.DAV, 'group-member-set'], + children: memberUrls.map((url) => ({ + name: [NS.DAV, 'href'], + value: url, + })), + }], + }], + }) + const body = XMLUtility.serialize(skeleton) + await this._request.propPatch(groupUrl, {}, body) + } + + /** + * Fetches the group-membership of a principal (the groups it belongs to). + * @see https://tools.ietf.org/html/rfc3744#section-4.4 + * + * @param {string} principalUrl Absolute URL of the principal + * @return {Promise} Absolute URLs of groups the principal belongs to + */ + async getGroupMembership(principalUrl) { + const { body } = await this._request.propFind(principalUrl, [ + [NS.DAV, 'group-membership'], + ]) + const groups = body[`{${NS.DAV}}group-membership`] ?? [] + return groups.map((href) => this._request.absoluteUrl(href)) + } + + /** + * Discovers the calendar home URL for a principal via CalDAV PROPFIND. + * + * Performs a depth-0 PROPFIND on the principal URL requesting the + * calendar-home-set property (RFC 4791 §6.2.1). + * + * @param {string} principalUrl Absolute URL of the principal + * @return {Promise} Absolute URL of the calendar home, or null if not found + */ + async getCalendarHomeUrlForPrincipal(principalUrl) { + const { body } = await this._request.propFind(principalUrl, [ + [NS.IETF_CALDAV, 'calendar-home-set'], + ]) + const homes = body[`{${NS.IETF_CALDAV}}calendar-home-set`] + if (!homes || !homes.length) { + return null + } + return this._request.absoluteUrl(homes[0]) + } + + /** + * Returns the absolute URLs of all write-delegates for a principal. + * + * Delegates are members of the principal's calendar-proxy-write group. + * + * @param {string} proxyWriteGroupUrl Absolute URL of the principal's calendar-proxy-write group + * @return {Promise} Absolute principal URLs of delegates + */ + async getDelegateUrls(proxyWriteGroupUrl) { + return this.getGroupMemberSet(proxyWriteGroupUrl) + } + + /** + * Adds a delegate to a calendar-proxy-write group. + * + * Fetches the current member set and appends the new delegate if not already present. + * + * @param {string} proxyWriteGroupUrl Absolute URL of the principal's calendar-proxy-write group + * @param {string} delegatePrincipalUrl Absolute or relative URL of the principal to add as delegate + * @return {Promise} + */ + async addDelegate(proxyWriteGroupUrl, delegatePrincipalUrl) { + const normalizedUrl = this._request.absoluteUrl(delegatePrincipalUrl) + const current = await this.getGroupMemberSet(proxyWriteGroupUrl) + if (!current.includes(normalizedUrl)) { + await this.setGroupMemberSet(proxyWriteGroupUrl, [...current, normalizedUrl]) + } + } + + /** + * Removes a delegate from a calendar-proxy-write group. + * + * @param {string} proxyWriteGroupUrl Absolute URL of the principal's calendar-proxy-write group + * @param {string} delegatePrincipalUrl Absolute or relative URL of the principal to remove + * @return {Promise} + */ + async removeDelegate(proxyWriteGroupUrl, delegatePrincipalUrl) { + const normalizedUrl = this._request.absoluteUrl(delegatePrincipalUrl) + const current = await this.getGroupMemberSet(proxyWriteGroupUrl) + await this.setGroupMemberSet(proxyWriteGroupUrl, current.filter((url) => url !== normalizedUrl)) + } + + /** + * Returns the principal URLs of users who have granted the given principal + * write-proxy (delegate) access. + * + * Inspects the group-membership property for groups ending in + * /calendar-proxy-write and strips that suffix to obtain the owner's + * principal URL. + * + * @param {string} principalUrl Absolute URL of the principal + * @return {Promise} Absolute principal URLs of users who delegated to this principal + */ + async getDelegatorPrincipalUrls(principalUrl) { + const groups = await this.getGroupMembership(principalUrl) + return groups + .filter((url) => url.includes('calendar-proxy-write')) + .map((url) => url.replace(/\/calendar-proxy-write\/?$/, '') || null) + .filter(Boolean) + } + + /** + * Returns the principal URLs and permission level of users who have granted + * the given principal proxy access (both read and write). + * + * Inspects the group-membership property for groups ending in + * /calendar-proxy-write or /calendar-proxy-read and returns objects with + * the owner's principal URL and the permission granted. + * + * @param {string} principalUrl Absolute URL of the principal + * @return {Promise>} + */ + async getDelegatorsWithPermission(principalUrl) { + const groups = await this.getGroupMembership(principalUrl) + const result = [] + + for (const groupUrl of groups) { + if (groupUrl.includes('calendar-proxy-write')) { + const ownerUrl = groupUrl.replace(/\/calendar-proxy-write\/?$/, '') + if (ownerUrl) { + result.push({ principalUrl: ownerUrl, permission: 'write' }) + } + } else if (groupUrl.includes('calendar-proxy-read')) { + const ownerUrl = groupUrl.replace(/\/calendar-proxy-read\/?$/, '') + if (ownerUrl) { + result.push({ principalUrl: ownerUrl, permission: 'read' }) + } + } + } + + return result + } + /** * discovers all calendar-homes in this account, all principal collections * and advertised features