Files
server/usr/share/psa-horde/kronolith/lib/Driver/Ical.php
2026-01-07 20:52:11 +01:00

822 lines
30 KiB
PHP

<?php
/**
* The Kronolith_Driver_Ical class implements the Kronolith_Driver API for
* iCalendar data.
*
* Possible driver parameters:
* - url: The location of the remote calendar.
* - proxy: A hash with HTTP proxy information.
* - user: The user name for HTTP Basic Authentication.
* - password: The password for HTTP Basic Authentication.
*
* Copyright 2004-2017 Horde LLC (http://www.horde.org/)
*
* See the enclosed file COPYING for license information (GPL). If you
* did not receive this file, see http://www.horde.org/licenses/gpl.
*
* @author Chuck Hagenbuch <chuck@horde.org>
* @author Jan Schneider <jan@horde.org>
* @package Kronolith
*/
class Kronolith_Driver_Ical extends Kronolith_Driver
{
/**
* Cache events as we fetch them to avoid fetching or parsing the same
* event twice.
*
* @var array
*/
protected $_cache = array();
/**
* HTTP client object.
*
* @var Horde_Http_Client
*/
protected $_client;
/**
* A list of DAV support levels.
*
* @var array
*/
protected $_davSupport;
/**
* The Horde_Perms permissions mask matching the CalDAV ACL.
*
* @var integer
*/
protected $_permission;
/**
* Selects a calendar as the currently opened calendar.
*
* @param string $calendar A calendar identifier.
*/
public function open($calendar)
{
parent::open($calendar);
$this->_client = null;
$this->_permission = 0;
unset($this->_davSupport);
}
/**
* Returns the background color of the current calendar.
*
* @return string The calendar color.
*/
public function backgroundColor()
{
return $GLOBALS['calendar_manager']->getEntry(Kronolith::ALL_REMOTE_CALENDARS, $this->calendar)
? $GLOBALS['calendar_manager']->getEntry(Kronolith::ALL_REMOTE_CALENDARS, $this->calendar)->background()
: '#dddddd';
}
public function listAlarms($date, $fullevent = false)
{
return array();
}
/**
* Lists all events in the time range, optionally restricting results to
* only events with alarms.
*
* @param Horde_Date $startDate The start of range date.
* @param Horde_Date $endDate The end of date range.
* @param array $options Additional options:
* - show_recurrence: (boolean) Return every instance of a recurring
* event?
* DEFAULT: false (Only return recurring events once
* inside $startDate - $endDate range)
* - has_alarm: (boolean) Only return events with alarms.
* DEFAULT: false (Return all events)
* - json: (boolean) Store the results of the event's toJson()
* method?
* DEFAULT: false
* - cover_dates: (boolean) Add the events to all days that they
* cover?
* DEFAULT: true
* - hide_exceptions: (boolean) Hide events that represent exceptions to
* a recurring event.
* DEFAULT: false (Do not hide exception events)
* - fetch_tags: (boolean) Fetch tags for all events.
* DEFAULT: false (Do not fetch event tags)
*
* @throws Kronolith_Exception
*/
protected function _listEvents(Horde_Date $startDate = null,
Horde_Date $endDate = null,
array $options = array())
{
if ($this->isCalDAV()) {
try {
return $this->_listCalDAVEvents(
$startDate, $endDate, $options['show_recurrence'],
$options['has_alarm'], $options['json'],
$options['cover_dates'], $options['hide_exceptions']);
} catch (Kronolith_Exception $e) {
// Fall back to regular ICS downloads. At least Nextcloud
// advertises calendars as CalDAV capable, but then denying
// CalDAV requests.
$this->_davSupport = false;
}
}
return $this->_listWebDAVEvents(
$startDate, $endDate, $options['show_recurrence'],
$options['has_alarm'], $options['json'],
$options['cover_dates'], $options['hide_exceptions']);
}
/**
* Lists all events in the time range, optionally restricting results to
* only events with alarms.
*
* @param Horde_Date $startInterval Start of range date object.
* @param Horde_Date $endInterval End of range data object.
* @param boolean $showRecurrence Return every instance of a recurring
* event? If false, will only return
* recurring events once inside the
* $startDate - $endDate range.
* @param boolean $hasAlarm Only return events with alarms?
* @param boolean $json Store the results of the events'
* toJson() method?
* @param boolean $coverDates Whether to add the events to all days
* that they cover.
* $param boolean $hideExceptions Hide events that represent exceptions
* to a recurring event.
*
* @return array Events in the given time range.
* @throws Kronolith_Exception
*/
protected function _listWebDAVEvents(
$startDate = null, $endDate = null, $showRecurrence = false,
$hasAlarm = false, $json = false, $coverDates = true,
$hideExceptions = false
)
{
$ical = $this->getRemoteCalendar();
if (is_null($startDate)) {
$startDate = new Horde_Date(array('mday' => 1,
'month' => 1,
'year' => 0000));
}
if (is_null($endDate)) {
$endDate = new Horde_Date(array('mday' => 31,
'month' => 12,
'year' => 9999));
}
$startDate = clone $startDate;
$startDate->hour = $startDate->min = $startDate->sec = 0;
$endDate = clone $endDate;
$endDate->hour = 23;
$endDate->min = $endDate->sec = 59;
$results = array();
$this->_processComponents(
$results, $ical, $startDate, $endDate, $showRecurrence, $json,
$coverDates, $hideExceptions
);
return $results;
}
/**
* Lists all events in the time range, optionally restricting results to
* only events with alarms.
*
* @param Horde_Date $startInterval Start of range date object.
* @param Horde_Date $endInterval End of range data object.
* @param boolean $showRecurrence Return every instance of a recurring
* event? If false, will only return
* recurring events once inside the
* $startDate - $endDate range.
* @param boolean $hasAlarm Only return events with alarms?
* @param boolean $json Store the results of the events'
* toJson() method?
* @param boolean $coverDates Whether to add the events to all days
* that they cover.
* $param boolean $hideExceptions Hide events that represent exceptions
* to a recurring event.
*
* @return array Events in the given time range.
* @throws Kronolith_Exception
*/
protected function _listCalDAVEvents(
$startDate = null, $endDate = null, $showRecurrence = false,
$hasAlarm = false, $json = false, $coverDates = true,
$hideExceptions = false
)
{
if (!is_null($startDate)) {
$startDate = clone $startDate;
$startDate->hour = $startDate->min = $startDate->sec = 0;
}
if (!is_null($endDate)) {
$endDate = clone $endDate;
$endDate->hour = 23;
$endDate->min = $endDate->sec = 59;
}
/* Build report query. */
$xml = new XMLWriter();
$xml->openMemory();
$xml->setIndent(true);
$xml->startDocument();
$xml->startElementNS('C', 'calendar-query', 'urn:ietf:params:xml:ns:caldav');
$xml->writeAttribute('xmlns:D', 'DAV:');
$xml->startElement('D:prop');
$xml->writeElement('D:getetag');
$xml->startElement('C:calendar-data');
$xml->startElement('C:comp');
$xml->writeAttribute('name', 'VCALENDAR');
$xml->startElement('C:comp');
$xml->writeAttribute('name', 'VEVENT');
$xml->endElement();
$xml->endElement();
$xml->endElement();
$xml->endElement();
$xml->startElement('C:filter');
$xml->startElement('C:comp-filter');
$xml->writeAttribute('name', 'VCALENDAR');
$xml->startElement('C:comp-filter');
$xml->writeAttribute('name', 'VEVENT');
if (!is_null($startDate) ||
!is_null($endDate)) {
$xml->startElement('C:time-range');
if (!is_null($startDate)) {
$xml->writeAttribute('start', $startDate->toiCalendar());
}
if (!is_null($endDate)) {
$xml->writeAttribute('end', $endDate->toiCalendar());
}
}
$xml->endDocument();
$url = $this->_getUrl();
list($response, $events) = $this->_request('REPORT', $url, $xml,
array('Depth' => 1));
if (!$events->children('DAV:')->response) {
return array();
}
if (isset($response['headers']['content-location'])) {
$path = $response['headers']['content-location'];
} else {
$parsedUrl = parse_url($url);
$path = $parsedUrl['path'];
}
$results = array();
foreach ($events->children('DAV:')->response as $response) {
if (!$response->children('DAV:')->propstat) {
continue;
}
$ical = new Horde_Icalendar();
try {
$ical->parsevCalendar($response->children('DAV:')->propstat->prop->children('urn:ietf:params:xml:ns:caldav')->{'calendar-data'});
} catch (Horde_Icalendar_Exception $e) {
throw new Kronolith_Exception($e);
}
$this->_processComponents(
$results, $ical, $startDate, $endDate, $showRecurrence, $json,
$coverDates, $hideExceptions,
trim(str_replace($path, '', $response->href), '/')
);
}
return $results;
}
/**
* Processes the components of a Horde_Icalendar container into an event
* list.
*
* @param array $results Gets filled with the events in the
* given time range.
* @param Horde_Icalendar $ical An Horde_Icalendar container.
* @param Horde_Date $startInterval Start of range date.
* @param Horde_Date $endInterval End of range date.
* @param boolean $showRecurrence Return every instance of a recurring
* event? If false, will only return
* recurring events once inside the
* $startDate - $endDate range.
* @param boolean $json Store the results of the events'
* toJson() method?
* @param boolean $coverDates Whether to add the events to all days
* that they cover.
* $param boolean $hideExceptions Hide events that represent exceptions
* to a recurring event.
* @param string $id Enforce a certain event id (not UID).
*
* @throws Kronolith_Exception
*/
protected function _processComponents(
&$results, $ical, $startDate, $endDate, $showRecurrence, $json,
$coverDates, $hideExceptions, $id = null
)
{
$components = $ical->getComponents();
$events = array();
$count = count($components);
$exceptions = array();
for ($i = 0; $i < $count; $i++) {
$component = $components[$i];
if ($component->getType() == 'vEvent') {
try {
$event = new Kronolith_Event_Ical($this, $component);
} catch (Kronolith_Exception $e) {
Horde::log(
sprintf(
'Failed parse event from remote calendar: url = "%s"',
$this->calendar
),
'INFO'
);
continue;
}
$event->permission = $this->getPermission();
// Force string so JSON encoding is consistent across drivers.
$event->id = $id ? $id : 'ical' . $i;
/* Catch RECURRENCE-ID attributes which mark single recurrence
* instances. */
try {
$recurrence_id = $component->getAttribute('RECURRENCE-ID');
if (is_int($recurrence_id) &&
is_string($uid = $component->getAttribute('UID')) &&
is_int($seq = $component->getAttribute('SEQUENCE'))) {
$exceptions[$uid][$seq] = $recurrence_id;
if ($hideExceptions) {
continue;
}
$event->id .= '/' . $recurrence_id;
}
} catch (Horde_Icalendar_Exception $e) {}
/* Ignore events out of the period. */
$recurs = $event->recurs();
if (
/* Starts after the period. */
($endDate && $event->start->compareDateTime($endDate) > 0) ||
/* End before the period and doesn't recur. */
($startDate && !$recurs &&
$event->end->compareDateTime($startDate) < 0)) {
continue;
}
if ($recurs && $startDate) {
// Fixed end date? Check if end is before start period.
if ($event->recurrence->hasRecurEnd() &&
$event->recurrence->recurEnd->compareDateTime($startDate) < 0) {
continue;
} elseif ($endDate) {
$next = $event->recurrence->nextRecurrence($startDate);
if ($next == false || $next->compareDateTime($endDate) > 0) {
continue;
}
}
}
$events[] = $event;
}
}
/* Loop through all explicitly defined recurrence intances and create
* exceptions for those in the event with the matching recurrence. */
foreach ($events as $key => $event) {
if ($event->recurs() &&
isset($exceptions[$event->uid][$event->sequence])) {
$timestamp = $exceptions[$event->uid][$event->sequence];
$events[$key]->recurrence->addException(date('Y', $timestamp), date('m', $timestamp), date('d', $timestamp));
}
Kronolith::addEvents($results, $event, $startDate, $endDate,
$showRecurrence, $json, $coverDates);
}
}
/**
* @throws Kronolith_Exception
* @throws Horde_Exception_NotFound
*/
public function getEvent($eventId = null)
{
if (!$eventId) {
$event = new Kronolith_Event_Ical($this);
$event->permission = $this->getPermission();
return $event;
}
if ($this->isCalDAV()) {
if (preg_match('/(.*)-(\d+)$/', $eventId, $matches)) {
$eventId = $matches[1];
//$recurrenceId = $matches[2];
}
$url = trim($this->_getUrl(), '/') . '/' . $eventId;
try {
$response = $this->_getClient($url)->request('GET');
} catch (Horde_Dav_Exception $e) {
throw new Kronolith_Exception($e);
}
if ($response['statusCode'] == 200) {
$ical = new Horde_Icalendar();
try {
$ical->parsevCalendar($response['body']);
} catch (Horde_Icalendar_Exception $e) {
throw new Kronolith_Exception($e);
}
$results = array();
$this->_processComponents($results, $ical, null, null, false,
false, false, false, $eventId);
$event = reset(reset($results));
if (!$event) {
throw new Horde_Exception_NotFound(_("Event not found"));
}
return $event;
}
}
$eventId = str_replace('ical', '', $eventId);
$ical = $this->getRemoteCalendar();
$components = $ical->getComponents();
if (isset($components[$eventId]) &&
$components[$eventId]->getType() == 'vEvent') {
$event = new Kronolith_Event_Ical($this, $components[$eventId]);
$event->permission = $this->getPermission();
$event->id = 'ical' . $eventId;
return $event;
}
throw new Horde_Exception_NotFound(_("Event not found"));
}
/**
* Updates an existing event in the backend.
*
* @param Kronolith_Event $event The event to save.
*
* @return string The event id.
* @throws Horde_Mime_Exception
* @throws Kronolith_Exception
*/
protected function _updateEvent(Kronolith_Event $event)
{
$response = $this->_saveEvent($event);
if (!in_array($response['statusCode'], array(200, 204))) {
Horde::log(sprintf('Failed to update event on remote calendar: url = "%s", status = %s',
$response['url'], $response['statusCode']), 'INFO');
throw new Kronolith_Exception(_("The event could not be updated on the remote server."));
}
return $event->id;
}
/**
* Adds an event to the backend.
*
* @param Kronolith_Event $event The event to save.
*
* @return string The event id.
* @throws Horde_Mime_Exception
* @throws Kronolith_Exception
*/
protected function _addEvent(Kronolith_Event $event)
{
if (!$event->uid) {
$event->uid = (string)new Horde_Support_Uuid;
}
if (!$event->id) {
$event->id = $event->uid . '.ics';
}
$response = $this->_saveEvent($event);
if (!in_array($response['statusCode'], array(200, 201, 204))) {
Horde::log(sprintf('Failed to create event on remote calendar: status = %s',
$response['statusCode']), 'INFO');
throw new Kronolith_Exception(_("The event could not be added to the remote server."));
}
return $event->id;
}
/**
* Updates an existing event in the backend.
*
* @param Kronolith_Event $event The event to save.
*
* @return string The event id.
* @throws Horde_Mime_Exception
* @throws Kronolith_Exception
*/
protected function _saveEvent($event)
{
$ical = new Horde_Icalendar();
$ical->addComponent($event->toiCalendar($ical));
$url = trim($this->_getUrl(), '/') . '/' . $event->id;
try {
return $this->_getClient($url)
->request(
'PUT',
'',
$ical->exportvCalendar(),
array('Content-Type' => 'text/calendar')
);
} catch (Horde_Dav_Exception $e) {
Horde::log($e, 'INFO');
throw new Kronolith_Exception($e);
}
}
/**
* Deletes an event.
*
* @param string $eventId The ID of the event to delete.
* @param boolean $silent Don't send notifications, used when deleting
* events in bulk from maintenance tasks.
*
* @throws Kronolith_Exception
* @throws Horde_Exception_NotFound
* @throws Horde_Mime_Exception
*/
protected function _deleteEvent($eventId, $silent = false)
{
/* Fetch the event for later use. */
if ($eventId instanceof Kronolith_Event) {
$event = $eventId;
$eventId = $event->id;
} else {
$event = $this->getEvent($eventId);
}
if (!$this->isCalDAV()) {
throw new Kronolith_Exception(_("Deleting events is not supported with this remote calendar."));
}
if (preg_match('/(.*)-(\d+)$/', $eventId)) {
throw new Kronolith_Exception(_("Cannot delete exceptions (yet)."));
}
$url = trim($this->_getUrl(), '/') . '/' . $eventId;
try {
$response = $this->_getClient($url)->request('DELETE');
} catch (Horde_Dav_Exception $e) {
Horde::log($e, 'INFO');
throw new Kronolith_Exception($e);
}
if (!in_array($response['statusCode'], array(200, 202, 204))) {
Horde::log(sprintf('Failed to delete event from remote calendar: url = "%s", status = %s',
$url, $response['statusCode']), 'INFO');
throw new Kronolith_Exception(_("The event could not be deleted from the remote server."));
}
return $event;
}
/**
* Fetches a remote calendar into the cache and return the data.
*
* @param boolean $cache Whether to return data from the cache.
*
* @return Horde_Icalendar The calendar data.
* @throws Kronolith_Exception
*/
public function getRemoteCalendar($cache = true)
{
$url = $this->_getUrl();
$cacheOb = $GLOBALS['injector']->getInstance('Horde_Cache');
$cacheVersion = 2;
$signature = 'kronolith_remote_' . $cacheVersion . '_' . $url . '_' . serialize($this->_params);
if ($cache) {
$calendar = $cacheOb->get($signature, 3600);
if ($calendar) {
$calendar = unserialize($calendar);
if (!is_object($calendar)) {
throw new Kronolith_Exception($calendar);
}
return $calendar;
}
}
try {
$response = $this->_getClient($url)->request('GET');
if ($response['statusCode'] != 200) {
throw new Horde_Dav_Exception('Request Failed', $response['statusCode']);
}
} catch (Horde_Dav_Exception $e) {
Horde::log(
sprintf('Failed to retrieve remote calendar: url = "%s", status = %s',
$url, $e->getCode()),
'INFO'
);
$error = sprintf(
_("Could not open %s: %s"),
$url, $e->getMessage()
);
if ($cache) {
$cacheOb->set($signature, serialize($error));
}
throw new Kronolith_Exception($error, $e->getCode());
}
/* Log fetch at DEBUG level. */
Horde::log(
sprintf('Retrieved remote calendar for %s: url = "%s"',
$GLOBALS['registry']->getAuth(), $url),
'DEBUG'
);
$ical = new Horde_Icalendar();
try {
$ical->parsevCalendar($response['body']);
} catch (Horde_Icalendar_Exception $e) {
if ($cache) {
$cacheOb->set($signature, serialize($e->getMessage()));
}
throw new Kronolith_Exception($e);
}
if ($cache) {
$cacheOb->set($signature, serialize($ical));
}
return $ical;
}
/**
* Returns whether the remote calendar is a CalDAV server, and propagates
* the $_davSupport propery with the server's DAV capabilities.
*
* @return boolean True if the remote calendar is a CalDAV server.
* @throws Kronolith_Exception
*/
public function isCalDAV()
{
if (isset($this->_davSupport)) {
return $this->_davSupport
? in_array('calendar-access', $this->_davSupport)
: false;
}
$client = $this->_getClient($this->_getUrl());
try {
$this->_davSupport = $client->options();
} catch (Horde_Dav_Exception $e) {
if ($e->getCode() != 405) {
Horde::log($e, 'INFO');
}
$this->_davSupport = false;
return false;
}
if (!$this->_davSupport) {
$this->_davSupport = false;
return false;
}
if (!in_array('calendar-access', $this->_davSupport)) {
return false;
}
/* Check if this URL is a collection. */
try {
$properties = $client->propfind(
'',
array('{DAV:}resourcetype', '{DAV:}current-user-privilege-set')
);
} catch (\Sabre\DAV\Exception $e) {
Horde::log($e, 'INFO');
return false;
} catch (Horde_Dav_Exception $e) {
Horde::log($e, 'INFO');
return false;
}
if (!$properties['{DAV:}resourcetype']->is('{DAV:}collection')) {
throw new Kronolith_Exception(_("The remote server URL does not point to a CalDAV directory."));
}
/* Read ACLs. */
if (!empty($properties['{DAV:}current-user-privilege-set'])) {
$privileges = $properties['{DAV:}current-user-privilege-set'];
if ($privileges->has('{DAV:}read')) {
/* GET access. */
$this->_permission |= Horde_Perms::SHOW;
$this->_permission |= Horde_Perms::READ;
}
if ($privileges->has('{DAV:}write') ||
$privileges->has('{DAV:}write-content')) {
/* PUT access. */
$this->_permission |= Horde_Perms::EDIT;
}
if ($privileges->has('{DAV:}unbind')) {
/* DELETE access. */
$this->_permission |= Horde_Perms::DELETE;
}
}
return true;
}
/**
* Returns the permissions for the current calendar.
*
* @return integer A Horde_Perms permission bit mask.
*/
public function getPermission()
{
if ($this->isCalDAV()) {
return $this->_permission;
}
return Horde_Perms::SHOW | Horde_Perms::READ;
}
/**
* Sends a CalDAV request.
*
* @param string $method A request method.
* @param string $url A request URL.
* @param XMLWriter $xml An XMLWriter object with the body content.
* @param array $headers A hash with additional request headers.
*
* @return array The Horde_Http_Response object and the parsed
* SimpleXMLElement results.
* @throws Kronolith_Exception
*/
protected function _request($method, $url, XMLWriter $xml = null,
array $headers = array())
{
try {
$response = $this->_getClient($url)
->request($method,
'',
$xml ? $xml->outputMemory() : null,
array_merge(array('Cache-Control' => 'no-cache',
'Pragma' => 'no-cache',
'Content-Type' => 'application/xml'),
$headers));
} catch (Horde_Dav_Exception $e) {
Horde::log($e, 'INFO');
throw new Kronolith_Exception($e);
}
if ($response['statusCode'] != 207) {
throw new Kronolith_Exception(_("Unexpected response from remote server."));
}
libxml_use_internal_errors(true);
try {
$xml = new SimpleXMLElement($response['body']);
} catch (Exception $e) {
throw new Kronolith_Exception($e);
}
return array($response, $xml);
}
/**
* Returns the URL of this calendar.
*
* Does any necessary trimming and URL scheme fixes on the user-provided
* calendar URL.
*
* @return string The URL of this calendar.
*/
protected function _getUrl()
{
$url = trim($this->calendar);
if (strpos($url, 'http') !== 0) {
$url = str_replace(array('webcal://', 'webdav://', 'webdavs://'),
array('http://', 'http://', 'https://'),
$url);
}
return $url;
}
/**
* Returns a configured, cached DAV client.
*
* @param string $uri The base URI for any requests.
*
* @return Horde_Dav_Client A DAV client.
*/
protected function _getClient($uri)
{
$hordeOptions = array(
'request.timeout' => isset($this->_params['timeout'])
? $this->_params['timeout']
: 5
);
$sabreOptions = array('baseUri' => $uri);
if (!empty($this->_params['user'])) {
$hordeOptions['request.username'] = $sabreOptions['userName'] = $this->_params['user'];
$hordeOptions['request.password'] = $sabreOptions['password'] = $this->_params['password'];
}
$sabreOptions['client'] = $GLOBALS['injector']
->getInstance('Horde_Core_Factory_HttpClient')
->create($hordeOptions);
$this->_client = new Horde_Dav_Client($sabreOptions);
return $this->_client;
}
}