Skip to content

Commit 32122eb

Browse files
committed
feat: add calendar delegation
Signed-off-by: Grigory Vodyanov <scratchx@gmx.com>
1 parent 47b6577 commit 32122eb

2 files changed

Lines changed: 292 additions & 0 deletions

File tree

src/index.js

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,235 @@ export default class DavClient {
485485
return this._request.pathname(response.body['{DAV:}current-user-principal'].href)
486486
}
487487

488+
/**
489+
* Creates a CalendarHome instance for an arbitrary calendar home URL.
490+
*
491+
* This can be used to access calendars that are not in the current user's
492+
* own calendar home – for example when acting as a calendar proxy (delegate)
493+
* for another user.
494+
*
495+
* @param {string} calendarHomeUrl Absolute URL of the calendar home
496+
* @return {CalendarHome}
497+
*/
498+
getCalendarHomeForUrl(calendarHomeUrl) {
499+
const cachedCalendarHome = this._findCachedCalendarHome(calendarHomeUrl)
500+
if (cachedCalendarHome) {
501+
return cachedCalendarHome
502+
}
503+
504+
const url = this._request.pathname(calendarHomeUrl)
505+
return new CalendarHome(this, this._request, url, {})
506+
}
507+
508+
/**
509+
* Finds a previously discovered calendar home by URL.
510+
*
511+
* @param {string} calendarHomeUrl Absolute or relative URL of a calendar home
512+
* @return {CalendarHome|null}
513+
* @private
514+
*/
515+
_findCachedCalendarHome(calendarHomeUrl) {
516+
const url = this._request.pathname(calendarHomeUrl).replace(/\/?$/, '/')
517+
return this.calendarHomes.find((calendarHome) => calendarHome.url === url) || null
518+
}
519+
520+
/**
521+
* Fetches the group-member-set of a principal collection (e.g. a calendar-proxy group).
522+
* @see https://tools.ietf.org/html/rfc3744#section-4.3
523+
*
524+
* @param {string} groupUrl Absolute URL of the proxy group principal
525+
* @return {Promise<string[]>} Absolute URLs of member principals
526+
*/
527+
async getGroupMemberSet(groupUrl) {
528+
const { body } = await this._request.propFind(groupUrl, [
529+
[NS.DAV, 'group-member-set'],
530+
])
531+
const members = body[`{${NS.DAV}}group-member-set`] ?? []
532+
return members.map((href) => this._request.absoluteUrl(href))
533+
}
534+
535+
/**
536+
* Sets the group-member-set of a principal collection (e.g. a calendar-proxy group).
537+
* @see https://tools.ietf.org/html/rfc3744#section-4.3
538+
*
539+
* @param {string} groupUrl Absolute URL of the proxy group principal
540+
* @param {string[]} memberUrls Absolute URLs of the new member set
541+
* @return {Promise<void>}
542+
*/
543+
async setGroupMemberSet(groupUrl, memberUrls) {
544+
const [skeleton] = XMLUtility.getRootSkeleton([NS.DAV, 'propertyupdate'])
545+
skeleton.children.push({
546+
name: [NS.DAV, 'set'],
547+
children: [{
548+
name: [NS.DAV, 'prop'],
549+
children: [{
550+
name: [NS.DAV, 'group-member-set'],
551+
children: memberUrls.map((url) => ({
552+
name: [NS.DAV, 'href'],
553+
value: url,
554+
})),
555+
}],
556+
}],
557+
})
558+
const body = XMLUtility.serialize(skeleton)
559+
await this._request.propPatch(groupUrl, {}, body)
560+
}
561+
562+
/**
563+
* Fetches the group-membership of a principal (the groups it belongs to).
564+
* @see https://tools.ietf.org/html/rfc3744#section-4.4
565+
*
566+
* @param {string} principalUrl Absolute URL of the principal
567+
* @return {Promise<string[]>} Absolute URLs of groups the principal belongs to
568+
*/
569+
async getGroupMembership(principalUrl) {
570+
const { body } = await this._request.propFind(principalUrl, [
571+
[NS.DAV, 'group-membership'],
572+
])
573+
const groups = body[`{${NS.DAV}}group-membership`] ?? []
574+
return groups.map((href) => this._request.absoluteUrl(href))
575+
}
576+
577+
/**
578+
* Discovers the calendar home URL for a principal via CalDAV PROPFIND.
579+
*
580+
* Performs a depth-0 PROPFIND on the principal URL requesting the
581+
* calendar-home-set property (RFC 4791 §6.2.1).
582+
*
583+
* @param {string} principalUrl Absolute URL of the principal
584+
* @return {Promise<string|null>} Absolute URL of the calendar home, or null if not found
585+
*/
586+
async getCalendarHomeUrlForPrincipal(principalUrl) {
587+
const currentPrincipalUrl = this.currentUserPrincipal
588+
? this._request.absoluteUrl(this.currentUserPrincipal.url)
589+
: null
590+
const requestedPrincipalUrl = this._request.absoluteUrl(principalUrl)
591+
592+
if (currentPrincipalUrl === requestedPrincipalUrl && this.calendarHomes.length > 0) {
593+
return this._request.absoluteUrl(this.calendarHomes[0].url)
594+
}
595+
596+
const { body } = await this._request.propFind(principalUrl, [
597+
[NS.IETF_CALDAV, 'calendar-home-set'],
598+
])
599+
const homes = body[`{${NS.IETF_CALDAV}}calendar-home-set`]
600+
if (!homes || !homes.length) {
601+
return null
602+
}
603+
return this._request.absoluteUrl(homes[0])
604+
}
605+
606+
/**
607+
* Returns the absolute URL of a calendar proxy group for a given principal.
608+
*
609+
* @param {string} principalUrl Absolute URL of the principal
610+
* @param {'write'|'read'} type The proxy group type
611+
* @return {string}
612+
* @private
613+
*/
614+
_getProxyGroupUrl(principalUrl, type) {
615+
return principalUrl.replace(/\/?$/, '') + '/calendar-proxy-' + type
616+
}
617+
618+
/**
619+
* Returns the absolute URLs of all delegates for a principal, separated by permission level.
620+
*
621+
* @param {string} principalUrl Absolute URL of the principal
622+
* @return {Promise<{write: string[], read: string[]}>} Absolute principal URLs of delegates
623+
*/
624+
async getDelegatesForPrincipal(principalUrl) {
625+
const [write, read] = await Promise.all([
626+
this.getGroupMemberSet(this._getProxyGroupUrl(principalUrl, 'write')),
627+
this.getGroupMemberSet(this._getProxyGroupUrl(principalUrl, 'read')),
628+
])
629+
return { write, read }
630+
}
631+
632+
/**
633+
* Adds a delegate to a principal's calendar-proxy group.
634+
*
635+
* Fetches the current member set and appends the new delegate if not already present.
636+
*
637+
* @param {string} ownerPrincipalUrl Absolute URL of the principal who owns the proxy group
638+
* @param {string} delegatePrincipalUrl Absolute or relative URL of the principal to add as delegate
639+
* @param {'write'|'read'} [permission='write'] The proxy group to add the delegate to
640+
* @return {Promise<void>}
641+
*/
642+
async addDelegate(ownerPrincipalUrl, delegatePrincipalUrl, permission = 'write') {
643+
const proxyGroupUrl = this._getProxyGroupUrl(ownerPrincipalUrl, permission)
644+
const normalizedUrl = this._request.absoluteUrl(delegatePrincipalUrl)
645+
const current = await this.getGroupMemberSet(proxyGroupUrl)
646+
if (!current.includes(normalizedUrl)) {
647+
await this.setGroupMemberSet(proxyGroupUrl, [...current, normalizedUrl])
648+
}
649+
}
650+
651+
/**
652+
* Removes a delegate from a principal's calendar-proxy group.
653+
*
654+
* @param {string} ownerPrincipalUrl Absolute URL of the principal who owns the proxy group
655+
* @param {string} delegatePrincipalUrl Absolute or relative URL of the principal to remove
656+
* @param {'write'|'read'} [permission='write'] The proxy group to remove the delegate from
657+
* @return {Promise<void>}
658+
*/
659+
async removeDelegate(ownerPrincipalUrl, delegatePrincipalUrl, permission = 'write') {
660+
const proxyGroupUrl = this._getProxyGroupUrl(ownerPrincipalUrl, permission)
661+
const normalizedUrl = this._request.absoluteUrl(delegatePrincipalUrl)
662+
const current = await this.getGroupMemberSet(proxyGroupUrl)
663+
await this.setGroupMemberSet(proxyGroupUrl, current.filter((url) => url !== normalizedUrl))
664+
}
665+
666+
/**
667+
* Returns the principal URLs of users who have granted the given principal
668+
* write-proxy (delegate) access.
669+
*
670+
* Inspects the group-membership property for groups ending in
671+
* /calendar-proxy-write and strips that suffix to obtain the owner's
672+
* principal URL.
673+
*
674+
* @param {string} principalUrl Absolute URL of the principal
675+
* @return {Promise<string[]>} Absolute principal URLs of users who delegated to this principal
676+
*/
677+
async getDelegatorPrincipalUrls(principalUrl) {
678+
const groups = await this.getGroupMembership(principalUrl)
679+
return groups
680+
.filter((url) => url.includes('calendar-proxy-write'))
681+
.map((url) => url.replace(/\/calendar-proxy-write\/?$/, '') || null)
682+
.filter(Boolean)
683+
}
684+
685+
/**
686+
* Returns the principal URLs and permission level of users who have granted
687+
* the given principal proxy access (both read and write).
688+
*
689+
* Inspects the group-membership property for groups ending in
690+
* /calendar-proxy-write or /calendar-proxy-read and returns objects with
691+
* the owner's principal URL and the permission granted.
692+
*
693+
* @param {string} principalUrl Absolute URL of the principal
694+
* @return {Promise<Array<{principalUrl: string, permission: 'write'|'read'}>>}
695+
*/
696+
async getDelegatorsWithPermission(principalUrl) {
697+
const groups = await this.getGroupMembership(principalUrl)
698+
const result = []
699+
700+
for (const groupUrl of groups) {
701+
if (groupUrl.includes('calendar-proxy-write')) {
702+
const ownerUrl = groupUrl.replace(/\/calendar-proxy-write\/?$/, '')
703+
if (ownerUrl) {
704+
result.push({ principalUrl: ownerUrl, permission: 'write' })
705+
}
706+
} else if (groupUrl.includes('calendar-proxy-read')) {
707+
const ownerUrl = groupUrl.replace(/\/calendar-proxy-read\/?$/, '')
708+
if (ownerUrl) {
709+
result.push({ principalUrl: ownerUrl, permission: 'read' })
710+
}
711+
}
712+
}
713+
714+
return result
715+
}
716+
488717
/**
489718
* discovers all calendar-homes in this account, all principal collections
490719
* and advertised features

test/unit/clientTest.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { describe, expect, it, vi } from 'vitest'
77

88
import Client from '../../src/index.js'
9+
import * as NS from '../../src/utility/namespaceUtility.js'
910

1011
describe('Client', () => {
1112
it('should extract advertised DAV features', () => {
@@ -42,3 +43,65 @@ describe('Client', () => {
4243
])
4344
})
4445
})
46+
47+
describe('calendar home helpers', () => {
48+
it('reuses an existing calendar home instance for a known URL', async () => {
49+
const client = new Client({ rootUrl: 'https://cloud.example.com/remote.php/dav/' })
50+
await client._extractCalendarHomes({
51+
[`{${NS.IETF_CALDAV}}calendar-home-set`]: ['/remote.php/dav/calendars/users/alice/'],
52+
})
53+
54+
const calendarHome = client.getCalendarHomeForUrl('https://cloud.example.com/remote.php/dav/calendars/users/alice')
55+
56+
expect(calendarHome).toBe(client.calendarHomes[0])
57+
})
58+
59+
it('creates a new calendar home instance for an unknown URL', async () => {
60+
const client = new Client({ rootUrl: 'https://cloud.example.com/remote.php/dav/' })
61+
await client._extractCalendarHomes({
62+
[`{${NS.IETF_CALDAV}}calendar-home-set`]: ['/remote.php/dav/calendars/users/alice/'],
63+
})
64+
65+
const calendarHome = client.getCalendarHomeForUrl('https://cloud.example.com/remote.php/dav/calendars/users/bob/')
66+
67+
expect(calendarHome).not.toBe(client.calendarHomes[0])
68+
expect(calendarHome.url).toBe('/remote.php/dav/calendars/users/bob/')
69+
})
70+
71+
it('returns the cached home URL for the current principal without a request', async () => {
72+
const client = new Client({ rootUrl: 'https://cloud.example.com/remote.php/dav/' })
73+
await client._extractCalendarHomes({
74+
[`{${NS.IETF_CALDAV}}calendar-home-set`]: ['/remote.php/dav/calendars/users/alice/'],
75+
})
76+
client.currentUserPrincipal = { url: '/remote.php/dav/principals/users/alice/' }
77+
const propFindSpy = vi.spyOn(client._request, 'propFind')
78+
79+
const calendarHomeUrl = await client.getCalendarHomeUrlForPrincipal('https://cloud.example.com/remote.php/dav/principals/users/alice/')
80+
81+
expect(calendarHomeUrl).toBe('https://cloud.example.com/remote.php/dav/calendars/users/alice/')
82+
expect(propFindSpy).not.toHaveBeenCalled()
83+
})
84+
85+
it('falls back to PROPFIND for a different principal', async () => {
86+
const client = new Client({ rootUrl: 'https://cloud.example.com/remote.php/dav/' })
87+
client.currentUserPrincipal = { url: '/remote.php/dav/principals/users/alice/' }
88+
const propFindSpy = vi.spyOn(client._request, 'propFind').mockResolvedValue({
89+
body: {
90+
[`{${NS.IETF_CALDAV}}calendar-home-set`]: ['/remote.php/dav/calendars/users/bob/'],
91+
},
92+
})
93+
94+
const calendarHomeUrl = await client.getCalendarHomeUrlForPrincipal('https://cloud.example.com/remote.php/dav/principals/users/bob/')
95+
96+
expect(propFindSpy).toHaveBeenCalledOnce()
97+
expect(calendarHomeUrl).toBe('https://cloud.example.com/remote.php/dav/calendars/users/bob/')
98+
})
99+
100+
it('returns null when calendar-home-set is missing', async () => {
101+
const client = new Client({ rootUrl: 'https://cloud.example.com/remote.php/dav/' })
102+
client.currentUserPrincipal = { url: '/remote.php/dav/principals/users/alice/' }
103+
vi.spyOn(client._request, 'propFind').mockResolvedValue({ body: {} })
104+
105+
await expect(client.getCalendarHomeUrlForPrincipal('https://cloud.example.com/remote.php/dav/principals/users/bob/')).resolves.toBeNull()
106+
})
107+
})

0 commit comments

Comments
 (0)