diff --git a/apps/dav/lib/AppInfo/Application.php b/apps/dav/lib/AppInfo/Application.php index 1f5c4fdba0e1f..ce887e96de707 100644 --- a/apps/dav/lib/AppInfo/Application.php +++ b/apps/dav/lib/AppInfo/Application.php @@ -250,6 +250,7 @@ public function register(IRegistrationContext $context): void { #[\Override] public function boot(IBootContext $context): void { VObject\Component\VCalendar::$propertyMap[TipBroker::INVITATION_FORWARDING_PROPERTY] = VObject\Property\Boolean::class; + VObject\Component\VCalendar::$propertyMap[TipBroker::ALLOW_ATTENDEE_GUESTS_PROPERTY] = VObject\Property\Boolean::class; // Load all dav apps $context->getServerContainer()->get(IAppManager::class)->loadApps(['dav']); diff --git a/apps/dav/lib/CalDAV/TipBroker.php b/apps/dav/lib/CalDAV/TipBroker.php index e83111330328b..9b7ad70aeb53f 100644 --- a/apps/dav/lib/CalDAV/TipBroker.php +++ b/apps/dav/lib/CalDAV/TipBroker.php @@ -23,6 +23,8 @@ class TipBroker extends Broker { public const INVITATION_FORWARDING_PROPERTY = 'X-NC-INVITATION-FORWARDING'; + public const ALLOW_ATTENDEE_GUESTS_PROPERTY = 'X-NC-ALLOW-ATTENDEE-GUESTS'; + public const ADD_GUEST_PROPERTY = 'X-NC-ADD-GUEST'; public $significantChangeProperties = [ 'DTSTART', @@ -95,6 +97,7 @@ protected function processMessageReply(Message $itipMessage, ?VCalendar $existin return null; } $instances = []; + $guests = []; $requestStatus = '2.0'; /** @var list $vevents */ @@ -115,6 +118,7 @@ protected function processMessageReply(Message $itipMessage, ?VCalendar $existin continue; } $instances[$recurId] = $partstat->getValue(); + $guests[$recurId] = $this->getGuestsFromReply($vevent); if (isset($vevent->{'REQUEST-STATUS'})) { $requestStatus = $vevent->{'REQUEST-STATUS'}->getValue(); [$requestStatus] = explode(';', $requestStatus); @@ -142,9 +146,12 @@ protected function processMessageReply(Message $itipMessage, ?VCalendar $existin $attendeeFound = true; $attendee['PARTSTAT'] = $instances[$recurId]; $attendee['SCHEDULE-STATUS'] = $requestStatus; - // Un-setting the RSVP status, because we now know - // that the attendee already replied. - unset($attendee['RSVP']); + if ($instances[$recurId] !== 'NEEDS-ACTION') { + // A reply leaving the attendee at NEEDS-ACTION, for + // example one only forwarding the invitation, is not + // an answer, so keep asking for one. + unset($attendee['RSVP']); + } break; } } @@ -159,6 +166,10 @@ protected function processMessageReply(Message $itipMessage, ?VCalendar $existin $parameters['CN'] = $itipMessage->senderName; } $vevent->add('ATTENDEE', $itipMessage->sender, $parameters); + } elseif ($attendeeFound && $this->allowAttendeeGuests($vevent)) { + // Only guests of a known attendee are invited, otherwise a + // party crasher could bring others along. + $this->addGuests($vevent, $guests[$recurId] ?? []); } unset($instances[$recurId]); } @@ -203,7 +214,10 @@ protected function processMessageReply(Message $itipMessage, ?VCalendar $existin $attendeeFound = true; $attendee['PARTSTAT'] = $partstat; $attendee['SCHEDULE-STATUS'] = $requestStatus; - unset($attendee['RSVP']); + if ($partstat !== 'NEEDS-ACTION') { + // Not an answer yet, so keep asking for one. + unset($attendee['RSVP']); + } break; } } @@ -252,16 +266,207 @@ protected function getFirstAttendee(VEvent $vevent): ?CalAddress { return null; } + /** + * Collects the guests a replying attendee added to the event. + * + * @return array Calendar user address => common name + */ + protected function getGuestsFromReply(VEvent $vevent): array { + $guests = []; + /** @var list $properties */ + $properties = $vevent->select(self::ADD_GUEST_PROPERTY); + foreach ($properties as $property) { + $href = $property->getValue(); + if ($href === null || $href === '') { + continue; + } + $commonName = $property->offsetGet('CN'); + $guests[$href] = $commonName instanceof Parameter ? $commonName->getValue() : null; + } + + return $guests; + } + + /** + * Adds guests that are not part of the event yet as attendees. + * + * @param array $guests Calendar user address => common name + */ + protected function addGuests(VEvent $vevent, array $guests): void { + $known = []; + /** @var list $properties */ + $properties = $vevent->select('ATTENDEE'); + foreach ($properties as $property) { + if ($property instanceof CalAddress) { + $known[strtolower($property->getNormalizedValue())] = true; + } + } + $organizer = $this->getOrganizerHref($vevent); + if ($organizer !== null) { + $known[strtolower($organizer)] = true; + } + + foreach ($guests as $href => $commonName) { + if (isset($known[strtolower($href)])) { + continue; + } + $parameters = [ + 'PARTSTAT' => 'NEEDS-ACTION', + 'RSVP' => 'TRUE', + ]; + if ($commonName !== null) { + $parameters['CN'] = $commonName; + } + $vevent->add('ATTENDEE', $href, $parameters); + } + } + + protected function getOrganizerHref(VEvent $vevent): ?string { + /** @var list $properties */ + $properties = $vevent->select('ORGANIZER'); + foreach ($properties as $property) { + if ($property instanceof CalAddress) { + return $property->getNormalizedValue(); + } + } + return null; + } + + /** + * Unlike forwarding, this has to be turned on by the organizer, so it is + * also absent on invitations we could not report guests back to. + */ + protected function allowAttendeeGuests(VEvent $vevent): bool { + $properties = $vevent->select(self::ALLOW_ATTENDEE_GUESTS_PROPERTY); + foreach ($properties as $property) { + if ($property instanceof Boolean) { + return $property->getValue() === true; + } + } + return false; + } + protected function allowInvitationForwarding(VEvent $vevent): bool { $properties = $vevent->select(self::INVITATION_FORWARDING_PROPERTY); foreach ($properties as $property) { if ($property instanceof Boolean) { - return $property->getValue() === 'TRUE'; + return $property->getValue() === true; } } return true; } + /** + * On top of the participation status handled by the parent, an attendee may + * add guests to an event they were invited to. Those go out as a separate + * reply, from which the organizer invites them. + * + * @param string $attendee + * + * @return array + */ + #[\Override] + protected function parseEventForAttendee(VCalendar $calendar, array $eventInfo, array $oldEventInfo, $attendee) { + $messages = parent::parseEventForAttendee($calendar, $eventInfo, $oldEventInfo, $attendee); + + $guests = $this->extractAddedGuests($eventInfo, $oldEventInfo, $attendee); + if ($guests !== []) { + $messages[] = $this->generateGuestReply($eventInfo, $attendee, $guests); + } + + return $messages; + } + + /** + * Collects the guests an attendee added to their own copy of an event. + * + * Only guests of the base instance are picked up, guests on a recurrence + * exception are ignored. The calendar app therefore does not offer this + * for recurring events. + * + * @return array Calendar user address => common name + */ + protected function extractAddedGuests(array $eventInfo, array $oldEventInfo, string $attendee): array { + $master = $eventInfo['instances']['master'] ?? null; + if (!$master instanceof VEvent || !$this->allowAttendeeGuests($master)) { + return []; + } + + $guests = []; + foreach ($eventInfo['attendees'] as $href => $info) { + if ($href === $attendee || $href === $eventInfo['organizer']) { + continue; + } + // Already invited, so not a guest of this attendee + if (isset($oldEventInfo['attendees'][$href])) { + continue; + } + // Added to a recurrence exception instead of the whole event + if (!isset($info['instances']['master'])) { + continue; + } + $guests[$href] = $info['name']; + } + + return $guests; + } + + /** + * Generates a reply carrying the guests an attendee added to the event. + * + * The organizer invites them from there. + * + * @param array $guests Calendar user address => common name + */ + protected function generateGuestReply(array $eventInfo, string $attendee, array $guests): Message { + // A reply only carries scheduling information, the organizer already + // knows the event itself. + $calendar = new VCalendar(); + $calendar->add('METHOD', 'REPLY'); + /** @var VEvent $vevent */ + $vevent = $calendar->add('VEVENT', [ + 'UID' => $eventInfo['uid'], + 'SEQUENCE' => $eventInfo['sequence'], + ]); + $organizerParameters = []; + if ($eventInfo['organizerName']) { + $organizerParameters['CN'] = $eventInfo['organizerName']; + } + $vevent->add('ORGANIZER', $eventInfo['organizer'], $organizerParameters); + + $attendeeParameters = [ + 'PARTSTAT' => $eventInfo['attendees'][$attendee]['instances']['master']['partstat'] ?? 'NEEDS-ACTION', + ]; + if ($eventInfo['attendees'][$attendee]['name']) { + $attendeeParameters['CN'] = $eventInfo['attendees'][$attendee]['name']; + } + $vevent->add('ATTENDEE', $attendee, $attendeeParameters); + + foreach ($guests as $href => $commonName) { + $guestParameters = []; + if ($commonName) { + $guestParameters['CN'] = $commonName; + } + $vevent->add(self::ADD_GUEST_PROPERTY, $href, $guestParameters); + } + + $message = new Message(); + $message->uid = $eventInfo['uid']; + $message->method = 'REPLY'; + $message->component = 'VEVENT'; + $message->sequence = $eventInfo['sequence']; + $message->sender = $attendee; + $message->senderName = $eventInfo['attendees'][$attendee]['name']; + $message->recipient = $eventInfo['organizer']; + $message->recipientName = $eventInfo['organizerName']; + // The participation status did not necessarily change, so an email about + // this reply would be misleading. + $message->significantChange = false; + $message->message = $calendar; + + return $message; + } + /** * This method is used in cases where an event got updated, and we * potentially need to send emails to attendees to let them know of updates diff --git a/apps/dav/lib/Capabilities.php b/apps/dav/lib/Capabilities.php index e710ea3b3d1b1..8d951eac41637 100644 --- a/apps/dav/lib/Capabilities.php +++ b/apps/dav/lib/Capabilities.php @@ -21,7 +21,7 @@ public function __construct( } /** - * @return array{dav: array{chunking: string, public_shares_chunking: bool, search_supports_creation_time: bool, search_supports_upload_time: bool, search_supports_last_activity: bool, bulkupload?: string, absence-supported?: bool, absence-replacement?: bool}} + * @return array{dav: array{chunking: string, public_shares_chunking: bool, search_supports_creation_time: bool, search_supports_upload_time: bool, search_supports_last_activity: bool, attendee_guests: bool, bulkupload?: string, absence-supported?: bool, absence-replacement?: bool}} */ #[\Override] public function getCapabilities() { @@ -32,6 +32,8 @@ public function getCapabilities() { 'search_supports_creation_time' => true, 'search_supports_upload_time' => true, 'search_supports_last_activity' => true, + // An attendee can add guests, which the server reports to the organizer + 'attendee_guests' => true, ] ]; if ($this->config->getSystemValueBool('bulkupload.enabled', true)) { diff --git a/apps/dav/openapi.json b/apps/dav/openapi.json index e69d5be01d9a0..9e70980b56f79 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -33,7 +33,8 @@ "public_shares_chunking", "search_supports_creation_time", "search_supports_upload_time", - "search_supports_last_activity" + "search_supports_last_activity", + "attendee_guests" ], "properties": { "chunking": { @@ -51,6 +52,9 @@ "search_supports_last_activity": { "type": "boolean" }, + "attendee_guests": { + "type": "boolean" + }, "bulkupload": { "type": "string" }, diff --git a/apps/dav/tests/unit/CalDAV/TipBrokerTest.php b/apps/dav/tests/unit/CalDAV/TipBrokerTest.php index 0af957823787b..15c7ad3fbb2e5 100644 --- a/apps/dav/tests/unit/CalDAV/TipBrokerTest.php +++ b/apps/dav/tests/unit/CalDAV/TipBrokerTest.php @@ -25,6 +25,7 @@ protected function setUp(): void { parent::setUp(); VCalendar::$propertyMap[TipBroker::INVITATION_FORWARDING_PROPERTY] = VObject\Property\Boolean::class; + VCalendar::$propertyMap[TipBroker::ALLOW_ATTENDEE_GUESTS_PROPERTY] = VObject\Property\Boolean::class; $this->broker = new TipBroker(); @@ -81,7 +82,10 @@ protected function setUp(): void { } protected function tearDown(): void { - unset(VCalendar::$propertyMap[TipBroker::INVITATION_FORWARDING_PROPERTY]); + unset( + VCalendar::$propertyMap[TipBroker::INVITATION_FORWARDING_PROPERTY], + VCalendar::$propertyMap[TipBroker::ALLOW_ATTENDEE_GUESTS_PROPERTY], + ); parent::tearDown(); } @@ -591,7 +595,7 @@ public function testParseEventForOrganizerScheduleForceSend(): void { public function testProcessMessageReplyDisallowsInvitationForwarding(): void { $existingCalendar = clone $this->vCalendar1a; - $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, false); $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); $reply = new Message(); $reply->uid = $existingCalendar->VEVENT->UID->getValue(); @@ -616,7 +620,7 @@ public function testProcessMessageReplyDisallowsInvitationForwarding(): void { public function testProcessMessageReplyUpdatesExistingAttendeeWhenInvitationForwardingDisabled(): void { $existingCalendar = clone $this->vCalendar1a; - $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, false); $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); $reply = new Message(); $reply->uid = $existingCalendar->VEVENT->UID->getValue(); @@ -645,7 +649,7 @@ public function testProcessMessageReplyUpdatesExistingAttendeeWhenInvitationForw public function testProcessMessageReplyAllowsInvitationForwarding(): void { $existingCalendar = clone $this->vCalendar1a; - $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE'); + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, true); $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); $reply = new Message(); $reply->uid = $existingCalendar->VEVENT->UID->getValue(); @@ -741,7 +745,7 @@ public function testProcessMessageReplyIgnoresReplyWithoutPartstat(): void { public function testProcessMessageReplyDisallowsInvitationForwardingForGeneratedRecurringInstance(): void { $existingCalendar = clone $this->vCalendar2a; - $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, false); $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); $reply = new Message(); $reply->uid = $existingCalendar->VEVENT->UID->getValue(); @@ -768,7 +772,7 @@ public function testProcessMessageReplyDisallowsInvitationForwardingForGenerated public function testProcessMessageReplyUpdatesExistingAttendeeForGeneratedRecurringInstanceWhenInvitationForwardingDisabled(): void { $existingCalendar = clone $this->vCalendar2a; - $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, false); $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); $reply = new Message(); $reply->uid = $existingCalendar->VEVENT->UID->getValue(); @@ -798,9 +802,36 @@ public function testProcessMessageReplyUpdatesExistingAttendeeForGeneratedRecurr $this->assertFalse(isset($result->VEVENT[1]->ATTENDEE[0]['RSVP'])); } + public function testProcessMessageReplyKeepsRsvpForGeneratedRecurringInstanceWithoutAnswer(): void { + $existingCalendar = clone $this->vCalendar2a; + $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = 'mailto:attendee1@example.org'; + $reply->senderName = 'Attendee One'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('RECURRENCE-ID', '20240715T080000', ['TZID' => 'America/Toronto']); + $replyEvent->add('ATTENDEE', $reply->sender, [ + 'PARTSTAT' => 'NEEDS-ACTION', + ]); + $replyEvent->add('REQUEST-STATUS', '2.0;Success'); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertCount(2, $result->VEVENT); + $this->assertEquals('20240715T080000', $result->VEVENT[1]->{'RECURRENCE-ID'}->getValue()); + $this->assertEquals('NEEDS-ACTION', $result->VEVENT[1]->ATTENDEE[0]['PARTSTAT']->getValue()); + $this->assertTrue(isset($result->VEVENT[1]->ATTENDEE[0]['RSVP'])); + } + public function testProcessMessageReplyAllowsInvitationForwardingForDetachedRecurringExceptionWhenMasterDisallows(): void { $existingCalendar = clone $this->vCalendar2a; - $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, false); $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); /** @var \Sabre\VObject\Component\VEvent $detachedInstance */ $detachedInstance = $existingCalendar->add('VEVENT', []); @@ -811,7 +842,7 @@ public function testProcessMessageReplyAllowsInvitationForwardingForDetachedRecu $detachedInstance->add('DTEND', '20240715T090000', ['TZID' => 'America/Toronto']); $detachedInstance->add('SUMMARY', 'Detached Test Event'); $detachedInstance->add('ORGANIZER', 'mailto:organizer@example.org', ['CN' => 'Organizer']); - $detachedInstance->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE'); + $detachedInstance->add(TipBroker::INVITATION_FORWARDING_PROPERTY, true); $detachedInstance->add('ATTENDEE', 'mailto:attendee1@example.org', [ 'CN' => 'Attendee One', 'CUTYPE' => 'INDIVIDUAL', @@ -845,7 +876,7 @@ public function testProcessMessageReplyAllowsInvitationForwardingForDetachedRecu public function testProcessMessageReplyDisallowsInvitationForwardingForDetachedRecurringExceptionWhenMasterAllows(): void { $existingCalendar = clone $this->vCalendar2a; - $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE'); + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, true); $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); /** @var \Sabre\VObject\Component\VEvent $detachedInstance */ $detachedInstance = $existingCalendar->add('VEVENT', []); @@ -856,7 +887,7 @@ public function testProcessMessageReplyDisallowsInvitationForwardingForDetachedR $detachedInstance->add('DTEND', '20240715T090000', ['TZID' => 'America/Toronto']); $detachedInstance->add('SUMMARY', 'Detached Test Event'); $detachedInstance->add('ORGANIZER', 'mailto:organizer@example.org', ['CN' => 'Organizer']); - $detachedInstance->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'FALSE'); + $detachedInstance->add(TipBroker::INVITATION_FORWARDING_PROPERTY, false); $detachedInstance->add('ATTENDEE', 'mailto:attendee1@example.org', [ 'CN' => 'Attendee One', 'CUTYPE' => 'INDIVIDUAL', @@ -890,7 +921,7 @@ public function testProcessMessageReplyDisallowsInvitationForwardingForDetachedR public function testProcessMessageReplyAllowsInvitationForwardingForGeneratedRecurringInstance(): void { $existingCalendar = clone $this->vCalendar2a; - $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, 'TRUE'); + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, true); $existingCalendar->VEVENT->ATTENDEE[0]->setValue('mailto:attendee1@example.org'); $reply = new Message(); $reply->uid = $existingCalendar->VEVENT->UID->getValue(); @@ -949,4 +980,197 @@ public function testProcessMessageReplyAllowsInvitationForwardingByDefaultForGen $this->assertEquals('Attendee Two', $result->VEVENT[1]->ATTENDEE[1]['CN']->getValue()); } + /** + * Builds a reply from an attendee that also carries a guest they added + */ + private function buildReplyWithGuest(VCalendar $existingCalendar, string $sender): Message { + $reply = new Message(); + $reply->uid = $existingCalendar->VEVENT->UID->getValue(); + $reply->component = 'VEVENT'; + $reply->sender = $sender; + $reply->senderName = 'Attendee One'; + $reply->sequence = 1; + $reply->message = new VCalendar(); + /** @var \Sabre\VObject\Component\VEvent $replyEvent */ + $replyEvent = $reply->message->add('VEVENT', []); + $replyEvent->add('UID', $reply->uid); + $replyEvent->add('ORGANIZER', 'mailto:organizer@example.org'); + $replyEvent->add('ATTENDEE', $sender, [ + 'PARTSTAT' => 'ACCEPTED', + ]); + $replyEvent->add(TipBroker::ADD_GUEST_PROPERTY, 'mailto:guest@example.org', [ + 'CN' => 'Guest', + ]); + + return $reply; + } + + public function testParseEventForAttendeeReportsAddedGuest(): void { + $originalCalendar = clone $this->vCalendar1a; + $originalCalendar->VEVENT->add(TipBroker::ALLOW_ATTENDEE_GUESTS_PROPERTY, true); + $mutatedCalendar = clone $originalCalendar; + $mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:guest@example.org', [ + 'CN' => 'Guest', + 'PARTSTAT' => 'NEEDS-ACTION', + ]); + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + + $messages = $this->invokePrivate($this->broker, 'parseEventForAttendee', [ + $mutatedCalendar, $mutatedEventInfo, $originalEventInfo, 'mailto:attendee1@example.org', + ]); + + $this->assertCount(1, $messages); + $this->assertEquals('REPLY', $messages[0]->method); + $this->assertEquals('mailto:attendee1@example.org', $messages[0]->sender); + $this->assertEquals('mailto:organizer@example.org', $messages[0]->recipient); + // The participation status did not change, so no email should be sent + $this->assertFalse($messages[0]->significantChange); + // RFC 5546 allows exactly one attendee in a reply, so the guest travels + // as an extension property + $this->assertCount(1, $messages[0]->message->VEVENT->ATTENDEE); + $this->assertEquals('mailto:attendee1@example.org', $messages[0]->message->VEVENT->ATTENDEE[0]->getValue()); + $this->assertEquals('NEEDS-ACTION', $messages[0]->message->VEVENT->ATTENDEE[0]['PARTSTAT']->getValue()); + $guests = $messages[0]->message->VEVENT->{TipBroker::ADD_GUEST_PROPERTY}; + $this->assertCount(1, $guests); + $this->assertEquals('mailto:guest@example.org', $guests[0]->getValue()); + $this->assertEquals('Guest', $guests[0]['CN']->getValue()); + } + + public function testParseEventForAttendeeReportsAddedGuestAlongsideParticipationStatus(): void { + $originalCalendar = clone $this->vCalendar1a; + $originalCalendar->VEVENT->add(TipBroker::ALLOW_ATTENDEE_GUESTS_PROPERTY, true); + $mutatedCalendar = clone $originalCalendar; + $mutatedCalendar->VEVENT->ATTENDEE[0]['PARTSTAT'] = 'ACCEPTED'; + $mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:guest@example.org', [ + 'CN' => 'Guest', + 'PARTSTAT' => 'NEEDS-ACTION', + ]); + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + + $messages = $this->invokePrivate($this->broker, 'parseEventForAttendee', [ + $mutatedCalendar, $mutatedEventInfo, $originalEventInfo, 'mailto:attendee1@example.org', + ]); + + // One reply for the participation status, one carrying the guest + $this->assertCount(2, $messages); + $this->assertTrue($messages[0]->significantChange); + $this->assertCount(1, $messages[0]->message->VEVENT->ATTENDEE); + $this->assertEquals('ACCEPTED', $messages[0]->message->VEVENT->ATTENDEE[0]['PARTSTAT']->getValue()); + $this->assertFalse($messages[1]->significantChange); + $this->assertCount(1, $messages[1]->message->VEVENT->ATTENDEE); + $this->assertEquals('ACCEPTED', $messages[1]->message->VEVENT->ATTENDEE[0]['PARTSTAT']->getValue()); + $this->assertEquals('mailto:guest@example.org', $messages[1]->message->VEVENT->{TipBroker::ADD_GUEST_PROPERTY}[0]->getValue()); + } + + public function testParseEventForAttendeeIgnoresAddedGuestWhenNotAllowed(): void { + // The organizer has to opt in, so an event without the property is enough + $originalCalendar = clone $this->vCalendar1a; + $mutatedCalendar = clone $originalCalendar; + $mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:guest@example.org', [ + 'CN' => 'Guest', + 'PARTSTAT' => 'NEEDS-ACTION', + ]); + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + + $messages = $this->invokePrivate($this->broker, 'parseEventForAttendee', [ + $mutatedCalendar, $mutatedEventInfo, $originalEventInfo, 'mailto:attendee1@example.org', + ]); + + $this->assertCount(0, $messages); + } + + public function testParseEventForAttendeeDoesNotReportTheOrganizer(): void { + $originalCalendar = clone $this->vCalendar1a; + $originalCalendar->VEVENT->add(TipBroker::ALLOW_ATTENDEE_GUESTS_PROPERTY, true); + $mutatedCalendar = clone $originalCalendar; + // The organizer joining as an attendee is not a guest of anyone + $mutatedCalendar->VEVENT->add('ATTENDEE', 'mailto:organizer@example.org', [ + 'CN' => 'Organizer', + 'PARTSTAT' => 'ACCEPTED', + ]); + $originalEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$originalCalendar]); + $mutatedEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$mutatedCalendar]); + + $messages = $this->invokePrivate($this->broker, 'parseEventForAttendee', [ + $mutatedCalendar, $mutatedEventInfo, $originalEventInfo, 'mailto:attendee1@example.org', + ]); + + $this->assertCount(0, $messages); + } + + public function testProcessMessageReplyAddsGuest(): void { + $existingCalendar = clone $this->vCalendar1a; + $existingCalendar->VEVENT->add(TipBroker::ALLOW_ATTENDEE_GUESTS_PROPERTY, true); + $reply = $this->buildReplyWithGuest($existingCalendar, 'mailto:attendee1@example.org'); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertSame($existingCalendar, $result); + $this->assertCount(2, $result->VEVENT->ATTENDEE); + $this->assertEquals('mailto:attendee1@example.org', $result->VEVENT->ATTENDEE[0]->getValue()); + $this->assertEquals('ACCEPTED', $result->VEVENT->ATTENDEE[0]['PARTSTAT']->getValue()); + $this->assertEquals('mailto:guest@example.org', $result->VEVENT->ATTENDEE[1]->getValue()); + $this->assertEquals('NEEDS-ACTION', $result->VEVENT->ATTENDEE[1]['PARTSTAT']->getValue()); + $this->assertEquals('Guest', $result->VEVENT->ATTENDEE[1]['CN']->getValue()); + } + + public function testProcessMessageReplyAddsGuestToParsedCalendar(): void { + $existingCalendar = clone $this->vCalendar1a; + $existingCalendar->VEVENT->add(TipBroker::ALLOW_ATTENDEE_GUESTS_PROPERTY, true); + // Parsing turns the property into a bool, unlike adding it in memory + $existingCalendar = VObject\Reader::read($existingCalendar->serialize()); + $reply = $this->buildReplyWithGuest($existingCalendar, 'mailto:attendee1@example.org'); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertCount(2, $result->VEVENT->ATTENDEE); + $this->assertEquals('mailto:guest@example.org', $result->VEVENT->ATTENDEE[1]->getValue()); + } + + public function testProcessMessageReplyIgnoresGuestWhenNotAllowed(): void { + // Forwarding stays allowed, only adding guests was never turned on + $existingCalendar = clone $this->vCalendar1a; + $existingCalendar->VEVENT->add(TipBroker::INVITATION_FORWARDING_PROPERTY, true); + $reply = $this->buildReplyWithGuest($existingCalendar, 'mailto:attendee1@example.org'); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertCount(1, $result->VEVENT->ATTENDEE); + $this->assertEquals('mailto:attendee1@example.org', $result->VEVENT->ATTENDEE[0]->getValue()); + $this->assertEquals('ACCEPTED', $result->VEVENT->ATTENDEE[0]['PARTSTAT']->getValue()); + } + + public function testProcessMessageReplyIgnoresGuestOfUnknownSender(): void { + $existingCalendar = clone $this->vCalendar1a; + $existingCalendar->VEVENT->add(TipBroker::ALLOW_ATTENDEE_GUESTS_PROPERTY, true); + // A party crasher must not be able to invite further guests + $reply = $this->buildReplyWithGuest($existingCalendar, 'mailto:crasher@example.org'); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertCount(2, $result->VEVENT->ATTENDEE); + $this->assertEquals('mailto:attendee1@example.org', $result->VEVENT->ATTENDEE[0]->getValue()); + $this->assertEquals('mailto:crasher@example.org', $result->VEVENT->ATTENDEE[1]->getValue()); + } + + public function testProcessMessageReplyDoesNotDuplicateAKnownGuest(): void { + $existingCalendar = clone $this->vCalendar1a; + $existingCalendar->VEVENT->add(TipBroker::ALLOW_ATTENDEE_GUESTS_PROPERTY, true); + $existingCalendar->VEVENT->add('ATTENDEE', 'mailto:guest@example.org', [ + 'CN' => 'Guest', + 'PARTSTAT' => 'DECLINED', + ]); + $reply = $this->buildReplyWithGuest($existingCalendar, 'mailto:attendee1@example.org'); + + $result = $this->invokePrivate($this->broker, 'processMessageReply', [$reply, $existingCalendar]); + + $this->assertCount(2, $result->VEVENT->ATTENDEE); + $this->assertEquals('mailto:guest@example.org', $result->VEVENT->ATTENDEE[1]->getValue()); + // A reply must not overwrite the participation status of anyone but the sender + $this->assertEquals('DECLINED', $result->VEVENT->ATTENDEE[1]['PARTSTAT']->getValue()); + } + } diff --git a/apps/dav/tests/unit/CapabilitiesTest.php b/apps/dav/tests/unit/CapabilitiesTest.php index 1c8edd250ca14..d02fddffa32a9 100644 --- a/apps/dav/tests/unit/CapabilitiesTest.php +++ b/apps/dav/tests/unit/CapabilitiesTest.php @@ -35,6 +35,7 @@ public function testGetCapabilities(): void { 'search_supports_creation_time' => true, 'search_supports_upload_time' => true, 'search_supports_last_activity' => true, + 'attendee_guests' => true, ], ]; $this->assertSame($expected, $capabilities->getCapabilities()); @@ -58,6 +59,7 @@ public function testGetCapabilitiesWithBulkUpload(): void { 'search_supports_creation_time' => true, 'search_supports_upload_time' => true, 'search_supports_last_activity' => true, + 'attendee_guests' => true, 'bulkupload' => '1.0', ], ]; @@ -82,6 +84,7 @@ public function testGetCapabilitiesWithAbsence(): void { 'search_supports_creation_time' => true, 'search_supports_upload_time' => true, 'search_supports_last_activity' => true, + 'attendee_guests' => true, 'absence-supported' => true, 'absence-replacement' => true, ], diff --git a/openapi.json b/openapi.json index a0d248d466ca2..bb2d0c219cb1c 100644 --- a/openapi.json +++ b/openapi.json @@ -1506,7 +1506,8 @@ "public_shares_chunking", "search_supports_creation_time", "search_supports_upload_time", - "search_supports_last_activity" + "search_supports_last_activity", + "attendee_guests" ], "properties": { "chunking": { @@ -1524,6 +1525,9 @@ "search_supports_last_activity": { "type": "boolean" }, + "attendee_guests": { + "type": "boolean" + }, "bulkupload": { "type": "string" },