Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Entities/Calendar.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ public function getExternalContent( bool $force = false ): ?string {
$remainingUrl = substr( $fullUrl, strpos( $fullUrl, '/', 8 ) );

$caldavClient = new CalDAV( $baseUrl, $this->custom_fields['username'], $this->custom_fields['password'] );
$data = implode( '', $caldavClient->getEvents( $remainingUrl ) );
// Keep event payloads separated to preserve valid ICS line boundaries.
$data = implode( "\r\n", $caldavClient->getEvents( $remainingUrl ) );
} catch ( Exception $e ) {
Log::error( $e->getMessage(), [ 'exception' => $e ] );

Expand Down
175 changes: 158 additions & 17 deletions Http/Helpers/CalDAV.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,51 @@ public function __construct( $baseUri, $userName, $password ) {
}

public function getEvents( $calendarUrl ) {
$response = $this->client->propFind( $calendarUrl, [
'{DAV:}displayname',
'{urn:ietf:params:xml:ns:caldav}calendar-description',
'{urn:ietf:params:xml:ns:caldav}calendar-data',
], 2 );
\Log::debug( 'CalDAV getEvents called' );

$xmlBody = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" .
'<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">' . "\n" .
' <D:prop>' . "\n" .
' <D:getetag/>' . "\n" .
' <C:calendar-data/>' . "\n" .
' </D:prop>' . "\n" .
' <C:filter>' . "\n" .
' <C:comp-filter name="VCALENDAR">' . "\n" .
' <C:comp-filter name="VEVENT">' . "\n" .
' </C:comp-filter>' . "\n" .
' </C:comp-filter>' . "\n" .
' </C:filter>' . "\n" .
'</C:calendar-query>';

try {
$response = $this->client->request( 'REPORT', $calendarUrl, $xmlBody, [
'Content-Type' => 'application/xml; charset=utf-8',
'Depth' => '1',
] );
} catch ( \Exception $e ) {
\Log::debug( 'CalDAV REPORT query failed, returning empty events list', [
'error' => $e->getMessage(),
] );
return [];
}

\Log::debug( 'CalDAV REPORT response', [
'status' => $response['statusCode'] ?? 'no status',
'body_length' => strlen( $response['body'] ?? '' ),
] );

$events = [];
foreach ( $response as $eventData ) {
if ( isset( $eventData['{urn:ietf:params:xml:ns:caldav}calendar-data'] ) ) {
$events[] = $eventData['{urn:ietf:params:xml:ns:caldav}calendar-data'];
if ( isset( $response['statusCode'] ) && $response['statusCode'] >= 200 && $response['statusCode'] < 300 ) {
if ( isset( $response['body'] ) && ! empty( $response['body'] ) ) {
$events = $this->extractCalendarDataFromMultistatus( $response['body'] );
if ( count( $events ) === 0 ) {
$hrefs = $this->extractEventHrefsFromMultistatus( $response['body'], $calendarUrl );
$events = $this->fetchEventsByHref( $hrefs );
}
\Log::debug( 'CalDAV parsed events count', [ 'count' => count( $events ) ] );
}
} else {
\Log::debug( 'CalDAV REPORT failed', [ 'status' => $response['statusCode'] ?? 'unknown' ] );
}

return $events;
Expand Down Expand Up @@ -100,7 +134,7 @@ public function deleteEvent( $calendarUrl, $uid ) {
/**
* Get a single event by UID using CalDAV REPORT query
* This is much more efficient than fetching all events
*
*
* @param string $calendarUrl The calendar URL
* @param string $uid The event UID to fetch
* @return array|null The event data or null if not found
Expand Down Expand Up @@ -157,25 +191,25 @@ public function getEventByUid( $calendarUrl, $uid ) {

/**
* Check if the CalDAV server supports REPORT queries
*
*
* @param string $calendarUrl
* @return bool
*/
public function supportsReportQuery( $calendarUrl ) {
try {
$response = $this->client->options( $calendarUrl );

if ( isset( $response['headers']['allow'] ) ) {
$allow = is_array( $response['headers']['allow'] )
? implode( ',', $response['headers']['allow'] )
$allow = is_array( $response['headers']['allow'] )
? implode( ',', $response['headers']['allow'] )
: $response['headers']['allow'];
return stripos( $allow, 'REPORT' ) !== false;
}

// Also check DAV header for calendar-access
if ( isset( $response['headers']['dav'] ) ) {
$dav = is_array( $response['headers']['dav'] )
? implode( ',', $response['headers']['dav'] )
$dav = is_array( $response['headers']['dav'] )
? implode( ',', $response['headers']['dav'] )
: $response['headers']['dav'];
return stripos( $dav, 'calendar-access' ) !== false;
}
Expand All @@ -185,8 +219,115 @@ public function supportsReportQuery( $calendarUrl ) {
'calendar_url' => $calendarUrl
] );
}

return false;
}

/**
* Extract calendar-data nodes from a CalDAV multistatus XML response.
* Uses XML parsing for namespace-agnostic extraction and regex as fallback.
*
* @param string $responseBody
* @return string[]
*/
private function extractCalendarDataFromMultistatus( $responseBody ) {
$events = [];

libxml_use_internal_errors( true );
$document = new \DOMDocument();
if ( $document->loadXML( $responseBody ) ) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

DOMDocument::loadXML() without LIBXML_NONET allows external entity loading on PHP < 8.0 (XXE risk). FreeScout supports PHP 7.x. Use:

$document->loadXML( $responseBody, LIBXML_NONET );

Same applies to extractEventHrefsFromMultistatus below.

$xpath = new \DOMXPath( $document );
$nodes = $xpath->query( '//*[local-name()="calendar-data"]' );
if ( $nodes !== false ) {
foreach ( $nodes as $node ) {
$data = trim( $node->nodeValue );
if ( $data !== '' ) {
$events[] = html_entity_decode( $data, ENT_XML1, 'UTF-8' );
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Doesn't $node->nodeValue already returns decoded text?

}
}
}
}
libxml_clear_errors();

if ( count( $events ) > 0 ) {
return $events;
}

$matches = [];
preg_match_all( '/<(?:[A-Za-z0-9_-]+:)?calendar-data[^>]*>(.*?)<\/(?:[A-Za-z0-9_-]+:)?calendar-data>/s', $responseBody, $matches );
if ( isset( $matches[1] ) ) {
foreach ( $matches[1] as $data ) {
$decoded = html_entity_decode( trim( $data ), ENT_XML1, 'UTF-8' );
if ( $decoded !== '' ) {
$events[] = $decoded;
}
}
}

return $events;
}

/**
* Extract event hrefs from a CalDAV multistatus XML response.
*
* @param string $responseBody
* @param string $calendarUrl
* @return string[]
*/
private function extractEventHrefsFromMultistatus( $responseBody, $calendarUrl ) {
$hrefs = [];

libxml_use_internal_errors( true );
$document = new \DOMDocument();
if ( $document->loadXML( $responseBody ) ) {
$xpath = new \DOMXPath( $document );
$nodes = $xpath->query( '//*[local-name()="href"]' );
if ( $nodes !== false ) {
foreach ( $nodes as $node ) {
$href = trim( $node->nodeValue );
if ( $href === '' ) {
continue;
}
if ( substr( $href, -1 ) === '/' ) {
continue;
}
if ( stripos( $href, '.ics' ) === false ) {
continue;
}
$hrefs[] = $href;
}
}
}
libxml_clear_errors();

return array_values( array_unique( $hrefs ) );
}

/**
* Fetch event bodies by href list.
*
* @param string[] $hrefs
* @return string[]
*/
private function fetchEventsByHref( $hrefs ) {
$events = [];
foreach ( $hrefs as $href ) {
try {
$response = $this->client->request( 'GET', $href, null, [
'Accept' => 'text/calendar, */*;q=0.1',
] );
if ( isset( $response['statusCode'] ) && $response['statusCode'] >= 200 && $response['statusCode'] < 300 ) {
$body = trim( $response['body'] ?? '' );
if ( $body !== '' ) {
$events[] = $body;
}
}
} catch ( \Exception $e ) {
\Log::debug( 'CalDAV event GET failed', [ 'error' => $e->getMessage() ] );
}
}

return $events;
}

}