Conversation
AI-assisted: OpenCode (gpt-5.4) Signed-off-by: Daniel Kesselberg <mail@danielkesselberg.de>
| foreach ($newObject->ATTENDEE as $attendee) { | ||
| if ($attendee->getValue() === $itipMessage->sender) { | ||
| $attendeeFound = true; | ||
| $attendee['PARTSTAT'] = $partstat; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 inTipBrokerto optionally reject “party crasher” attendees and to generate missing recurring exceptions. - Register
X-NC-PARTY-CRASHERas a typed VObject property viaVCalendar::$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.
|
|
||
| // 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); |
There was a problem hiding this comment.
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.
| // 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); | |
| } |
| $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); | ||
|
|
There was a problem hiding this comment.
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.
| $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; | |
| } |
| 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); | ||
|
|
There was a problem hiding this comment.
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.
| $attendeeFound = false; | ||
| if (isset($newObject->ATTENDEE)) { | ||
| foreach ($newObject->ATTENDEE as $attendee) { | ||
| if ($attendee->getValue() === $itipMessage->sender) { | ||
| $attendeeFound = true; | ||
| $attendee['PARTSTAT'] = $partstat; | ||
| break; | ||
| } |
There was a problem hiding this comment.
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.
| foreach ($properties as $property) { | ||
| if ($property instanceof Boolean) { | ||
| return $property->getValue() === 'TRUE'; | ||
| } |
There was a problem hiding this comment.
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.
| } | |
| } | |
| $value = strtoupper(trim((string)$property->getValue())); | |
| if ($value === 'TRUE' || $value === '1' || $value === 'YES') { | |
| return true; | |
| } | |
| if ($value === 'FALSE' || $value === '0' || $value === 'NO') { | |
| return false; | |
| } |
| VCalendar::$propertyMap['X-NC-PARTY-CRASHER'] = VObject\Property\Boolean::class; | ||
|
|
There was a problem hiding this comment.
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.
ChristophWurst
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
| * 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 { |
There was a problem hiding this comment.
nit: naming. Could this be allowPartyCrashers?
Summary
Calendar: nextcloud/calendar#8222
TODO
Checklist
3. to review, feature component)stable32)AI (if applicable)