diff --git a/src/index.js b/src/index.js index 82e0d11a..81d0d1f4 100644 --- a/src/index.js +++ b/src/index.js @@ -485,6 +485,235 @@ 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 cachedCalendarHome = this._findCachedCalendarHome(calendarHomeUrl) + if (cachedCalendarHome) { + return cachedCalendarHome + } + + const url = this._request.pathname(calendarHomeUrl) + return new CalendarHome(this, this._request, url, {}) + } + + /** + * Finds a previously discovered calendar home by URL. + * + * @param {string} calendarHomeUrl Absolute or relative URL of a calendar home + * @return {CalendarHome|null} + * @private + */ + _findCachedCalendarHome(calendarHomeUrl) { + const url = this._request.pathname(calendarHomeUrl).replace(/\/?$/, '/') + return this.calendarHomes.find((calendarHome) => calendarHome.url === url) || null + } + + /** + * 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 currentPrincipalUrl = this.currentUserPrincipal + ? this._request.absoluteUrl(this.currentUserPrincipal.url) + : null + const requestedPrincipalUrl = this._request.absoluteUrl(principalUrl) + + if (currentPrincipalUrl === requestedPrincipalUrl && this.calendarHomes.length > 0) { + return this._request.absoluteUrl(this.calendarHomes[0].url) + } + + 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 URL of a calendar proxy group for a given principal. + * + * @param {string} principalUrl Absolute URL of the principal + * @param {'write'|'read'} type The proxy group type + * @return {string} + * @private + */ + _getProxyGroupUrl(principalUrl, type) { + return principalUrl.replace(/\/?$/, '') + '/calendar-proxy-' + type + } + + /** + * Returns the absolute URLs of all delegates for a principal, separated by permission level. + * + * @param {string} principalUrl Absolute URL of the principal + * @return {Promise<{write: string[], read: string[]}>} Absolute principal URLs of delegates + */ + async getDelegatesForPrincipal(principalUrl) { + const [write, read] = await Promise.all([ + this.getGroupMemberSet(this._getProxyGroupUrl(principalUrl, 'write')), + this.getGroupMemberSet(this._getProxyGroupUrl(principalUrl, 'read')), + ]) + return { write, read } + } + + /** + * Adds a delegate to a principal's calendar-proxy group. + * + * Fetches the current member set and appends the new delegate if not already present. + * + * @param {string} ownerPrincipalUrl Absolute URL of the principal who owns the proxy group + * @param {string} delegatePrincipalUrl Absolute or relative URL of the principal to add as delegate + * @param {'write'|'read'} [permission='write'] The proxy group to add the delegate to + * @return {Promise} + */ + async addDelegate(ownerPrincipalUrl, delegatePrincipalUrl, permission = 'write') { + const proxyGroupUrl = this._getProxyGroupUrl(ownerPrincipalUrl, permission) + const normalizedUrl = this._request.absoluteUrl(delegatePrincipalUrl) + const current = await this.getGroupMemberSet(proxyGroupUrl) + if (!current.includes(normalizedUrl)) { + await this.setGroupMemberSet(proxyGroupUrl, [...current, normalizedUrl]) + } + } + + /** + * Removes a delegate from a principal's calendar-proxy group. + * + * @param {string} ownerPrincipalUrl Absolute URL of the principal who owns the proxy group + * @param {string} delegatePrincipalUrl Absolute or relative URL of the principal to remove + * @param {'write'|'read'} [permission='write'] The proxy group to remove the delegate from + * @return {Promise} + */ + async removeDelegate(ownerPrincipalUrl, delegatePrincipalUrl, permission = 'write') { + const proxyGroupUrl = this._getProxyGroupUrl(ownerPrincipalUrl, permission) + const normalizedUrl = this._request.absoluteUrl(delegatePrincipalUrl) + const current = await this.getGroupMemberSet(proxyGroupUrl) + await this.setGroupMemberSet(proxyGroupUrl, 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 diff --git a/test/unit/clientTest.js b/test/unit/clientTest.js index fbde7820..7d1d6342 100644 --- a/test/unit/clientTest.js +++ b/test/unit/clientTest.js @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest' import Client from '../../src/index.js' +import * as NS from '../../src/utility/namespaceUtility.js' describe('Client', () => { it('should extract advertised DAV features', () => { @@ -42,3 +43,65 @@ describe('Client', () => { ]) }) }) + +describe('calendar home helpers', () => { + it('reuses an existing calendar home instance for a known URL', async () => { + const client = new Client({ rootUrl: 'https://cloud.example.com/remote.php/dav/' }) + await client._extractCalendarHomes({ + [`{${NS.IETF_CALDAV}}calendar-home-set`]: ['/remote.php/dav/calendars/users/alice/'], + }) + + const calendarHome = client.getCalendarHomeForUrl('https://cloud.example.com/remote.php/dav/calendars/users/alice') + + expect(calendarHome).toBe(client.calendarHomes[0]) + }) + + it('creates a new calendar home instance for an unknown URL', async () => { + const client = new Client({ rootUrl: 'https://cloud.example.com/remote.php/dav/' }) + await client._extractCalendarHomes({ + [`{${NS.IETF_CALDAV}}calendar-home-set`]: ['/remote.php/dav/calendars/users/alice/'], + }) + + const calendarHome = client.getCalendarHomeForUrl('https://cloud.example.com/remote.php/dav/calendars/users/bob/') + + expect(calendarHome).not.toBe(client.calendarHomes[0]) + expect(calendarHome.url).toBe('/remote.php/dav/calendars/users/bob/') + }) + + it('returns the cached home URL for the current principal without a request', async () => { + const client = new Client({ rootUrl: 'https://cloud.example.com/remote.php/dav/' }) + await client._extractCalendarHomes({ + [`{${NS.IETF_CALDAV}}calendar-home-set`]: ['/remote.php/dav/calendars/users/alice/'], + }) + client.currentUserPrincipal = { url: '/remote.php/dav/principals/users/alice/' } + const propFindSpy = vi.spyOn(client._request, 'propFind') + + const calendarHomeUrl = await client.getCalendarHomeUrlForPrincipal('https://cloud.example.com/remote.php/dav/principals/users/alice/') + + expect(calendarHomeUrl).toBe('https://cloud.example.com/remote.php/dav/calendars/users/alice/') + expect(propFindSpy).not.toHaveBeenCalled() + }) + + it('falls back to PROPFIND for a different principal', async () => { + const client = new Client({ rootUrl: 'https://cloud.example.com/remote.php/dav/' }) + client.currentUserPrincipal = { url: '/remote.php/dav/principals/users/alice/' } + const propFindSpy = vi.spyOn(client._request, 'propFind').mockResolvedValue({ + body: { + [`{${NS.IETF_CALDAV}}calendar-home-set`]: ['/remote.php/dav/calendars/users/bob/'], + }, + }) + + const calendarHomeUrl = await client.getCalendarHomeUrlForPrincipal('https://cloud.example.com/remote.php/dav/principals/users/bob/') + + expect(propFindSpy).toHaveBeenCalledOnce() + expect(calendarHomeUrl).toBe('https://cloud.example.com/remote.php/dav/calendars/users/bob/') + }) + + it('returns null when calendar-home-set is missing', async () => { + const client = new Client({ rootUrl: 'https://cloud.example.com/remote.php/dav/' }) + client.currentUserPrincipal = { url: '/remote.php/dav/principals/users/alice/' } + vi.spyOn(client._request, 'propFind').mockResolvedValue({ body: {} }) + + await expect(client.getCalendarHomeUrlForPrincipal('https://cloud.example.com/remote.php/dav/principals/users/bob/')).resolves.toBeNull() + }) +})