Skip to content

Commit 69d0b7e

Browse files
authored
Merge pull request #59988 from nextcloud/feat/party-crasher
caldav party crasher
2 parents 887dfeb + abef091 commit 69d0b7e

3 files changed

Lines changed: 568 additions & 11 deletions

File tree

apps/dav/lib/AppInfo/Application.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use OCA\DAV\CalDAV\Reminder\NotificationProvider\PushProvider;
2020
use OCA\DAV\CalDAV\Reminder\NotificationProviderManager;
2121
use OCA\DAV\CalDAV\Reminder\Notifier as NotifierCalDAV;
22+
use OCA\DAV\CalDAV\TipBroker;
2223
use OCA\DAV\Capabilities;
2324
use OCA\DAV\CardDAV\ContactsManager;
2425
use OCA\DAV\CardDAV\Notification\Notifier as NotifierCardDAV;
@@ -108,6 +109,7 @@
108109
use OCP\User\Events\UserIdUnassignedEvent;
109110
use Psr\Container\ContainerInterface;
110111
use Psr\Log\LoggerInterface;
112+
use Sabre\VObject;
111113
use Throwable;
112114
use function is_null;
113115

@@ -238,6 +240,8 @@ public function register(IRegistrationContext $context): void {
238240

239241
#[\Override]
240242
public function boot(IBootContext $context): void {
243+
VObject\Component\VCalendar::$propertyMap[TipBroker::INVITATION_FORWARDING_PROPERTY] = VObject\Property\Boolean::class;
244+
241245
// Load all dav apps
242246
$context->getServerContainer()->get(IAppManager::class)->loadApps(['dav']);
243247

apps/dav/lib/CalDAV/TipBroker.php

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,18 @@
1111

1212
use Sabre\VObject\Component;
1313
use Sabre\VObject\Component\VCalendar;
14+
use Sabre\VObject\Component\VEvent;
1415
use Sabre\VObject\ITip\Broker;
1516
use Sabre\VObject\ITip\Message;
17+
use Sabre\VObject\Parameter;
18+
use Sabre\VObject\Property;
19+
use Sabre\VObject\Property\Boolean;
20+
use Sabre\VObject\Property\ICalendar\CalAddress;
21+
use Sabre\VObject\Property\ICalendar\DateTime;
22+
use Sabre\VObject\Recur\EventIterator;
1623

1724
class TipBroker extends Broker {
25+
public const INVITATION_FORWARDING_PROPERTY = 'X-NC-INVITATION-FORWARDING';
1826

1927
public $significantChangeProperties = [
2028
'DTSTART',
@@ -79,6 +87,181 @@ protected function processMessageCancel(Message $itipMessage, ?VCalendar $existi
7987
return $existingObject;
8088
}
8189

90+
#[\Override]
91+
protected function processMessageReply(Message $itipMessage, ?VCalendar $existingObject = null) {
92+
// A reply can only be processed based on an existing object.
93+
// If the object is not available, the reply is ignored.
94+
if ($existingObject === null) {
95+
return null;
96+
}
97+
$instances = [];
98+
$requestStatus = '2.0';
99+
100+
/** @var list<VEvent> $vevents */
101+
$vevents = $itipMessage->message->select('VEVENT');
102+
103+
// Finding all the instances the attendee replied to.
104+
foreach ($vevents as $vevent) {
105+
// Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
106+
// The Unix timestamp will be the same for an event, even if the reply from the attendee
107+
// used a different format/timezone to express the event date-time.
108+
$recurId = $this->getRecurrenceKey($vevent);
109+
$attendee = $this->getFirstAttendee($vevent);
110+
if ($attendee === null) {
111+
continue;
112+
}
113+
$partstat = $attendee->offsetGet('PARTSTAT');
114+
if (!$partstat instanceof Parameter) {
115+
continue;
116+
}
117+
$instances[$recurId] = $partstat->getValue();
118+
if (isset($vevent->{'REQUEST-STATUS'})) {
119+
$requestStatus = $vevent->{'REQUEST-STATUS'}->getValue();
120+
[$requestStatus] = explode(';', $requestStatus);
121+
}
122+
}
123+
124+
// Now we need to loop through the original organizer event, to find
125+
// all the instances where we have a reply for.
126+
/** @var VEvent|null $masterObject */
127+
$masterObject = $existingObject->getBaseComponent('VEVENT');
128+
$masterAllowInvitationForwarding = $masterObject === null || $this->allowInvitationForwarding($masterObject);
129+
130+
/** @var list<VEvent> $vevents */
131+
$vevents = $existingObject->select('VEVENT');
132+
133+
foreach ($vevents as $vevent) {
134+
// Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
135+
$recurId = $this->getRecurrenceKey($vevent);
136+
if (isset($instances[$recurId])) {
137+
$allowInvitationForwarding = $this->allowInvitationForwarding($vevent);
138+
$attendeeFound = false;
139+
if (isset($vevent->ATTENDEE)) {
140+
foreach ($vevent->ATTENDEE as $attendee) {
141+
if ($attendee->getValue() === $itipMessage->sender) {
142+
$attendeeFound = true;
143+
$attendee['PARTSTAT'] = $instances[$recurId];
144+
$attendee['SCHEDULE-STATUS'] = $requestStatus;
145+
// Un-setting the RSVP status, because we now know
146+
// that the attendee already replied.
147+
unset($attendee['RSVP']);
148+
break;
149+
}
150+
}
151+
}
152+
if (!$attendeeFound && $allowInvitationForwarding) {
153+
// Adding a new attendee. The iTip documentation calls this
154+
// a party crasher.
155+
$parameters = [
156+
'PARTSTAT' => $instances[$recurId],
157+
];
158+
if ($itipMessage->senderName) {
159+
$parameters['CN'] = $itipMessage->senderName;
160+
}
161+
$vevent->add('ATTENDEE', $itipMessage->sender, $parameters);
162+
}
163+
unset($instances[$recurId]);
164+
}
165+
}
166+
167+
if ($masterObject === null) {
168+
// No master object, we can't add new instances.
169+
return null;
170+
}
171+
// If we got replies to instances that did not exist in the
172+
// original list, it means that new exceptions must be created.
173+
foreach ($instances as $recurId => $partstat) {
174+
$recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
175+
$found = false;
176+
$iterations = 1000;
177+
do {
178+
$newObject = $recurrenceIterator->getEventObject();
179+
$recurrenceIterator->next();
180+
181+
// Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
182+
// If they are the same, then this is a matching recurrence, even though its date-time may have
183+
// been expressed in a different format/timezone.
184+
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
185+
$found = true;
186+
}
187+
--$iterations;
188+
} while ($recurrenceIterator->valid() && !$found && $iterations);
189+
190+
// Invalid recurrence id. Skipping this object.
191+
if (!$found) {
192+
continue;
193+
}
194+
195+
$newObject->remove('RRULE');
196+
$newObject->remove('EXDATE');
197+
$newObject->remove('RDATE');
198+
199+
$attendeeFound = false;
200+
if (isset($newObject->ATTENDEE)) {
201+
foreach ($newObject->ATTENDEE as $attendee) {
202+
if ($attendee->getValue() === $itipMessage->sender) {
203+
$attendeeFound = true;
204+
$attendee['PARTSTAT'] = $partstat;
205+
$attendee['SCHEDULE-STATUS'] = $requestStatus;
206+
unset($attendee['RSVP']);
207+
break;
208+
}
209+
}
210+
}
211+
if (!$attendeeFound && !$masterAllowInvitationForwarding) {
212+
continue;
213+
}
214+
if (!$attendeeFound) {
215+
// Adding a new attendee
216+
$parameters = [
217+
'PARTSTAT' => $partstat,
218+
];
219+
if ($itipMessage->senderName) {
220+
$parameters['CN'] = $itipMessage->senderName;
221+
}
222+
$newObject->add('ATTENDEE', $itipMessage->sender, $parameters);
223+
}
224+
$existingObject->add($newObject);
225+
}
226+
227+
return $existingObject;
228+
}
229+
230+
/**
231+
* @return int|'master'
232+
*/
233+
protected function getRecurrenceKey(VEvent $vevent): int|string {
234+
/** @var list<Property> $recurrenceIds */
235+
$recurrenceIds = $vevent->select('RECURRENCE-ID');
236+
foreach ($recurrenceIds as $recurrenceId) {
237+
if ($recurrenceId instanceof DateTime) {
238+
return $recurrenceId->getDateTime()->getTimestamp();
239+
}
240+
}
241+
return 'master';
242+
}
243+
244+
protected function getFirstAttendee(VEvent $vevent): ?CalAddress {
245+
/** @var list<Property> $attendees */
246+
$attendees = $vevent->select('ATTENDEE');
247+
foreach ($attendees as $attendee) {
248+
if ($attendee instanceof CalAddress) {
249+
return $attendee;
250+
}
251+
}
252+
return null;
253+
}
254+
255+
protected function allowInvitationForwarding(VEvent $vevent): bool {
256+
$properties = $vevent->select(self::INVITATION_FORWARDING_PROPERTY);
257+
foreach ($properties as $property) {
258+
if ($property instanceof Boolean) {
259+
return $property->getValue() === 'TRUE';
260+
}
261+
}
262+
return true;
263+
}
264+
82265
/**
83266
* This method is used in cases where an event got updated, and we
84267
* potentially need to send emails to attendees to let them know of updates

0 commit comments

Comments
 (0)