Skip to content

caldav party crasher#59988

Open
kesselb wants to merge 2 commits intomasterfrom
feat/party-crasher
Open

caldav party crasher#59988
kesselb wants to merge 2 commits intomasterfrom
feat/party-crasher

Conversation

@kesselb
Copy link
Copy Markdown
Contributor

@kesselb kesselb commented Apr 28, 2026

Summary

Calendar: nextcloud/calendar#8222

TODO

Checklist

AI (if applicable)

  • The content of this PR was partly or fully generated using AI

kesselb added 2 commits April 28, 2026 19:30
AI-assisted: OpenCode (gpt-5.4)

Signed-off-by: Daniel Kesselberg <mail@danielkesselberg.de>
@kesselb kesselb added this to the Nextcloud 34 milestone Apr 28, 2026
@kesselb kesselb self-assigned this Apr 28, 2026
@kesselb kesselb added enhancement 2. developing Work in progress feature: caldav Related to CalDAV internals labels Apr 28, 2026
@kesselb kesselb requested review from Altahrim, artonge, icewind1991 and salmart-dev and removed request for a team April 28, 2026 21:29
foreach ($newObject->ATTENDEE as $attendee) {
if ($attendee->getValue() === $itipMessage->sender) {
$attendeeFound = true;
$attendee['PARTSTAT'] = $partstat;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is matching the upstream: https://github.com/sabre-io/vobject/blob/2104a3ea37e248262617a8acbfe7648d8e2fd8bd/lib/ITip/Broker.php#L437

@SebastianKrupinski do you know why we don't unset RSVP here like for the existing events in the loop before?

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Nextcloud-specific “party crasher” control for CalDAV iTIP REPLY handling, plus unit tests, to prevent/allow adding new attendees from replies based on an X-NC-PARTY-CRASHER flag.

Changes:

  • Add custom processMessageReply() handling in TipBroker to optionally reject “party crasher” attendees and to generate missing recurring exceptions.
  • Register X-NC-PARTY-CRASHER as a typed VObject property via VCalendar::$propertyMap.
  • Add unit tests covering allow/disallow/default behavior for single and generated recurring instances.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
apps/dav/lib/CalDAV/TipBroker.php Implements REPLY processing with party-crasher gating and recurring-instance generation.
apps/dav/lib/AppInfo/Application.php Registers X-NC-PARTY-CRASHER in Sabre VObject’s VCalendar property map at app boot.
apps/dav/tests/unit/CalDAV/TipBrokerTest.php Adds coverage for party-crasher allow/deny/default scenarios (including generated recurrences).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +102 to +124

// Finding all the instances the attendee replied to.
foreach ($itipMessage->message->VEVENT as $vevent) {
// Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
// The Unix timestamp will be the same for an event, even if the reply from the attendee
// used a different format/timezone to express the event date-time.
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
$attendee = $vevent->ATTENDEE;
$instances[$recurId] = $attendee['PARTSTAT']->getValue();
if (isset($vevent->{'REQUEST-STATUS'})) {
$requestStatus = $vevent->{'REQUEST-STATUS'}->getValue();
[$requestStatus] = explode(';', $requestStatus);
}
}

// Now we need to loop through the original organizer event, to find
// all the instances where we have a reply for.
$masterObject = null;
$allowPartyCrasher = true;
foreach ($existingObject->VEVENT as $vevent) {
if (!isset($vevent->{'RECURRENCE-ID'})) {
$masterObject = $vevent;
$allowPartyCrasher = $this->partyCrasher($vevent);
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processMessageReply() iterates over $itipMessage->message->VEVENT and $existingObject->VEVENT, ignoring $itipMessage->component. TipBroker elsewhere supports different component types (e.g. VTODO/VJOURNAL), so this override will fail or silently ignore replies for non-VEVENT components. Consider using $componentType = $itipMessage->component and iterating $itipMessage->message->$componentType / $existingObject->$componentType (and adjust partyCrasher() accordingly) to keep behavior consistent.

Suggested change
// Finding all the instances the attendee replied to.
foreach ($itipMessage->message->VEVENT as $vevent) {
// Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
// The Unix timestamp will be the same for an event, even if the reply from the attendee
// used a different format/timezone to express the event date-time.
$recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
$attendee = $vevent->ATTENDEE;
$instances[$recurId] = $attendee['PARTSTAT']->getValue();
if (isset($vevent->{'REQUEST-STATUS'})) {
$requestStatus = $vevent->{'REQUEST-STATUS'}->getValue();
[$requestStatus] = explode(';', $requestStatus);
}
}
// Now we need to loop through the original organizer event, to find
// all the instances where we have a reply for.
$masterObject = null;
$allowPartyCrasher = true;
foreach ($existingObject->VEVENT as $vevent) {
if (!isset($vevent->{'RECURRENCE-ID'})) {
$masterObject = $vevent;
$allowPartyCrasher = $this->partyCrasher($vevent);
$componentType = $itipMessage->component;
// Finding all the instances the attendee replied to.
foreach (isset($itipMessage->message->{$componentType}) ? $itipMessage->message->{$componentType} : [] as $component) {
// Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
// The Unix timestamp will be the same for an event, even if the reply from the attendee
// used a different format/timezone to express the event date-time.
$recurId = isset($component->{'RECURRENCE-ID'}) ? $component->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
$attendee = $component->ATTENDEE;
$instances[$recurId] = $attendee['PARTSTAT']->getValue();
if (isset($component->{'REQUEST-STATUS'})) {
$requestStatus = $component->{'REQUEST-STATUS'}->getValue();
[$requestStatus] = explode(';', $requestStatus);
}
}
// Now we need to loop through the original organizer component, to find
// all the instances where we have a reply for.
$masterObject = null;
$allowPartyCrasher = true;
foreach (isset($existingObject->{$componentType}) ? $existingObject->{$componentType} : [] as $component) {
if (!isset($component->{'RECURRENCE-ID'})) {
$masterObject = $component;
if ($component instanceof VEvent) {
$allowPartyCrasher = $this->partyCrasher($component);
}

Copilot uses AI. Check for mistakes.
Comment on lines +168 to +183
$recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
$found = false;
$iterations = 1000;
do {
$newObject = $recurrenceIterator->getEventObject();
$recurrenceIterator->next();

// Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
// If they are the same, then this is a matching recurrence, even though its date-time may have
// been expressed in a different format/timezone.
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
$found = true;
}
--$iterations;
} while ($recurrenceIterator->valid() && !$found && $iterations);

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processMessageReply() constructs new EventIterator($existingObject, $itipMessage->uid) without handling exceptions. EventIterator can throw (e.g. NoInstancesException is handled elsewhere in the codebase), so a malformed or unexpected recurring definition could bubble up as an unhandled exception and break scheduling processing. Wrap the iterator creation/iteration in a try/catch and treat failures as an invalid recurrence (ignore the reply / skip instance) instead of hard-failing.

Suggested change
$recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
$found = false;
$iterations = 1000;
do {
$newObject = $recurrenceIterator->getEventObject();
$recurrenceIterator->next();
// Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
// If they are the same, then this is a matching recurrence, even though its date-time may have
// been expressed in a different format/timezone.
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
$found = true;
}
--$iterations;
} while ($recurrenceIterator->valid() && !$found && $iterations);
$found = false;
$iterations = 1000;
$newObject = null;
try {
$recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
do {
$newObject = $recurrenceIterator->getEventObject();
$recurrenceIterator->next();
// Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
// If they are the same, then this is a matching recurrence, even though its date-time may have
// been expressed in a different format/timezone.
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
$found = true;
}
--$iterations;
} while ($recurrenceIterator->valid() && !$found && $iterations);
} catch (\Throwable $e) {
// Invalid or non-expandable recurrence. Skip this reply instance.
continue;
}

Copilot uses AI. Check for mistakes.
Comment on lines +167 to +183
foreach ($instances as $recurId => $partstat) {
$recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
$found = false;
$iterations = 1000;
do {
$newObject = $recurrenceIterator->getEventObject();
$recurrenceIterator->next();

// Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
// If they are the same, then this is a matching recurrence, even though its date-time may have
// been expressed in a different format/timezone.
if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
$found = true;
}
--$iterations;
} while ($recurrenceIterator->valid() && !$found && $iterations);

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop that tries to match missing recurrences re-creates an EventIterator for each $instances entry and then scans up to 1000 iterations from the start. If a reply contains many RECURRENCE-ID values, this becomes expensive (O(n * 1000)) and can be abused to cause high CPU usage. Consider enforcing a reasonable cap on the number of instances processed from a single reply and/or reusing a single iterator (or fast-forwarding to the target) to avoid repeated full scans.

Copilot uses AI. Check for mistakes.
Comment on lines +194 to +201
$attendeeFound = false;
if (isset($newObject->ATTENDEE)) {
foreach ($newObject->ATTENDEE as $attendee) {
if ($attendee->getValue() === $itipMessage->sender) {
$attendeeFound = true;
$attendee['PARTSTAT'] = $partstat;
break;
}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For generated exceptions ($newObject), when the attendee already exists you only update PARTSTAT, but for existing components earlier in the method you also set SCHEDULE-STATUS and unset RSVP. This inconsistency means generated instances may keep stale RSVP flags / miss schedule status tracking. Consider applying the same updates (SCHEDULE-STATUS, unset(RSVP), etc.) in the generated-instance path as well.

Copilot uses AI. Check for mistakes.
foreach ($properties as $property) {
if ($property instanceof Boolean) {
return $property->getValue() === 'TRUE';
}
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

partyCrasher() only checks properties that are instances of Sabre\VObject\Property\Boolean. If the event contains X-NC-PARTY-CRASHER:FALSE but it gets parsed as a generic/unknown property (e.g. if the property map wasn’t initialized before parsing), this method will fall back to true and still allow party crashers. To make this robust, consider also checking the property's string value (case-insensitive) whenever the property exists, not only when it’s a Boolean instance.

Suggested change
}
}
$value = strtoupper(trim((string)$property->getValue()));
if ($value === 'TRUE' || $value === '1' || $value === 'YES') {
return true;
}
if ($value === 'FALSE' || $value === '0' || $value === 'NO') {
return false;
}

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +27
VCalendar::$propertyMap['X-NC-PARTY-CRASHER'] = VObject\Property\Boolean::class;

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setUp() mutates the global static VCalendar::$propertyMap but doesn’t restore it in tearDown(). Because this is shared global state, it can leak into other tests running in the same process and cause order-dependent behavior. Consider capturing the previous value and restoring it in tearDown() (or using a local helper) to keep the test isolated.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@ChristophWurst ChristophWurst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick review

In terms of naming we could go with invitation forwarding. That's what we will call it on the UI, and what other platforms (Outlook) call it.

* Processes incoming REPLY messages.
*
* The message is a reply. This is for example an attendee telling
* an organizer he accepted the invite, or declined it.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* an organizer he accepted the invite, or declined it.
* an organizer they accepted the invite, or declined it.

nit: neutral language

return $existingObject;
}

protected function partyCrasher(VEvent $vevent): bool {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: naming. Could this be allowPartyCrashers?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

2. developing Work in progress enhancement feature: caldav Related to CalDAV internals

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants