diff --git a/plugins/calendar/drivers/caldav/caldav_driver.php b/plugins/calendar/drivers/caldav/caldav_driver.php index 43aa3b59..3603a5f5 100644 --- a/plugins/calendar/drivers/caldav/caldav_driver.php +++ b/plugins/calendar/drivers/caldav/caldav_driver.php @@ -1,690 +1,695 @@ * * Copyright (C) 2012-2022, Apheleia IT AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ require_once(__DIR__ . '/../kolab/kolab_driver.php'); class caldav_driver extends kolab_driver { // features this backend supports public $alarms = true; public $attendees = true; public $freebusy = true; public $attachments = true; public $undelete = false; // TODO public $alarm_types = ['DISPLAY', 'AUDIO']; public $categoriesimmutable = true; protected $scheduling_properties = ['start', 'end', 'location']; /** * Default constructor */ public function __construct($cal) { $cal->require_plugin('libkolab'); // load helper classes *after* libkolab has been loaded (#3248) require_once(__DIR__ . '/caldav_calendar.php'); // require_once(__DIR__ . '/kolab_user_calendar.php'); // require_once(__DIR__ . '/caldav_invitation_calendar.php'); $this->cal = $cal; $this->rc = $cal->rc; // Initialize the CalDAV storage $url = $this->rc->config->get('calendar_caldav_server', 'http://localhost'); $this->storage = new kolab_storage_dav($url); $this->cal->register_action('push-freebusy', [$this, 'push_freebusy']); $this->cal->register_action('calendar-acl', [$this, 'calendar_acl']); // $this->freebusy_trigger = $this->rc->config->get('calendar_freebusy_trigger', false); if (!$this->rc->config->get('kolab_freebusy_server', false)) { $this->freebusy = false; } // TODO: get configuration for the Bonnie API // $this->bonnie_api = libkolab::get_bonnie_api(); } /** * Read available calendars from server */ protected function _read_calendars() { // already read sources if (isset($this->calendars)) { return $this->calendars; } // get all folders that support VEVENT, sorted by namespace/name $folders = $this->storage->get_folders('event'); // + $this->storage->get_user_folders('event', true); $this->calendars = []; foreach ($folders as $folder) { $calendar = $this->_to_calendar($folder); if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; if ($calendar->editable) { $this->has_writeable = true; } } } return $this->calendars; } /** * Convert kolab_storage_folder into caldav_calendar * * @return caldav_calendar|kolab_user_calendar */ protected function _to_calendar($folder) { if ($folder instanceof caldav_calendar) { return $folder; } if ($folder instanceof kolab_storage_folder_user) { $calendar = new kolab_user_calendar($folder, $this->cal); $calendar->subscriptions = count($folder->children) > 0; } else { $calendar = new caldav_calendar($folder, $this->cal); } return $calendar; } /** * Get a list of available calendars from this source. * * @param int $filter Bitmask defining filter criterias * @param ?kolab_storage_folder_virtual $tree Reference to hierarchical folder tree object * * @return array List of calendars */ public function list_calendars($filter = 0, &$tree = null) { $this->_read_calendars(); $folders = $this->filter_calendars($filter); $calendars = []; $prefs = $this->rc->config->get('kolab_calendars', []); // include virtual folders for a full folder tree /* if (!is_null($tree)) { $folders = $this->storage->folder_hierarchy($folders, $tree); } */ $parents = array_keys($this->calendars); foreach ($folders as $id => $cal) { $parent_id = null; /* $path = explode('/', $cal->name); // find parent do { array_pop($path); $parent_id = $this->storage->folder_id(implode('/', $path)); } while (count($path) > 1 && !in_array($parent_id, $parents)); // restore "real" parent ID if ($parent_id && !in_array($parent_id, $parents)) { $parent_id = $this->storage->folder_id($cal->get_parent()); } $parents[] = $cal->id; if ($cal instanceof kolab_storage_folder_virtual) { $calendars[$cal->id] = [ 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_foldername(), 'editname' => $cal->get_foldername(), 'virtual' => true, 'editable' => false, 'group' => $cal->get_namespace(), ]; } else { */ // additional folders may come from kolab_storage_dav::folder_hierarchy() above // make sure we deal with caldav_calendar instances $cal = $this->_to_calendar($cal); $this->calendars[$cal->id] = $cal; $is_user = false; // ($cal instanceof caldav_user_calendar); $calendars[$cal->id] = [ 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_foldername(), 'editname' => $cal->get_foldername(), 'title' => '', // $cal->get_title(), 'color' => $cal->get_color(), 'editable' => $cal->editable, 'group' => $is_user ? 'other user' : $cal->get_namespace(), // @phpstan-ignore-line 'active' => !isset($prefs[$cal->id]['active']) || !empty($prefs[$cal->id]['active']), 'owner' => $cal->get_owner(), 'removable' => !$cal->default, // extras to hide some elements in the UI 'subscriptions' => $cal->subscriptions, 'driver' => 'caldav', ]; // @phpstan-ignore-next-line if (!$is_user) { $calendars[$cal->id] += [ 'default' => $cal->default, 'rights' => $cal->rights, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'children' => true, // TODO: determine if that folder indeed has child folders 'parent' => $parent_id, 'subtype' => $cal->subtype, 'caldavurl' => '', // $cal->get_caldav_url(), ]; } /* } */ if ($cal->subscriptions) { $calendars[$cal->id]['subscribed'] = $cal->is_subscribed(); } } /* // list virtual calendars showing invitations if ($this->rc->config->get('kolab_invitation_calendars') && !($filter & self::FILTER_INSERTABLE)) { foreach ([self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED] as $id) { $cal = new caldav_invitation_calendar($id, $this->cal); if (!($filter & self::FILTER_ACTIVE) || $cal->is_active()) { $calendars[$id] = [ 'id' => $cal->id, 'name' => $cal->get_name(), 'listname' => $cal->get_name(), 'editname' => $cal->get_foldername(), 'title' => $cal->get_title(), 'color' => $cal->get_color(), 'editable' => $cal->editable, 'rights' => $cal->rights, 'showalarms' => $cal->alarms, 'history' => !empty($this->bonnie_api), 'group' => 'x-invitations', 'default' => false, 'active' => $cal->is_active(), 'owner' => $cal->get_owner(), 'children' => false, 'counts' => $id == self::INVITATIONS_CALENDAR_PENDING, ]; if (is_object($tree)) { $tree->children[] = $cal; } } } } */ // append the virtual birthdays calendar if ($this->rc->config->get('calendar_contact_birthdays', false) && !($filter & self::FILTER_INSERTABLE)) { $id = self::BIRTHDAY_CALENDAR_ID; $prefs = $this->rc->config->get('kolab_calendars', []); // read local prefs if (!($filter & self::FILTER_ACTIVE) || !empty($prefs[$id]['active'])) { $calendars[$id] = [ 'id' => $id, 'name' => $this->cal->gettext('birthdays'), 'listname' => $this->cal->gettext('birthdays'), 'color' => !empty($prefs[$id]['color']) ? $prefs[$id]['color'] : '87CEFA', 'active' => !empty($prefs[$id]['active']), 'showalarms' => (bool) $this->rc->config->get('calendar_birthdays_alarm_type'), 'group' => 'x-birthdays', 'editable' => false, 'default' => false, 'children' => false, 'history' => false, ]; } } return $calendars; } /** * Get the caldav_calendar instance for the given calendar ID * * @param string $id Calendar identifier * * @return caldav_calendar|caldav_invitation_calendar|null Object nor null if calendar doesn't exist */ public function get_calendar($id) { $this->_read_calendars(); // create calendar object if necessary if (empty($this->calendars[$id])) { if (in_array($id, [self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED])) { return new caldav_invitation_calendar($id, $this->cal); } // for unsubscribed calendar folders if ($id !== self::BIRTHDAY_CALENDAR_ID) { $calendar = caldav_calendar::factory($id, $this->cal); if ($calendar->ready) { $this->calendars[$calendar->id] = $calendar; } } } return !empty($this->calendars[$id]) ? $this->calendars[$id] : null; } /** * Create a new calendar assigned to the current user * * @param array $prop Hash array with calendar properties * name: Calendar name * color: The color of the calendar * * @return mixed ID of the calendar on success, False on error */ public function create_calendar($prop) { $prop['type'] = 'event'; $prop['alarms'] = !empty($prop['showalarms']); $id = $this->storage->folder_update($prop); if ($id === false) { return false; } $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); $prefs['kolab_calendars'][$id]['active'] = true; $this->rc->user->save_prefs($prefs); return $id; } /** * Update properties of an existing calendar * * @see calendar_driver::edit_calendar() */ public function edit_calendar($prop) { $id = $prop['id']; if (!in_array($id, [self::BIRTHDAY_CALENDAR_ID, self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED])) { $prop['type'] = 'event'; $prop['alarms'] = !empty($prop['showalarms']); return $this->storage->folder_update($prop) !== false; } // fallback to local prefs for special calendars $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); unset($prefs['kolab_calendars'][$id]['showalarms']); if (isset($prop['showalarms']) && $id == self::BIRTHDAY_CALENDAR_ID) { $prefs['calendar_birthdays_alarm_type'] = $prop['showalarms'] ? $this->alarm_types[0] : ''; } elseif (isset($prop['showalarms'])) { $prefs['kolab_calendars'][$id]['showalarms'] = !empty($prop['showalarms']); } if (!empty($prefs['kolab_calendars'][$id])) { $this->rc->user->save_prefs($prefs); } return true; } /** * Set active/subscribed state of a calendar * * @see calendar_driver::subscribe_calendar() */ public function subscribe_calendar($prop) { if (empty($prop['id'])) { return false; } // save state in local prefs if (isset($prop['active'])) { $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); $prefs['kolab_calendars'][$prop['id']]['active'] = !empty($prop['active']); $this->rc->user->save_prefs($prefs); } return true; } /** * Delete the given calendar with all its contents * * @see calendar_driver::delete_calendar() */ public function delete_calendar($prop) { if (!empty($prop['id'])) { if ($this->storage->folder_delete($prop['id'], 'event')) { // remove folder from user prefs $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); if (isset($prefs['kolab_calendars'][$prop['id']])) { unset($prefs['kolab_calendars'][$prop['id']]); $this->rc->user->save_prefs($prefs); } return true; } } return false; } /** * Search for shared or otherwise not listed calendars the user has access * * @param string $query Search string * @param string $source Section/source to search * * @return array List of calendars */ public function search_calendars($query, $source) { $this->calendars = []; $this->search_more_results = false; /* // find unsubscribed IMAP folders that have "event" type if ($source == 'folders') { foreach ((array) $this->storage->search_folders('event', $query, ['other']) as $folder) { $calendar = new kolab_calendar($folder->name, $this->cal); $this->calendars[$calendar->id] = $calendar; } } // find other user's virtual calendars else if ($source == 'users') { // we have slightly more space, so display twice the number $limit = $this->rc->config->get('autocomplete_max', 15) * 2; foreach ($this->storage->search_users($query, 0, [], $limit, $count) as $user) { $calendar = new caldav_user_calendar($user, $this->cal); $this->calendars[$calendar->id] = $calendar; // search for calendar folders shared by this user foreach ($this->storage->list_user_folders($user, 'event', false) as $foldername) { $cal = new caldav_calendar($foldername, $this->cal); $this->calendars[$cal->id] = $cal; $calendar->subscriptions = true; } } if ($count > $limit) { $this->search_more_results = true; } } // don't list the birthday calendar $this->rc->config->set('calendar_contact_birthdays', false); $this->rc->config->set('kolab_invitation_calendars', false); */ return $this->list_calendars(); } /** * Get events from source. * * @param int $start Event's new start (unix timestamp) * @param int $end Event's new end (unix timestamp) * @param string $search Search query (optional) * @param mixed $calendars List of calendar IDs to load events from (either as array or comma-separated string) * @param bool $virtual Include virtual events (optional) * @param int $modifiedsince Only list events modified since this time (unix timestamp) * * @return array A list of event records */ public function load_events($start, $end, $search = null, $calendars = null, $virtual = true, $modifiedsince = null) { if ($calendars && is_string($calendars)) { $calendars = explode(',', $calendars); } elseif (!$calendars) { $this->_read_calendars(); $calendars = array_keys($this->calendars); } $query = []; $events = []; $categories = []; if ($modifiedsince) { $query[] = ['changed', '>=', $modifiedsince]; } foreach ($calendars as $cid) { if ($storage = $this->get_calendar($cid)) { $events = array_merge($events, $storage->list_events($start, $end, $search, $virtual, $query)); $categories += $storage->categories; } } // add events from the address books birthday calendar if (in_array(self::BIRTHDAY_CALENDAR_ID, $calendars)) { $events = array_merge($events, $this->load_birthday_events($start, $end, $search, $modifiedsince)); } // add new categories to user prefs $old_categories = $this->rc->config->get('calendar_categories', $this->default_categories); $newcats = array_udiff( array_keys($categories), array_keys($old_categories), function ($a, $b) { return strcasecmp($a, $b); } ); if (!empty($newcats)) { foreach ($newcats as $category) { $old_categories[$category] = ''; // no color set yet } $this->rc->user->save_prefs(['calendar_categories' => $old_categories]); } array_walk($events, 'caldav_driver::to_rcube_event'); return $events; } /** * Create instances of a recurring event * * @param array $event Hash array with event properties * @param DateTime $start Start date of the recurrence window * @param ?DateTime $end End date of the recurrence window * * @return array List of recurring event instances */ public function get_recurring_events($event, $start, $end = null) { $this->_read_calendars(); $storage = reset($this->calendars); return $storage->get_recurring_events($event, $start, $end); } /** * */ protected function get_recurrence_count($event, $dtstart) { // use libkolab to compute recurring events $recurrence = libcalendaring::get_recurrence($event); $count = 0; while (($next_event = $recurrence->next_instance()) && $next_event['start'] <= $dtstart && $count < 1000) { $count++; } return $count; } /** * Determine whether the current change affects scheduling and reset attendee status accordingly */ protected function check_scheduling(&$event, $old, $update = true) { // skip this check when importing iCal/iTip events if (isset($event['sequence']) || !empty($event['_method'])) { return false; } // iterate through the list of properties considered 'significant' for scheduling $reschedule = $this->is_rescheduling_needed($event, $old); // reset all attendee status to needs-action (#4360) if ($update && $reschedule && !empty($event['attendees'])) { $is_organizer = false; $emails = $this->cal->get_user_emails(); $attendees = $event['attendees']; foreach ($attendees as $i => $attendee) { if ($attendee['role'] == 'ORGANIZER' && !empty($attendee['email']) && in_array(strtolower($attendee['email']), $emails) ) { $is_organizer = true; } elseif ($attendee['role'] != 'ORGANIZER' && $attendee['role'] != 'NON-PARTICIPANT' && $attendee['status'] != 'DELEGATED' ) { $attendees[$i]['status'] = 'NEEDS-ACTION'; $attendees[$i]['rsvp'] = true; } } // update attendees only if I'm the organizer if ($is_organizer || (!empty($event['organizer']) && in_array(strtolower($event['organizer']['email']), $emails))) { $event['attendees'] = $attendees; } } return $reschedule; } /** * Identify changes considered relevant for scheduling * * @param array $object Hash array with NEW object properties * @param array $old Hash array with OLD object properties * * @return bool True if changes affect scheduling, False otherwise */ protected function is_rescheduling_needed($object, $old = null) { $reschedule = false; foreach ($this->scheduling_properties as $prop) { $a = $old[$prop] ?? null; $b = $object[$prop] ?? null; if (!empty($object['allday']) && ($prop == 'start' || $prop == 'end') && $a instanceof DateTimeInterface && $b instanceof DateTimeInterface ) { $a = $a->format('Y-m-d'); $b = $b->format('Y-m-d'); } if ($prop == 'recurrence' && is_array($a) && is_array($b)) { unset($a['EXCEPTIONS'], $b['EXCEPTIONS']); $a = array_filter($a); $b = array_filter($b); // advanced rrule comparison: no rescheduling if series was shortened if ($a['COUNT'] && $b['COUNT'] && $b['COUNT'] < $a['COUNT']) { unset($a['COUNT'], $b['COUNT']); } elseif ($a['UNTIL'] && $b['UNTIL'] && $b['UNTIL'] < $a['UNTIL']) { unset($a['UNTIL'], $b['UNTIL']); } } if ($a != $b) { $reschedule = true; break; } } return $reschedule; } /** * Callback function to produce driver-specific calendar create/edit form * * @param string $action Request action 'form-edit|form-new' * @param array $calendar Calendar properties (e.g. id, color) * @param array $formfields Edit form fields * * @return string HTML content of the form */ public function calendar_form($action, $calendar, $formfields) { $special_calendars = [ self::BIRTHDAY_CALENDAR_ID, self::INVITATIONS_CALENDAR_PENDING, self::INVITATIONS_CALENDAR_DECLINED, ]; // show default dialog for birthday calendar if (in_array($calendar['id'], $special_calendars)) { if ($calendar['id'] != self::BIRTHDAY_CALENDAR_ID) { unset($formfields['showalarms']); } // General tab $form['props'] = [ 'name' => $this->rc->gettext('properties'), 'fields' => $formfields, ]; return kolab_utils::folder_form($form, '', 'calendar'); } + if ($calendar['id']) { + $cal = $this->get_calendar($calendar['id']); + $folder = $cal->storage; + } + $form['props'] = [ 'name' => $this->rc->gettext('properties'), 'fields' => [ 'location' => $formfields['name'], 'color' => $formfields['color'], 'alarms' => $formfields['showalarms'], ], ]; - return kolab_utils::folder_form($form, '', 'calendar', [], true); + return kolab_utils::folder_form($form, $folder ?? null, 'calendar', []); } } diff --git a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php index 879ac859..470055eb 100644 --- a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php +++ b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php @@ -1,1277 +1,1277 @@ * @author Aleksander Machniak * * Copyright (C) 2011-2022, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @see rcube_addressbook */ class carddav_contacts extends rcube_addressbook { public $primary_key = 'ID'; public $rights = 'lrs'; public $readonly = true; public $undelete = false; public $groups = true; public $coltypes = [ 'name' => ['limit' => 1], 'firstname' => ['limit' => 1], 'surname' => ['limit' => 1], 'middlename' => ['limit' => 1], 'prefix' => ['limit' => 1], 'suffix' => ['limit' => 1], 'nickname' => ['limit' => 1], 'jobtitle' => ['limit' => 1], 'organization' => ['limit' => 1], 'department' => ['limit' => 1], 'email' => ['subtypes' => ['home','work','other']], 'phone' => [], 'address' => ['subtypes' => ['home','work','office']], 'website' => ['subtypes' => ['homepage','blog']], 'im' => ['subtypes' => null], 'gender' => ['limit' => 1], 'birthday' => ['limit' => 1], 'anniversary' => ['limit' => 1], 'manager' => ['limit' => null], 'assistant' => ['limit' => null], 'spouse' => ['limit' => 1], 'notes' => ['limit' => 1], 'photo' => ['limit' => 1], ]; public $vcard_map = [ // 'profession' => 'X-PROFESSION', // 'officelocation' => 'X-OFFICE-LOCATION', // 'initials' => 'X-INITIALS', // 'children' => 'X-CHILDREN', // 'freebusyurl' => 'X-FREEBUSY-URL', // 'pgppublickey' => 'KEY', 'uid' => 'UID', ]; /** * List of date type fields */ public $date_cols = ['birthday', 'anniversary']; public $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email']; + public $storage; private $gid; - private $storage; private $dataset; private $sortindex; private $contacts; private $distlists; private $groupmembers; private $filter; private $result; private $namespace; // list of fields used for searching in "All fields" mode private $search_fields = [ 'name', 'firstname', 'surname', 'middlename', 'prefix', 'suffix', 'nickname', 'jobtitle', 'organization', 'department', 'email', 'phone', 'address', // 'profession', 'manager', 'assistant', 'spouse', // 'children', 'notes', ]; /** * Object constructor */ public function __construct($dav_folder = null) { $this->storage = $dav_folder; $this->ready = !empty($this->storage); // Set readonly and rights flags according to folder permissions if ($this->ready) { if ($this->storage->get_owner() == $_SESSION['username']) { $this->readonly = false; $this->rights = 'lrswikxtea'; } else { $rights = $this->storage->get_myrights(); if ($rights && !PEAR::isError($rights)) { $this->rights = $rights; if (strpos($rights, 'i') !== false && strpos($rights, 't') !== false) { $this->readonly = false; } } } } } /** * Getter for the address book name to be displayed * * @return string Name of this address book */ public function get_name() { return $this->storage->get_name(); } /** * Wrapper for kolab_storage_folder::get_foldername() */ public function get_foldername() { return $this->storage->get_foldername(); } /** * Getter for the folder name * * @return string Name of the folder */ public function get_realname() { return $this->get_name(); } /** * Getter for the name of the namespace to which the IMAP folder belongs * * @return string Name of the namespace (personal, other, shared) */ public function get_namespace() { if ($this->namespace === null && $this->ready) { $this->namespace = $this->storage->get_namespace(); } return $this->namespace; } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { return $this->storage->get_parent(); } /** * Check subscription status of this folder * * @return boolean True if subscribed, false if not */ public function is_subscribed() { return true; } /** * Compose an URL for CardDAV access to this address book (if configured) */ public function get_carddav_url() { /* $rcmail = rcmail::get_instance(); if ($template = $rcmail->config->get('kolab_addressbook_carddav_url', null)) { return strtr($template, [ '%h' => $_SERVER['HTTP_HOST'], '%u' => urlencode($rcmail->get_user_name()), '%i' => urlencode($this->storage->get_uid()), '%n' => urlencode($this->imap_folder), ]); } */ return false; } /** * Setter for the current group */ public function set_group($gid) { $this->gid = $gid; } /** * Save a search string for future listings * * @param mixed $filter Search params to use in listing method, obtained by get_search_set() */ public function set_search_set($filter): void { $this->filter = $filter; } /** * Getter for saved search properties * * @return mixed Search properties used by this class */ public function get_search_set() { return $this->filter; } /** * Reset saved results and search parameters */ public function reset(): void { $this->result = null; $this->filter = null; } /** * List all active contact groups of this source * * @param string $search Optional search string to match group name * @param int $mode Search mode. Sum of self::SEARCH_* * * @return array Indexed list of contact groups, each a hash array */ public function list_groups($search = null, $mode = 0) { $this->_fetch_groups(); $groups = []; foreach ((array)$this->distlists as $group) { if (!$search || strstr(mb_strtolower($group['name']), mb_strtolower($search))) { $groups[$group['ID']] = ['ID' => $group['ID'], 'name' => $group['name']]; } } // sort groups by name uasort($groups, function ($a, $b) { return strcoll($a['name'], $b['name']); }); return array_values($groups); } /** * List the current set of contact records * * @param array $cols List of cols to show * @param int $subset Only return this number of records, use negative values for tail * @param bool $nocount True to skip the count query (select only) * * @return rcube_result_set Indexed list of contact records, each a hash array */ public function list_records($cols = null, $subset = 0, $nocount = false) { $this->result = new rcube_result_set(0, ($this->list_page - 1) * $this->page_size); $fetch_all = false; $fast_mode = !empty($cols) && is_array($cols); // list member of the selected group if ($this->gid) { $this->_fetch_groups(); $this->sortindex = []; $this->contacts = []; $local_sortindex = []; $uids = []; // get members with email specified foreach ((array)$this->distlists[$this->gid]['member'] as $member) { // skip member that don't match the search filter if (!empty($this->filter['ids']) && array_search($member['ID'], $this->filter['ids']) === false) { continue; } if (!empty($member['uid'])) { $uids[] = $member['uid']; } elseif (!empty($member['email'])) { $this->contacts[$member['ID']] = $member; $local_sortindex[$member['ID']] = $this->_sort_string($member); $fetch_all = true; } } // get members by UID if (!empty($uids)) { $this->_fetch_contacts($query = [['uid', '=', $uids]], $fetch_all ? false : count($uids), $fast_mode); $this->sortindex = array_merge($this->sortindex, $local_sortindex); } } elseif (isset($this->filter['ids']) && is_array($this->filter['ids'])) { $ids = $this->filter['ids']; if (count($ids)) { $uids = array_map([$this, 'id2uid'], $this->filter['ids']); $this->_fetch_contacts($query = [['uid', '=', $uids]], count($ids), $fast_mode); } } else { $this->_fetch_contacts($query = 'contact', true, $fast_mode); } if ($fetch_all) { // sort results (index only) asort($this->sortindex, SORT_LOCALE_STRING); $ids = array_keys($this->sortindex); // fill contact data into the current result set $this->result->count = count($ids); $start_row = $subset < 0 ? $this->result->first + $this->page_size + $subset : $this->result->first; $last_row = min($subset != 0 ? $start_row + abs($subset) : $this->result->first + $this->page_size, $this->result->count); for ($i = $start_row; $i < $last_row; $i++) { if (array_key_exists($i, $ids)) { $idx = $ids[$i]; $this->result->add($this->contacts[$idx] ?: $this->_to_rcube_contact($this->dataset[$idx])); } } } elseif (!empty($this->dataset)) { // get all records count, skip the query if possible if (!isset($query) || count($this->dataset) < $this->page_size) { $this->result->count = count($this->dataset) + $this->page_size * ($this->list_page - 1); } else { $this->result->count = $this->storage->count($query); } $start_row = $subset < 0 ? $this->page_size + $subset : 0; $last_row = min($subset != 0 ? $start_row + abs($subset) : $this->page_size, $this->result->count); for ($i = $start_row; $i < $last_row; $i++) { $this->result->add($this->_to_rcube_contact($this->dataset[$i])); } } return $this->result; } /** * Search records * * @param mixed $fields The field name of array of field names to search in * @param mixed $value Search value (or array of values when $fields is array) * @param int $mode Matching mode: * 0 - partial (*abc*), * 1 - strict (=), * 2 - prefix (abc*) * 4 - include groups (if supported) * @param bool $select True if results are requested, False if count only * @param bool $nocount True to skip the count query (select only) * @param array $required List of fields that cannot be empty * * @return rcube_result_set List of contact records and 'count' value */ public function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = []) { // search by ID if ($fields == $this->primary_key) { $ids = !is_array($value) ? explode(',', $value) : $value; $result = new rcube_result_set(); foreach ($ids as $id) { if ($rec = $this->get_record($id, true)) { $result->add($rec); $result->count++; } } return $result; } elseif ($fields == '*') { $fields = $this->search_fields; } if (!is_array($fields)) { $fields = [$fields]; } if (!is_array($required) && !empty($required)) { $required = [$required]; } // advanced search if (is_array($value)) { $advanced = true; $value = array_map('mb_strtolower', $value); } else { $value = mb_strtolower($value); } $scount = count($fields); // build key name regexp $regexp = '/^(' . implode('|', $fields) . ')(?:.*)$/'; // pass query to storage if only indexed cols are involved // NOTE: this is only some rough pre-filtering but probably includes false positives $squery = $this->_search_query($fields, $value, $mode); // add magic selector to select contacts with birthday dates only if (in_array('birthday', $required)) { $squery[] = ['tags', '=', 'x-has-birthday']; } $squery[] = ['type', '=', 'contact']; // get all/matching records $this->_fetch_contacts($squery); // save searching conditions $this->filter = ['fields' => $fields, 'value' => $value, 'mode' => $mode, 'ids' => []]; // search by iterating over all records in dataset foreach ($this->dataset as $record) { $contact = $this->_to_rcube_contact($record); $id = $contact['ID']; // check if current contact has required values, otherwise skip it if ($required) { foreach ($required as $f) { // required field might be 'email', but contact might contain 'email:home' $v = rcube_addressbook::get_col_values($f, $contact, true); if (count($v) === 0) { continue 2; } } } $found = []; $contents = ''; foreach (preg_grep($regexp, array_keys($contact)) as $col) { $pos = strpos($col, ':'); $colname = $pos ? substr($col, 0, $pos) : $col; foreach ((array)$contact[$col] as $val) { if (!empty($advanced)) { $found[$colname] = $this->compare_search_value($colname, $val, $value[array_search($colname, $fields)], $mode); } else { $contents .= ' ' . implode(' ', (array)$val); } } } // compare matches if ((!empty($advanced) && count($found) >= $scount) || (empty($advanced) && rcube_utils::words_match(mb_strtolower($contents), $value)) ) { $this->filter['ids'][] = $id; } } // dummy result with contacts count if (!$select) { return new rcube_result_set(count($this->filter['ids']), ($this->list_page - 1) * $this->page_size); } // list records (now limited by $this->filter) return $this->list_records(); } /** * Refresh saved search results after data has changed */ public function refresh_search() { if ($this->filter) { $this->search($this->filter['fields'], $this->filter['value'], $this->filter['mode']); } return $this->get_search_set(); } /** * Count number of available contacts in database * * @return rcube_result_set Result set with values for 'count' and 'first' */ public function count() { if ($this->gid) { $this->_fetch_groups(); $count = count($this->distlists[$this->gid]['member']); } elseif (isset($this->filter['ids']) && is_array($this->filter['ids'])) { $count = count($this->filter['ids']); } else { $count = $this->storage->count('contact'); } return new rcube_result_set($count, ($this->list_page - 1) * $this->page_size); } /** * Return the last result set * * @return ?rcube_result_set Current result set or NULL if nothing selected yet */ public function get_result() { return $this->result; } /** * Get a specific contact record * * @param mixed $id Record identifier(s) * @param bool $assoc True to return record as associative array, otherwise a result set is returned * * @return rcube_result_set|array|false Result object with all record fields or False if not found */ public function get_record($id, $assoc = false) { $rec = null; $uid = $this->id2uid($id); $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC); if (strpos($uid, 'mailto:') === 0) { $this->_fetch_groups(true); $rec = $this->contacts[$id]; $this->readonly = true; // set source to read-only } /* else if (!empty($rev)) { $rcmail = rcube::get_instance(); $plugin = $rcmail->plugins->get_plugin('kolab_addressbook'); if ($plugin && ($object = $plugin->get_revision($id, kolab_storage::id_encode($this->imap_folder), $rev))) { $rec = $this->_to_rcube_contact($object); $rec['rev'] = $rev; } $this->readonly = true; // set source to read-only } */ elseif ($object = $this->storage->get_object($uid)) { $rec = $this->_to_rcube_contact($object); } if ($rec) { $this->result = new rcube_result_set(1); $this->result->add($rec); return $assoc ? $rec : $this->result; } return false; } /** * Get group assignments of a specific contact record * * @param mixed $id Record identifier * * @return array List of assigned groups as ID=>Name pairs */ public function get_record_groups($id) { $out = []; $this->_fetch_groups(); if (!empty($this->groupmembers[$id])) { foreach ((array) $this->groupmembers[$id] as $gid) { if (!empty($this->distlists[$gid])) { $group = $this->distlists[$gid]; $out[$gid] = $group['name']; } } } return $out; } /** * Create a new contact record * * @param array $save_data Associative array with save data * Keys: Field name with optional section in the form FIELD:SECTION * Values: Field value. Can be either a string or an array of strings for multiple values * @param bool $check True to check for duplicates first * * @return mixed The created record ID on success, False on error */ public function insert($save_data, $check = false) { if (!is_array($save_data)) { return false; } $insert_id = $existing = false; // check for existing records by e-mail comparison if ($check) { foreach ($this->get_col_values('email', $save_data, true) as $email) { if (($res = $this->search('email', $email, true, false)) && $res->count) { $existing = true; break; } } } if (!$existing) { // Unset contact ID (e.g. when copying/moving from another addressbook) unset($save_data['ID'], $save_data['uid'], $save_data['_type']); // generate new Kolab contact item $object = $this->_from_rcube_contact($save_data); $saved = $this->storage->save($object, 'contact'); if (!$saved) { rcube::raise_error( [ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to CardDAV server", ], true, false ); } else { $insert_id = $object['uid']; } } return $insert_id; } /** * Update a specific contact record * * @param mixed $id Record identifier * @param array $save_data Associative array with save data * Keys: Field name with optional section in the form FIELD:SECTION * Values: Field value. Can be either a string or an array of strings for multiple values * * @return bool True on success, False on error */ public function update($id, $save_data) { $updated = false; if ($old = $this->storage->get_object($this->id2uid($id))) { $object = $this->_from_rcube_contact($save_data, $old); if (!$this->storage->save($object, 'contact', $old['uid'])) { rcube::raise_error( [ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving contact object to CardDAV server", ], true, false ); } else { $updated = true; // TODO: update data in groups this contact is member of } } return $updated; } /** * Mark one or more contact records as deleted * * @param array $ids Record identifiers * @param bool $force Remove record(s) irreversible (mark as deleted otherwise) * * @return int Number of records deleted */ public function delete($ids, $force = true) { $this->_fetch_groups(); if (!is_array($ids)) { $ids = explode(',', $ids); } $count = 0; foreach ($ids as $id) { if ($uid = $this->id2uid($id)) { $is_mailto = strpos($uid, 'mailto:') === 0; $deleted = $is_mailto || $this->storage->delete($uid, $force); if (!$deleted) { rcube::raise_error( [ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting a contact object $uid from the CardDAV server", ], true, false ); } else { // remove from distribution lists if (isset($this->groupmembers[$id])) { foreach ((array) $this->groupmembers[$id] as $gid) { if (!$is_mailto || $gid == $this->gid) { $this->remove_from_group($gid, $id); } } // clear internal cache unset($this->groupmembers[$id]); } $count++; } } } return $count; } /** * Undelete one or more contact records. * Only possible just after delete (see 2nd argument of delete() method). * * @param array $ids Record identifiers * * @return int Number of records restored */ public function undelete($ids) { if (!is_array($ids)) { $ids = explode(',', $ids); } $count = 0; foreach ($ids as $id) { $uid = $this->id2uid($id); if ($this->storage->undelete($uid)) { $count++; } else { rcube::raise_error( [ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error undeleting a contact object $uid from the CardDav server", ], true, false ); } } return $count; } /** * Remove all records from the database * * @param bool $with_groups Remove also groups */ public function delete_all($with_groups = false) { if ($this->storage->delete_all()) { $this->contacts = []; $this->sortindex = []; $this->dataset = null; $this->result = null; } } /** * Close connection to source * Called on script shutdown */ public function close() { // NOP } /** * Create a contact group with the given name * * @param string $name The group name * * @return mixed False on error, array with record props in success */ public function create_group($name) { $this->_fetch_groups(); $rcube = rcube::get_instance(); $list = [ 'uid' => strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($rcube->user->get_username()), 0, 16)), 'name' => $name, 'kind' => 'group', 'member' => [], ]; $saved = $this->storage->save($list, 'contact'); if (!$saved) { rcube::raise_error( [ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving a contact group to CardDAV server", ], true, false ); return false; } $id = $this->uid2id($list['uid']); $this->distlists[$id] = $list; return ['id' => $id, 'name' => $name]; } /** * Delete the given group and all linked group members * * @param string $gid Group identifier * * @return bool True on success, false if no data was changed */ public function delete_group($gid) { $this->_fetch_groups(); $list = $this->distlists[$gid]; if (!$list) { return false; } $deleted = $this->storage->delete($list['uid']); if (!$deleted) { rcube::raise_error( [ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error deleting a contact group from the CardDAV server", ], true, false ); } return $deleted; } /** * Rename a specific contact group * * @param string $gid Group identifier * @param string $newname New name to set for this group * @param string $newid New group identifier (if changed, otherwise don't set) * * @return string|false New name on success, false if no data was changed */ public function rename_group($gid, $newname, &$newid) { $this->_fetch_groups(); $list = $this->distlists[$gid]; if (!$list) { return false; } if ($newname === $list['name']) { return $newname; } $list['name'] = $newname; $saved = $this->storage->save($list, 'contact', $list['uid']); if (!$saved) { rcube::raise_error( [ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving a contact group to CardDAV server", ], true, false ); return false; } return $newname; } /** * Add the given contact records the a certain group * * @param string $gid Group identifier * @param array $ids List of contact identifiers to be added * * @return int Number of contacts added */ public function add_to_group($gid, $ids) { if (!is_array($ids)) { $ids = explode(',', $ids); } $this->_fetch_groups(true); $list = $this->distlists[$gid]; if (!$list) { return 0; } $added = 0; $uids = []; $exists = []; foreach ((array) $list['member'] as $member) { $exists[] = $member['ID']; } // substract existing assignments from list $ids = array_unique(array_diff($ids, $exists)); // add mailto: members foreach ($ids as $contact_id) { $uid = $this->id2uid($contact_id); if (strpos($uid, 'mailto:') === 0 && ($contact = $this->contacts[$contact_id])) { $list['member'][] = [ 'email' => $contact['email'], 'name' => $contact['name'], ]; $this->groupmembers[$contact_id][] = $gid; $added++; } else { $uids[$uid] = $contact_id; } } // add members with UID if (!empty($uids)) { foreach ($uids as $uid => $contact_id) { $list['member'][] = ['uid' => $uid]; $this->groupmembers[$contact_id][] = $gid; $added++; } } if ($added) { $saved = $this->storage->save($list, 'contact', $list['uid']); } else { $saved = true; } if (!$saved) { rcube::raise_error( [ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving a contact-group to CardDAV server", ], true, false ); $added = false; $this->set_error(self::ERROR_SAVING, 'errorsaving'); } else { $this->distlists[$gid] = $list; } return $added; } /** * Remove the given contact records from a certain group * * @param string $gid Group identifier * @param array $ids List of contact identifiers to be removed * * @return int Numer of removed contacts */ public function remove_from_group($gid, $ids) { $this->_fetch_groups(); $list = $this->distlists[$gid]; if (!$list) { return 0; } if (!is_array($ids)) { $ids = explode(',', $ids); } $new_member = []; $removed = 0; foreach ((array) $list['member'] as $member) { if (!in_array($member['ID'], $ids)) { $new_member[] = $member; } else { $removed++; } } // write distribution list back to server $list['member'] = $new_member; $saved = $this->storage->save($list, 'contact', $list['uid']); if (!$saved) { rcube::raise_error( [ 'code' => 600, 'file' => __FILE__, 'line' => __LINE__, 'message' => "Error saving a contact group to CardDAV server", ], true, false ); return 0; } // remove group assigments in local cache foreach ($ids as $id) { $j = array_search($gid, $this->groupmembers[$id]); unset($this->groupmembers[$id][$j]); } $this->distlists[$gid] = $list; return $removed; } /** * Check the given data before saving. * If input not valid, the message to display can be fetched using get_error() * * @param array $save_data Associative array with contact data to save * @param bool $autofix Attempt to fix/complete data automatically * * @return bool True if input is valid, False if not. */ public function validate(&$save_data, $autofix = false) { // validate e-mail addresses $valid = parent::validate($save_data); // require at least one e-mail address if there's no name // (syntax check is already done) if ($valid) { if (!strlen($save_data['name']) && !strlen($save_data['organization']) && !array_filter($this->get_col_values('email', $save_data, true)) ) { $this->set_error('warning', 'kolab_addressbook.noemailnamewarning'); $valid = false; } } return $valid; } /** * Query storage layer and store records in private member var */ private function _fetch_contacts($query = [], $limit = false, $fast_mode = false) { if (!isset($this->dataset) || !empty($query)) { if ($limit) { $size = is_int($limit) && $limit < $this->page_size ? $limit : $this->page_size; $this->storage->set_order_and_limit($this->_sort_columns(), $size, ($this->list_page - 1) * $this->page_size); } $this->sortindex = []; $this->dataset = $this->storage->select($query, $fast_mode); foreach ($this->dataset as $idx => $record) { $contact = $this->_to_rcube_contact($record); $this->sortindex[$idx] = $this->_sort_string($contact); } } } /** * Extract a string for sorting from the given contact record */ private function _sort_string($rec) { $str = ''; switch ($this->sort_col) { case 'name': $str = ($rec['name'] ?? '') . ($rec['prefix'] ?? ''); // no break case 'firstname': $str .= ($rec['firstname'] ?? '') . ($rec['middlename'] ?? '') . ($rec['surname'] ?? ''); break; case 'surname': $str = ($rec['surname'] ?? '') . ($rec['firstname'] ?? '') . ($rec['middlename'] ?? ''); break; default: $str = $rec[$this->sort_col] ?? ''; break; } if (!empty($rec['email'])) { $str .= is_array($rec['email']) ? $rec['email'][0] : $rec['email']; } return mb_strtolower($str); } /** * Return the cache table columns to order by */ private function _sort_columns() { $sortcols = []; switch ($this->sort_col) { case 'name': $sortcols[] = 'name'; // no break case 'firstname': $sortcols[] = 'firstname'; break; case 'surname': $sortcols[] = 'surname'; break; } $sortcols[] = 'email'; return $sortcols; } /** * Read contact groups from server */ private function _fetch_groups($with_contacts = false) { if (!isset($this->distlists)) { $this->distlists = $this->groupmembers = []; // Set order (and LIMIT to skip the count(*) select) $this->storage->set_order_and_limit(['name'], 200, 0); foreach ($this->storage->select('group', true) as $record) { $record['ID'] = $this->uid2id($record['uid']); foreach ((array)$record['member'] as $i => $member) { $mid = $this->uid2id($member['uid'] ? $member['uid'] : 'mailto:' . $member['email']); $record['member'][$i]['ID'] = $mid; $record['member'][$i]['readonly'] = empty($member['uid']); $this->groupmembers[$mid][] = $record['ID']; if ($with_contacts && empty($member['uid'])) { $this->contacts[$mid] = $record['member'][$i]; } } $this->distlists[$record['ID']] = $record; } $this->storage->set_order_and_limit($this->_sort_columns(), null, 0); } } /** * Encode object UID into a safe identifier */ public function uid2id($uid) { return rtrim(strtr(base64_encode($uid), '+/', '-_'), '='); } /** * Convert Roundcube object identifier back into the original UID */ public function id2uid($id) { return base64_decode(str_pad(strtr($id, '-_', '+/'), strlen($id) % 4, '=', STR_PAD_RIGHT)); } /** * Build SQL query for fulltext matches */ private function _search_query($fields, $value, $mode) { $query = []; $cols = []; $cols = array_intersect($fields, $this->fulltext_cols); if (count($cols)) { if ($mode & rcube_addressbook::SEARCH_STRICT) { $prefix = '^'; $suffix = '$'; } elseif ($mode & rcube_addressbook::SEARCH_PREFIX) { $prefix = '^'; $suffix = ''; } else { $prefix = ''; $suffix = ''; } $search_string = is_array($value) ? implode(' ', $value) : $value; foreach (rcube_utils::normalize_string($search_string, true) as $word) { $query[] = ['words', 'LIKE', $prefix . $word . $suffix]; } } return $query; } /** * Map fields from internal Kolab_Format to Roundcube contact format */ private function _to_rcube_contact($record) { $record['ID'] = $this->uid2id($record['uid']); // remove empty fields $record = array_filter($record); // Set _type for proper icon on the list $record['_type'] = 'person'; return $record; } /** * Map fields from Roundcube format to internal kolab_format_contact properties */ private function _from_rcube_contact($contact, $old = []) { if (empty($contact['uid']) && !empty($contact['ID'])) { $contact['uid'] = $this->id2uid($contact['ID']); } elseif (empty($contact['uid']) && !empty($old['uid'])) { $contact['uid'] = $old['uid']; } elseif (empty($contact['uid'])) { $rcube = rcube::get_instance(); $contact['uid'] = strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($rcube->user->get_username()), 0, 16)); } // When importing contacts 'vcard' data might be added, we don't need it (Bug #1711) unset($contact['vcard']); return $contact; } } diff --git a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts_driver.php b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts_driver.php index 881614ad..19961f71 100644 --- a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts_driver.php +++ b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts_driver.php @@ -1,215 +1,216 @@ * * Copyright (C) 2011-2022, Apheleia IT AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @see rcube_addressbook */ class carddav_contacts_driver { protected $plugin; protected $rc; protected $sources; public function __construct($plugin) { $this->plugin = $plugin; $this->rc = rcube::get_instance(); } /** * List addressbook sources (folders) */ public function list_folders() { if (isset($this->sources)) { return $this->sources; } $storage = self::get_storage(); $this->sources = []; // get all folders that have "contact" type foreach ($storage->get_folders('contact') as $folder) { $this->sources[$folder->id] = new carddav_contacts($folder); } return $this->sources; } /** * Getter for the rcube_addressbook instance * * @param string $id Addressbook (folder) ID * * @return ?carddav_contacts */ public function get_address_book($id) { if (isset($this->sources[$id])) { return $this->sources[$id]; } $storage = self::get_storage(); $folder = $storage->get_folder($id, 'contact'); if ($folder) { return new carddav_contacts($folder); } return null; } /** * Initialize kolab_storage_dav instance */ protected static function get_storage() { $rcube = rcube::get_instance(); $url = $rcube->config->get('kolab_addressbook_carddav_server', 'http://localhost'); return new kolab_storage_dav($url); } /** * Delete address book folder * * @param string $folder Addressbook identifier * * @return bool */ public function folder_delete($folder) { $storage = self::get_storage(); $this->sources = null; return $storage->folder_delete($folder, 'contact'); } /** * Address book folder form content for book create/edit * * @param string $action Action name (edit, create) * @param string $source Addressbook identifier * * @return string HTML output */ public function folder_form($action, $source) { $name = ''; if ($source && ($book = $this->get_address_book($source))) { $name = $book->get_name(); + $folder = $book->storage; } $foldername = new html_inputfield(['name' => '_name', 'id' => '_name', 'size' => 30]); $foldername = $foldername->show($name); // General tab $form = [ 'properties' => [ 'name' => $this->rc->gettext('properties'), 'fields' => [ 'name' => [ 'label' => $this->plugin->gettext('bookname'), 'value' => $foldername, 'id' => '_name', ], ], ], ]; $hidden_fields = [['name' => '_source', 'value' => $source]]; - return kolab_utils::folder_form($form, '', 'contacts', $hidden_fields, false); + return kolab_utils::folder_form($form, $folder ?? null, 'contacts', $hidden_fields); } /** * Handler for address book create/edit form submit */ public function folder_save() { $storage = self::get_storage(); $prop = [ 'id' => trim(rcube_utils::get_input_value('_source', rcube_utils::INPUT_POST)), 'name' => trim(rcube_utils::get_input_value('_name', rcube_utils::INPUT_POST)), 'type' => 'contact', 'subscribed' => true, ]; $type = !empty($prop['id']) ? 'update' : 'create'; $this->sources = null; $result = $storage->folder_update($prop); if ($result && ($abook = $this->get_address_book($prop['id'] ?: $result))) { $abook_id = $prop['id'] ?: $result; $props = $this->abook_prop($abook_id, $abook); $this->rc->output->show_message('kolab_addressbook.book' . $type . 'd', 'confirmation'); $this->rc->output->command('book_update', $props, $prop['id']); } else { $this->rc->output->show_message('kolab_addressbook.book' . $type . 'error', 'error'); } } /** * Helper method to build a hash array of address book properties */ public function abook_prop($id, $abook) { /* if ($abook instanceof kolab_storage_folder_virtual) { return [ 'id' => $id, 'name' => $abook->get_name(), 'listname' => $abook->get_foldername(), 'group' => $abook instanceof kolab_storage_folder_user ? 'user' : $abook->get_namespace(), 'readonly' => true, 'rights' => 'l', 'kolab' => true, 'virtual' => true, 'carddav' => true, ]; } */ return [ 'id' => $id, 'name' => $abook->get_name(), 'listname' => $abook->get_foldername(), 'readonly' => $abook->readonly, 'rights' => $abook->rights, 'groups' => $abook->groups, 'undelete' => $abook->undelete && $this->rc->config->get('undo_timeout'), 'realname' => rcube_charset::convert($abook->get_realname(), 'UTF7-IMAP'), // IMAP folder name 'group' => $abook->get_namespace(), 'subscribed' => $abook->is_subscribed(), 'carddavurl' => $abook->get_carddav_url(), 'removable' => true, 'kolab' => true, 'carddav' => true, 'audittrail' => false, // !empty($this->plugin->bonnie_api), ]; } } diff --git a/plugins/libkolab/lib/kolab_dav_acl.php b/plugins/libkolab/lib/kolab_dav_acl.php new file mode 100644 index 00000000..3f058ad0 --- /dev/null +++ b/plugins/libkolab/lib/kolab_dav_acl.php @@ -0,0 +1,533 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * A class providing DAV ACL management functionality + */ +class kolab_dav_acl +{ + public const PRIVILEGE_ALL = 'all'; + public const PRIVILEGE_READ = 'read'; + public const PRIVILEGE_FREE_BUSY = 'read-free-busy'; + public const PRIVILEGE_WRITE = 'write'; + + /** @var ?kolab_storage_dav_folder $folder Current folder */ + private static $folder; + + /** @var array Special principals */ + private static $specials = [ + kolab_dav_client::ACL_PRINCIPAL_AUTH, + kolab_dav_client::ACL_PRINCIPAL_ALL, + ]; + + + /** + * Handler for actions from the ACL dialog (AJAX) + */ + public static function actions() + { + $action = trim(rcube_utils::get_input_string('_act', rcube_utils::INPUT_GPC)); + + if ($action == 'save') { + self::action_save(); + } elseif ($action == 'delete') { + self::action_delete(); + } + + rcmail::get_instance()->output->send(); + } + + /** + * Returns folder sharing form (for a Sharing tab) + * + * @param kolab_storage_dav_folder $folder + * + * @return null|string Form HTML content + */ + public static function form($folder) + { + $rcmail = rcmail::get_instance(); + $myrights = $folder->get_myrights(); + + // Any privileges? + if (empty($myrights)) { + return null; + } + + // Return if not folder admin + $myrights = explode(',', $myrights); + if (!in_array('admin', $myrights) && !in_array('write-acl', $myrights)) { + return null; + } + + self::$folder = $folder; + + // Add localization labels and include scripts + $rcmail->output->add_label('libkolab.nouser', 'libkolab.newuser', 'libkolab.editperms', + 'libkolab.deleteconfirm', 'libkolab.delete', 'libkolab.norights', 'libkolab.saving'); + + $rcmail->output->include_script('list.js'); + $rcmail->plugins->include_script('libkolab/libkolab.js'); + + $rcmail->output->add_handlers([ + 'acltable' => [__CLASS__, 'templ_table'], + 'acluser' => [__CLASS__, 'templ_user'], + 'aclrights' => [__CLASS__, 'templ_rights'], + ]); + + $rcmail->output->set_env('acl_target', self::get_folder_id($folder)); + // $rcmail->output->set_env('acl_users_source', (bool) $this->rc->config->get('acl_users_source')); + // $rcmail->output->set_env('autocomplete_max', (int) $rcmail->config->get('autocomplete_max', 15)); + // $rcmail->output->set_env('autocomplete_min_length', $rcmail->config->get('autocomplete_min_length')); + // $rcmail->output->add_label('autocompletechars', 'autocompletemore'); + + return $rcmail->output->parse('libkolab.acl', false, false); + } + + /** + * Creates ACL rights table + * + * @param array $attrib Template object attributes + * + * @return string HTML Content + */ + public static function templ_table($attrib) + { + if (empty($attrib['id'])) { + $attrib['id'] = 'acl-table'; + } + + $out = self::list_rights($attrib); + + $rcmail = rcmail::get_instance(); + $rcmail->output->add_gui_object('acltable', $attrib['id']); + + return $out; + } + + /** + * Creates ACL rights form (rights list part) + * + * @param array $attrib Template object attributes + * + * @return string HTML Content + */ + public static function templ_rights($attrib) + { + $rcmail = rcmail::get_instance(); + $input = new html_checkbox(); + $ul = ''; + $attrib['id'] = 'rights'; + + $rights = [ + self::PRIVILEGE_READ, + self::PRIVILEGE_WRITE, + self::PRIVILEGE_ALL, + ]; + + if (self::$folder->type == 'event' || self::$folder->type == 'task') { + array_unshift($rights, self::PRIVILEGE_FREE_BUSY); + } + + foreach ($rights as $right) { + $id = "acl{$right}"; + $label = $rcmail->gettext($rcmail->text_exists("libkolab.acllong{$right}") ? "libkolab.acllong{$right}" : "libkolab.acl{$right}"); + $ul .= html::tag('li', null, + $input->show('', ['name' => "acl[{$right}]", 'value' => $right, 'id' => $id]) + . html::label(['for' => $id], $label) + ); + } + + return html::tag('ul', $attrib, $ul, html::$common_attrib); + } + + /** + * Creates ACL rights form (user part) + * + * @param array $attrib Template object attributes + * + * @return string HTML Content + */ + public static function templ_user($attrib) + { + $rcmail = rcmail::get_instance(); + + // Create username input + $class = !empty($attrib['class']) ? $attrib['class'] : ''; + $attrib['name'] = 'acluser'; + $attrib['class'] = 'form-control'; + + $textfield = new html_inputfield($attrib); + $label = html::label(['for' => $attrib['id'], 'class' => 'input-group-text'], $rcmail->gettext('libkolab.username')); + + $fields = [ + 'user' => html::div('input-group', + html::span('input-group-prepend', $label) . ' ' . $textfield->show() + ), + ]; + + foreach (self::$specials as $type) { + $fields[$type] = html::label(['for' => 'id' . $type], $rcmail->gettext("libkolab.{$type}")); + } + + // Create list with radio buttons + $ul = ''; + foreach ($fields as $key => $val) { + $radio = new html_radiobutton(['name' => 'usertype']); + $radio = $radio->show($key == 'user' ? 'user' : '', ['value' => $key, 'id' => 'id' . $key]); + $ul .= html::tag('li', null, $radio . $val); + } + + return html::tag('ul', ['id' => 'usertype', 'class' => $class], $ul, html::$common_attrib); + } + + /** + * Creates ACL rights table + * + * @param array $attrib Template object attributes + * + * @return string HTML Content + */ + private static function list_rights($attrib = []) + { + $rcmail = rcmail::get_instance(); + + // Get ACL for the folder + $acl = self::$folder->get_acl(); + + // Remove 'self' entry + // TODO: Do it only on folders user == owner + unset($acl[kolab_dav_client::ACL_PRINCIPAL_SELF]); + + // Sort the list by username + uksort($acl, 'strnatcasecmp'); + + // Move special entries to the top + $specials = []; + foreach (self::$specials as $key) { + if (isset($acl[$key])) { + $specials[$key] = $acl[$key]; + unset($acl[$key]); + } + } + + if (count($specials) > 0) { + $acl = array_merge($specials, $acl); + } + + $cols = [ + self::PRIVILEGE_READ, + self::PRIVILEGE_WRITE, + self::PRIVILEGE_ALL, + ]; + + if (self::$folder->type == 'event' || self::$folder->type == 'task') { + array_unshift($cols, self::PRIVILEGE_FREE_BUSY); + } + + // Create the table + $attrib['noheader'] = true; + $table = new html_table($attrib); + $js_table = []; + + // Create table header + $table->add_header('user', $rcmail->gettext('libkolab.identifier')); + foreach ($cols as $right) { + $label = $rcmail->gettext("libkolab.acl{$right}"); + $table->add_header(['class' => "acl{$right}", 'title' => $label], $label); + } + + foreach ($acl as $user => $rights) { + // We do not support 'deny' privileges + if (!empty($rights['deny']) || empty($rights['grant'])) { + continue; + } + + $userid = rcube_utils::html_identifier($user); + $title = null; + + if (!empty($specials) && isset($specials[$user])) { + $username = $rcmail->gettext("libkolab.{$user}"); + } else { + $username = $user; + } + + $table->add_row(['id' => 'rcmrow' . $userid, 'data-userid' => $user]); + $table->add(['class' => 'user text-nowrap', 'title' => $title], + html::a(['id' => 'rcmlinkrow' . $userid], rcube::Q($username)) + ); + + $rights = self::from_dav($rights['grant']); + + foreach ($cols as $right) { + $class = in_array($right, $rights) ? 'enabled' : 'disabled'; + $table->add('acl' . $right . ' ' . $class, ''); + } + + $js_table[$userid] = $rights; + } + + $rcmail->output->set_env('acl', $js_table); + $rcmail->output->set_env('acl_specials', self::$specials); + + return $table->show(); + } + + /** + * Handler for ACL update/create action + */ + private static function action_save() + { + $rcmail = rcmail::get_instance(); + $target = trim(rcube_utils::get_input_string('_target', rcube_utils::INPUT_POST, true)); + $user = trim(rcube_utils::get_input_string('_user', rcube_utils::INPUT_POST)); + $acl = trim(rcube_utils::get_input_string('_acl', rcube_utils::INPUT_POST)); + $oldid = trim(rcube_utils::get_input_string('_old', rcube_utils::INPUT_POST)); + + $users = $oldid ? [$user] : explode(',', $user); + $self = $rcmail->get_user_name(); + $updates = []; + + $folder = self::get_folder($target); + + if (!$folder || !$acl) { + $rcmail->output->show_message($oldid ? 'libkolab.updateerror' : 'libkolab.createerror', 'error'); + return; + } + + $folder_acl = $folder->get_acl(); + $acl = explode(',', $acl); + + foreach ($users as $user) { + $user = trim($user); + $username = ''; + + if (in_array($user, self::$specials)) { + $username = $rcmail->gettext("libkolab.{$user}"); + } elseif (!empty($user)) { + if (!strpos($user, '@') && ($realm = self::get_realm())) { + $user .= '@' . rcube_utils::idn_to_ascii(preg_replace('/^@/', '', $realm)); + } + + // Make sure it's valid email address + if (strpos($user, '@') && !rcube_utils::check_email($user, false)) { + $user = null; + } + + $username = $user; + } + + if (!$user) { + continue; + } + + if ($user != $self && $username != $self) { + $folder_acl[$user] = ['grant' => self::to_dav($acl), 'deny' => []]; + $updates[] = [ + 'id' => rcube_utils::html_identifier($user), + 'username' => $user, + 'display' => $username, + 'acl' => $acl, + 'old' => $oldid, + ]; + } + } + + if (count($updates) > 0 && $folder->set_acl($folder_acl)) { + foreach ($updates as $command) { + $rcmail->output->command('acl_update', $command); + } + $rcmail->output->show_message($oldid ? 'libkolab.updatesuccess' : 'libkolab.createsuccess', 'confirmation'); + } else { + $rcmail->output->show_message($oldid ? 'libkolab.updateerror' : 'libkolab.createerror', 'error'); + } + } + + /** + * Handler for ACL delete action + */ + private static function action_delete() + { + $rcmail = rcmail::get_instance(); + $target = trim(rcube_utils::get_input_string('_target', rcube_utils::INPUT_POST, true)); + $user = trim(rcube_utils::get_input_string('_user', rcube_utils::INPUT_POST)); + + $folder = self::get_folder($target); + $users = explode(',', $user); + + if (!$folder) { + $rcmail->output->show_message('libkolab.deleteerror', 'error'); + return; + } + + $folder_acl = $folder->get_acl(); + + foreach ($users as $user) { + $user = trim($user); + unset($folder_acl[$user]); + } + + if ($folder->set_acl($folder_acl)) { + foreach ($users as $user) { + $rcmail->output->command('acl_remove_row', rcube_utils::html_identifier($user)); + } + + $rcmail->output->show_message('libkolab.deletesuccess', 'confirmation'); + } else { + $rcmail->output->show_message('libkolab.deleteerror', 'error'); + } + } + + /** + * Convert DAV privileges into simplified "groups" + * + * @param array $list ACL privileges + * + * @return array + */ + private static function from_dav($list) + { + /* + DAV ACL is a complicated system, we don't really want to implement it in full, + we rather keep it simple and similar to what we have for mail folders. + Therefore we implement it like this: + + - free-busy: + - CALDAV:read-free-busy (not for addressbooks) + - read: + - DAV:read + - write: + - DAV:write-content + - CYRUS:remove-resource + - all (all the above plus administration): + - DAV:all + + Reference: https://datatracker.ietf.org/doc/html/rfc3744 + Reference: https://www.cyrusimap.org/imap/download/installation/http/caldav.html#calendar-acl + */ + + // TODO: Don't use CYRUS:remove-resource on non-Cyrus servers + + $result = []; + + if ($all = in_array('all', $list)) { + $result[] = self::PRIVILEGE_ALL; + } + + if ($all || in_array('read-free-busy', $list) || in_array('read', $list)) { + $result[] = self::PRIVILEGE_FREE_BUSY; + } + + if ($all || in_array('read', $list)) { + $result[] = self::PRIVILEGE_READ; + } + + if ($all || (in_array('write-content', $list) && in_array('remove-resource', $list)) || in_array('write', $list)) { + $result[] = self::PRIVILEGE_WRITE; + } + + return $result; + } + + /** + * Convert simplified privileges into ACL privileges + * + * @param array $list ACL privileges + * + * @return array + * @see self::from_dav() + */ + private static function to_dav($list) + { + $result = []; + + if (in_array(self::PRIVILEGE_ALL, $list)) { + return ['all']; + } + + if (in_array(self::PRIVILEGE_WRITE, $list)) { + $result[] = 'read'; + $result[] = 'write-content'; + // TODO: Don't use CYRUS:remove-resource on non-Cyrus servers + $result[] = 'remove-resource'; + } elseif (in_array(self::PRIVILEGE_READ, $list)) { + $result[] = 'read'; + } elseif (in_array(self::PRIVILEGE_FREE_BUSY, $list)) { + $result[] = 'read-free-busy'; + } + + return $result; + } + + /** + * Username realm detection. + * + * @return string Username realm (domain) + */ + private static function get_realm() + { + // When user enters a username without domain part, realm + // allows to add it to the username (and display correct username in the table) + + if (isset($_SESSION['acl_user_realm'])) { + return $_SESSION['acl_user_realm']; + } + + $rcmail = rcmail::get_instance(); + $self = $rcmail->get_user_name(); + + // find realm in username of logged user (?) + [$name, $domain] = rcube_utils::explode('@', $self); + + return $_SESSION['acl_username_realm'] = $domain; + } + + /** + * Get DAV folder object by ID + */ + private static function get_folder($id) + { + if (strpos($id, '?')) { + [$server_url, $folder_href] = explode('?', $id, 2); + + $dav = new kolab_dav_client($server_url); + $props = $dav->folderInfo($folder_href); + + if ($props) { + return new kolab_storage_dav_folder($dav, $props); + } + } + + return null; + } + + /** + * Get DAV folder identifier (with the server info) + */ + private static function get_folder_id($folder) + { + // the folder identifier needs to easily allow for + // connecting to the DAV server and getting/setting ACL + // TODO: It might be a security issue, consider generating ID and using session + // so the server URL is not revealed in the UI. + return $folder->dav->url . '?' . $folder->href; + } +} diff --git a/plugins/libkolab/lib/kolab_dav_client.php b/plugins/libkolab/lib/kolab_dav_client.php index 55717188..a27f9ba2 100644 --- a/plugins/libkolab/lib/kolab_dav_client.php +++ b/plugins/libkolab/lib/kolab_dav_client.php @@ -1,835 +1,985 @@ * * Copyright (C) 2022, Apheleia IT AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_dav_client { + public const ACL_PRINCIPAL_SELF = 'self'; + public const ACL_PRINCIPAL_ALL = 'all'; + public const ACL_PRINCIPAL_AUTH = 'authenticated'; + public const ACL_PRINCIPAL_UNAUTH = 'unauthenticated'; + public $url; protected $user; protected $password; protected $rc; protected $responseHeaders = []; /** * Object constructor */ public function __construct($url) { $this->rc = rcube::get_instance(); $parsedUrl = parse_url($url); if (!empty($parsedUrl['user']) && !empty($parsedUrl['pass'])) { $this->user = rawurldecode($parsedUrl['user']); $this->password = rawurldecode($parsedUrl['pass']); $url = str_replace(rawurlencode($this->user) . ':' . rawurlencode($this->password) . '@', '', $url); } else { $this->user = $this->rc->get_user_name(); $this->password = $this->rc->get_user_password(); } $this->url = $url; } /** * Execute HTTP request to a DAV server */ protected function request($path, $method, $body = '', $headers = []) { $rcube = rcube::get_instance(); $debug = (bool) $rcube->config->get('dav_debug'); $request_config = [ 'store_body' => true, 'follow_redirects' => true, ]; $this->responseHeaders = []; if ($path && ($rootPath = parse_url($this->url, PHP_URL_PATH)) && strpos($path, $rootPath) === 0) { $path = substr($path, strlen($rootPath)); } try { $request = $this->initRequest($this->url . $path, $method, $request_config); $request->setAuth($this->user, $this->password); if ($body) { $request->setBody($body); $request->setHeader(['Content-Type' => 'application/xml; charset=utf-8']); } if (!empty($headers)) { $request->setHeader($headers); } if ($debug) { rcube::write_log('dav', "C: {$method}: " . (string) $request->getUrl() . "\n" . $this->debugBody($body, $request->getHeaders())); } $response = $request->send(); $body = $response->getBody(); $code = $response->getStatus(); if ($debug) { rcube::write_log('dav', "S: [{$code}]\n" . $this->debugBody($body, $response->getHeader())); } if ($code >= 300) { throw new Exception("DAV Error ($code):\n{$body}"); } $this->responseHeaders = $response->getHeader(); return $this->parseXML($body); } catch (Exception $e) { rcube::raise_error($e, true, false); return false; } } /** * Discover DAV home (root) collection of specified type. * * @param string $component Component to filter by (VEVENT, VTODO, VCARD) * * @return string|false Home collection location or False on error */ public function discover($component = 'VEVENT') { if ($cache = $this->get_cache()) { $cache_key = "discover.{$component}." . md5($this->url); if ($response = $cache->get($cache_key)) { return $response; } } $path = parse_url($this->url, PHP_URL_PATH); $body = '' . '' . '' . '' . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request('/', 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $elements = $response->getElementsByTagName('response'); $principal_href = ''; foreach ($elements as $element) { foreach ($element->getElementsByTagName('current-user-principal') as $prop) { $principal_href = $prop->nodeValue; break; } } if ($path && strpos($principal_href, $path) === 0) { $principal_href = substr($principal_href, strlen($path)); } $homes = [ 'VEVENT' => 'calendar-home-set', 'VTODO' => 'calendar-home-set', 'VCARD' => 'addressbook-home-set', ]; $ns = [ 'VEVENT' => 'caldav', 'VTODO' => 'caldav', 'VCARD' => 'carddav', ]; $body = '' . '' . '' . '' . '' . ''; $response = $this->request($principal_href, 'PROPFIND', $body); if (empty($response)) { return false; } $elements = $response->getElementsByTagName('response'); foreach ($elements as $element) { foreach ($element->getElementsByTagName($homes[$component]) as $prop) { $root_href = $prop->nodeValue; break; } } if (empty($root_href)) { return false; } if ($path && strpos($root_href, $path) === 0) { $root_href = substr($root_href, strlen($path)); } if ($cache) { $cache->set($cache_key, $root_href); } return $root_href; } /** * Get list of folders of specified type. * * @param string $component Component to filter by (VEVENT, VTODO, VCARD) * * @return false|array List of folders' metadata or False on error */ public function listFolders($component = 'VEVENT') { $root_href = $this->discover($component); if ($root_href === false) { return false; } $ns = 'xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"'; $props = ''; if ($component != 'VCARD') { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/" xmlns:k="Kolab:"'; $props = '' . '' . ''; } $body = '' . '' . '' . '' . '' - // . '' . '' . $props . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request($root_href, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $folders = []; foreach ($response->getElementsByTagName('response') as $element) { $folder = $this->getFolderPropertiesFromResponse($element); // Filter out the folders of other type if ($component == 'VCARD') { if (in_array('addressbook', $folder['resource_type'])) { $folders[] = $folder; } } elseif (in_array('calendar', $folder['resource_type']) && in_array($component, (array) $folder['types'])) { $folders[] = $folder; } } return $folders; } /** * Create a DAV object in a folder * * @param string $location Object location * @param string $content Object content * @param string $component Content type (VEVENT, VTODO, VCARD) * * @return false|string|null ETag string (or NULL) on success, False on error */ public function create($location, $content, $component = 'VEVENT') { $ctype = [ 'VEVENT' => 'text/calendar', 'VTODO' => 'text/calendar', 'VCARD' => 'text/vcard', ]; $headers = ['Content-Type' => $ctype[$component] . '; charset=utf-8']; $response = $this->request($location, 'PUT', $content, $headers); return $this->getETagFromResponse($response); } /** * Update a DAV object in a folder * * @param string $location Object location * @param string $content Object content * @param string $component Content type (VEVENT, VTODO, VCARD) * * @return false|string|null ETag string (or NULL) on success, False on error */ public function update($location, $content, $component = 'VEVENT') { return $this->create($location, $content, $component); } /** * Delete a DAV object from a folder * * @param string $location Object location * * @return bool True on success, False on error */ public function delete($location) { $response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']); return $response !== false; } /** * Move a DAV object * * @param string $source Source object location * @param string $target Target object content * * @return false|string|null ETag string (or NULL) on success, False on error */ public function move($source, $target) { $headers = ['Destination' => $target]; $response = $this->request($source, 'MOVE', '', $headers); return $this->getETagFromResponse($response); } /** * Get folder properties. * * @param string $location Object location * * @return false|array Folder metadata or False on error */ public function folderInfo($location) { + $ns = implode(' ', [ + 'xmlns:d="DAV:"', + 'xmlns:cs="http://calendarserver.org/ns/"', + 'xmlns:c="urn:ietf:params:xml:ns:caldav"', + 'xmlns:a="http://apple.com/ns/ical/"', + 'xmlns:k="Kolab:"' + ]); + + // Note: does not include some of the properties we're interested in $body = '' - . '' - . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) $response = $this->request($location, 'PROPFIND', $body, ['Depth' => 0, 'Prefer' => 'return-minimal']); if (!empty($response) && ($element = $response->getElementsByTagName('response')->item(0)) && ($folder = $this->getFolderPropertiesFromResponse($element)) ) { return $folder; } return false; } /** * Create a DAV folder * * @param string $location Object location (relative to the user home) * @param string $component Content type (VEVENT, VTODO, VCARD) * @param array $properties Object content * * @return bool True on success, False on error */ public function folderCreate($location, $component, $properties = []) { $ns = 'xmlns:d="DAV:"'; $props = ''; if ($component == 'VCARD') { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:carddav"'; $props = ''; } else { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"'; $props = ''; } $body = '' . '' . '' . '' . $props . '' . '' . ''; // Create the collection $response = $this->request($location, 'MKCOL', $body); if (empty($response)) { return false; } // Update collection properties return $this->folderUpdate($location, $component, $properties); } /** * Delete a DAV folder * * @param string $location Folder location * * @return bool True on success, False on error */ public function folderDelete($location) { $response = $this->request($location, 'DELETE'); return $response !== false; } /** * Update a DAV folder * * @param string $location Object location * @param string $component Content type (VEVENT, VTODO, VCARD) * @param array $properties Object content * * @return bool True on success, False on error */ public function folderUpdate($location, $component, $properties = []) { $ns = 'xmlns:d="DAV:"'; $props = ''; if ($component == 'VCARD') { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:carddav"'; // Resourcetype property is protected // $props = ''; } else { $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"'; // Resourcetype property is protected // $props = ''; /* // Note: These are set by Cyrus automatically for calendars . '' . '' . '' . '' . '' . '' . ''; */ } foreach ($properties as $name => $value) { if ($name == 'name') { $props .= '' . htmlspecialchars($value, ENT_XML1, 'UTF-8') . ''; } elseif ($name == 'color' && strlen($value)) { if ($value[0] != '#') { $value = '#' . $value; } $ns .= ' xmlns:a="http://apple.com/ns/ical/"'; $props .= '' . htmlspecialchars($value, ENT_XML1, 'UTF-8') . ''; } elseif ($name == 'alarms') { if (!strpos($ns, 'Kolab:')) { $ns .= ' xmlns:k="Kolab:"'; } $props .= "" . ($value ? 'true' : 'false') . ""; } } if (empty($props)) { return true; } $body = '' . '' . '' . '' . $props . '' . '' . ''; $response = $this->request($location, 'PROPPATCH', $body); // TODO: Should we make sure "200 OK" status is set for all requested properties? return $response !== false; } /** * Fetch DAV objects metadata (ETag, href) a folder * * @param string $location Folder location * @param string $component Object type (VEVENT, VTODO, VCARD) * * @return false|array Objects metadata on success, False on error */ public function getIndex($location, $component = 'VEVENT') { $queries = [ 'VEVENT' => 'calendar-query', 'VTODO' => 'calendar-query', 'VCARD' => 'addressbook-query', ]; $ns = [ 'VEVENT' => 'caldav', 'VTODO' => 'caldav', 'VCARD' => 'carddav', ]; $filter = ''; if ($component != 'VCARD') { $filter = '' . '' . ''; } $body = '' . ' ' . '' . '' . '' . ($filter ? "$filter" : '') . ''; $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $objects[] = $this->getObjectPropertiesFromResponse($element); } return $objects; } /** * Fetch DAV objects data from a folder * * @param string $location Folder location * @param string $component Object type (VEVENT, VTODO, VCARD) * @param array $hrefs List of objects' locations to fetch (empty for all objects) * * @return false|array Objects metadata on success, False on error */ public function getData($location, $component = 'VEVENT', $hrefs = []) { if (empty($hrefs)) { return []; } $body = ''; foreach ($hrefs as $href) { $body .= '' . $href . ''; } $queries = [ 'VEVENT' => 'calendar-multiget', 'VTODO' => 'calendar-multiget', 'VCARD' => 'addressbook-multiget', ]; $ns = [ 'VEVENT' => 'caldav', 'VTODO' => 'caldav', 'VCARD' => 'carddav', ]; $types = [ 'VEVENT' => 'calendar-data', 'VTODO' => 'calendar-data', 'VCARD' => 'address-data', ]; $body = '' . ' ' . '' . '' . '' . '' . $body . ''; $response = $this->request($location, 'REPORT', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); if (empty($response)) { return false; } $objects = []; foreach ($response->getElementsByTagName('response') as $element) { $objects[] = $this->getObjectPropertiesFromResponse($element); } return $objects; } + /** + * Set ACL on a DAV folder + * + * @param string $location Object location (relative to the user home) + * @param array $acl ACL definition + * + * @return bool True on success, False on error + */ + public function setACL($location, $acl) + { + $ns_privileges = [ + // CalDAV + 'read-free-busy' => 'c:read-free-busy', + // Cyrus + 'admin' => 'cy:admin', + 'add-resource' => 'cy:add-resource', + 'remove-resource' => 'cy:remove-resource', + 'make-collection' => 'cy:make-collection', + 'remove-collection' => 'cy:remove-collection', + ]; + + foreach ($acl as $idx => $privileges) { + if (preg_match('/^[a-z]+$/', $idx)) { + $principal = ''; + } else { + $principal = '' . htmlspecialchars($idx, ENT_XML1, 'UTF-8') . ''; + } + + $grant = []; + $deny = []; + + foreach ($privileges['grant'] ?? [] as $i => $p) { + $p = '<' . ($ns_privileges[$p] ?? "d:{$p}") . '/>'; + $grant[$i] = '' . $p . ''; + } + foreach ($privileges['deny'] ?? [] as $i => $p) { + $p = '<' . ($ns_privileges[$p] ?? "d:{$p}") . '/>'; + $deny[$i] = '' . $p . ''; + } + + $acl[$idx] = '' + . '' . $principal . '' + . (count($grant) > 0 ? '' . implode('', $grant) . '' : '') + . (count($deny) > 0 ? '' . implode('', $deny) . '' : '') + . ''; + } + + $acl = implode('', $acl); + $ns = 'xmlns:d="DAV:"'; + + if (strpos($acl, '' . $acl . ''; + + $response = $this->request($location, 'ACL', $body); + + return $response !== false; + } + /** * Parse XML content */ protected function parseXML($xml) { $doc = new DOMDocument('1.0', 'UTF-8'); if (stripos($xml, 'loadXML($xml)) { throw new Exception("Failed to parse XML"); } $doc->formatOutput = true; } return $doc; } /** * Parse request/response body for debug purposes */ protected function debugBody($body, $headers) { $head = ''; foreach ($headers as $header_name => $header_value) { $head .= "{$header_name}: {$header_value}\n"; } if (stripos($body, 'formatOutput = true; $doc->preserveWhiteSpace = false; if (!$doc->loadXML($body)) { throw new Exception("Failed to parse XML"); } $body = $doc->saveXML(); } return $head . "\n" . rtrim($body); } /** * Extract folder properties from a server 'response' element */ protected function getFolderPropertiesFromResponse(DOMElement $element) { if ($href = $element->getElementsByTagName('href')->item(0)) { $href = $href->nodeValue; /* $path = parse_url($this->url, PHP_URL_PATH); if ($path && strpos($href, $path) === 0) { $href = substr($href, strlen($path)); } */ } if ($color = $element->getElementsByTagName('calendar-color')->item(0)) { if (preg_match('/^#[0-9a-fA-F]{6,8}$/', $color->nodeValue)) { $color = substr($color->nodeValue, 1); } else { $color = null; } } if ($name = $element->getElementsByTagName('displayname')->item(0)) { $name = $name->nodeValue; } if ($ctag = $element->getElementsByTagName('getctag')->item(0)) { $ctag = $ctag->nodeValue; } $components = []; if ($set_element = $element->getElementsByTagName('supported-calendar-component-set')->item(0)) { foreach ($set_element->getElementsByTagName('comp') as $comp_element) { $components[] = $comp_element->attributes->getNamedItem('name')->nodeValue; } } $types = []; if ($type_element = $element->getElementsByTagName('resourcetype')->item(0)) { foreach ($type_element->childNodes as $node) { $_type = explode(':', $node->nodeName); $types[] = count($_type) > 1 ? $_type[1] : $_type[0]; } } $result = [ 'href' => $href, 'name' => $name, 'ctag' => $ctag, 'color' => $color, 'types' => $components, 'resource_type' => $types, ]; + // Note: We're supporting only a subset of RFC 3744, it is: + // - grant, deny + // - principal (all, self, authenticated, href) + if ($acl_element = $element->getElementsByTagName('acl')->item(0)) { + $acl = []; + $special = [ + self::ACL_PRINCIPAL_SELF, + self::ACL_PRINCIPAL_ALL, + self::ACL_PRINCIPAL_AUTH, + self::ACL_PRINCIPAL_UNAUTH, + ]; + + foreach ($acl_element->getElementsByTagName('ace') as $ace) { + $principal = $ace->getElementsByTagName('principal')->item(0); + $grant = []; + $deny = []; + + if ($principal->firstChild && $principal->firstChild->localName == 'href') { + $principal = $principal->firstChild->nodeValue; + } elseif ($principal->firstChild && in_array($principal->firstChild->localName, $special)) { + $principal = $principal->firstChild->localName; + } else { + continue; + } + + if ($grant_element = $ace->getElementsByTagName('grant')->item(0)) { + foreach ($grant_element->childNodes as $privilege) { + if (strpos($privilege->nodeName, ':privilege') !== false && $privilege->firstChild) { + $grant[] = preg_replace('/^[^:]+:/', '', $privilege->firstChild->nodeName); + } + } + } + + if ($deny_element = $ace->getElementsByTagName('deny')->item(0)) { + foreach ($deny_element->childNodes as $privilege) { + if (strpos($privilege->nodeName, ':privilege') !== false && $privilege->firstChild) { + $deny[] = preg_replace('/^[^:]+:/', '', $privilege->firstChild->nodeName); + } + } + } + + if (count($grant) > 0 || count($deny) > 0) { + $acl[$principal] = [ + 'grant' => $grant, + 'deny' => $deny, + ]; + } + } + + $result['acl'] = $acl; + } + + if ($set_element = $element->getElementsByTagName('current-user-privilege-set')->item(0)) { + $rights = []; + + foreach ($set_element->childNodes as $privilege) { + if (strpos($privilege->nodeName, ':privilege') !== false && $privilege->firstChild) { + $rights[] = preg_replace('/^[^:]+:/', '', $privilege->firstChild->nodeName); + } + } + + $result['myrights'] = $rights; + } + foreach (['alarms'] as $tag) { if ($el = $element->getElementsByTagName($tag)->item(0)) { if (strlen($el->nodeValue) > 0) { $result[$tag] = strtolower($el->nodeValue) === 'true'; } } } return $result; } /** * Extract object properties from a server 'response' element */ protected function getObjectPropertiesFromResponse(DOMElement $element) { $uid = null; if ($href = $element->getElementsByTagName('href')->item(0)) { $href = $href->nodeValue; /* $path = parse_url($this->url, PHP_URL_PATH); if ($path && strpos($href, $path) === 0) { $href = substr($href, strlen($path)); } */ // Extract UID from the URL $href_parts = explode('/', $href); $uid = preg_replace('/\.[a-z]+$/', '', $href_parts[count($href_parts) - 1]); } if ($data = $element->getElementsByTagName('calendar-data')->item(0)) { $data = $data->nodeValue; } elseif ($data = $element->getElementsByTagName('address-data')->item(0)) { $data = $data->nodeValue; } if ($etag = $element->getElementsByTagName('getetag')->item(0)) { $etag = $etag->nodeValue; if (preg_match('|^".*"$|', $etag)) { $etag = substr($etag, 1, -1); } } return [ 'href' => $href, 'data' => $data, 'etag' => $etag, 'uid' => $uid, ]; } /** * Get ETag from a response */ protected function getETagFromResponse($response) { if ($response !== false) { // Note: ETag is not always returned, e.g. https://github.com/cyrusimap/cyrus-imapd/issues/2456 $etag = $this->responseHeaders['etag'] ?? null; if (is_string($etag) && preg_match('|^".*"$|', $etag)) { $etag = substr($etag, 1, -1); } return $etag; } return false; } /** * Initialize HTTP request object */ protected function initRequest($url = '', $method = 'GET', $config = []) { $rcube = rcube::get_instance(); $http_config = (array) $rcube->config->get('kolab_http_request'); // deprecated configuration options if (empty($http_config)) { foreach (['ssl_verify_peer', 'ssl_verify_host'] as $option) { $value = $rcube->config->get('kolab_' . $option, true); if (is_bool($value)) { $http_config[$option] = $value; } } } if (!empty($config)) { $http_config = array_merge($http_config, $config); } // load HTTP_Request2 (support both composer-installed and system-installed package) if (!class_exists('HTTP_Request2')) { require_once 'HTTP/Request2.php'; } try { $request = new HTTP_Request2(); $request->setConfig($http_config); // proxy User-Agent string if (isset($_SERVER['HTTP_USER_AGENT'])) { $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); } // cleanup $request->setBody(''); $request->setUrl($url); $request->setMethod($method); return $request; } catch (Exception $e) { rcube::raise_error($e, true, true); } } /** * Return caching object if enabled */ protected function get_cache() { $rcube = rcube::get_instance(); if ($cache_type = $rcube->config->get('dav_cache', 'db')) { $cache_ttl = $rcube->config->get('dav_cache_ttl', '10m'); $cache_name = 'DAV'; return $rcube->get_cache($cache_name, $cache_type, $cache_ttl); } } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php index 85f39b7c..d0a6a141 100644 --- a/plugins/libkolab/lib/kolab_storage_dav_folder.php +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -1,782 +1,868 @@ * * Copyright (C) 2014-2022, Apheleia IT AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ #[AllowDynamicProperties] class kolab_storage_dav_folder extends kolab_storage_folder { /** @var kolab_dav_client DAV Client */ public $dav; /** @var string Folder URL */ public $href; /** @var array Folder DAV attributes */ public $attributes; /** * Object constructor */ public function __construct($dav, $attributes, $type = '') { $this->attributes = $attributes; $this->href = $this->attributes['href']; $this->id = kolab_storage_dav::folder_id($dav->url, $this->href); $this->dav = $dav; $this->valid = true; [$this->type, $suffix] = strpos($type, '.') ? explode('.', $type) : [$type, '']; $this->default = $suffix == 'default'; $this->subtype = $this->default ? '' : $suffix; // Init cache - $this->cache = kolab_storage_dav_cache::factory($this); + if ($type) { + $this->cache = kolab_storage_dav_cache::factory($this); + } + } + + /** + * Get the folder ACL + * + * @return array Folder ACL list + */ + public function get_acl() + { + if (!isset($this->attributes['acl'])) { + $this->get_folder_info(); + } + + $acl = []; + foreach ($this->attributes['acl'] ?? [] as $principal => $privileges) { + // Convert a principal href into a user identifier + if (strpos($principal, '/') !== false) { + $tokens = explode('/', trim($principal, '/')); + $principal = end($tokens); + } + + $acl[$principal] = $privileges; + } + + // Workaround for Cyrus issue https://github.com/cyrusimap/cyrus-imapd/issues/4813 + // It converts single "authenticated" ACE into two "all" and "unauthenticated" + // We'll convert it back into one, as we want to keep UI simplicity + if (!empty($acl['all']['grant']) && !empty($acl['unauthenticated']['deny']) + && $acl['all']['grant'] == $acl['unauthenticated']['deny'] + ) { + $acl['authenticated'] = $acl['all']; + unset($acl['all']); + unset($acl['unauthenticated']); + } + + return $acl; } /** * Returns the owner of the folder. * * @param bool $fully_qualified Return a fully qualified owner name (i.e. including domain for shared folders) * * @return string The owner of this folder. */ public function get_owner($fully_qualified = false) { // return cached value if (isset($this->owner)) { return $this->owner; } $rcube = rcube::get_instance(); $this->owner = $rcube->get_user_name(); $this->valid = true; // TODO: Support shared folders return $this->owner; } /** * Get a folder Etag identifier */ public function get_ctag() { // Refresh requested, get the current CTag from the DAV server if ($this->attributes['ctag'] === false) { if ($fresh = $this->dav->folderInfo($this->href)) { $this->attributes['ctag'] = $fresh['ctag']; } } return $this->attributes['ctag']; } /** * Getter for the name of the namespace to which the folder belongs * * @return string Name of the namespace (personal, other, shared) */ public function get_namespace() { // TODO: Support shared folders return 'personal'; } /** * Get the display name value of this folder * * @return string Folder name */ public function get_name() { return kolab_storage_dav::object_name($this->attributes['name']); } /** * Getter for the top-end folder name (not the entire path) * * @return string Name of this folder */ public function get_foldername() { return $this->attributes['name']; } + /** + * Get (more) folder properties + * + * @return array Folder properties + */ public function get_folder_info() { - return []; // todo ? + if ($info = $this->dav->folderInfo($this->href)) { + $this->attributes = array_merge($info, $this->attributes); + } + + return $this->attributes; } /** * Getter for parent folder path * * @return string Full path to parent folder */ public function get_parent() { // TODO return ''; } /** * Compose a unique resource URI for this folder */ public function get_resource_uri() { if (!empty($this->resource_uri)) { return $this->resource_uri; } // compose fully qualified resource uri for this instance $host = preg_replace('|^https?://|', 'dav://' . urlencode($this->get_owner(true)) . '@', $this->dav->url); $path = $this->href[0] == '/' ? $this->href : "/{$this->href}"; $host_path = parse_url($host, PHP_URL_PATH); if ($host_path && strpos($path, $host_path) === 0) { $path = substr($path, strlen($host_path)); } $this->resource_uri = unslashify($host) . $path; return $this->resource_uri; } /** * Getter for the Cyrus mailbox identifier corresponding to this folder * (e.g. user/john.doe/Calendar/Personal@example.org) * * @return string Mailbox ID */ public function get_mailbox_id() { // TODO: This is used with Bonnie related features return ''; } /** * Get the color value stored in metadata * * @param string $default Default color value to return if not set * * @return mixed Color value from the folder metadata or $default if not set */ public function get_color($default = null) { return !empty($this->attributes['color']) ? $this->attributes['color'] : $default; } /** - * Get ACL information for this folder + * Get current user permissions to this folder * - * @return string Permissions as string + * @return string DAV privileges (comma-separated) */ public function get_myrights() { - // TODO - return ''; + if (!isset($this->attributes['myrights'])) { + $this->get_folder_info(); + } + + // Note: We return a string for compat. with the parent class + return implode(',', $this->attributes['myrights'] ?? []); } /** * Helper method to extract folder UID * * @return string|null Folder's UID */ public function get_uid() { return $this->id; } /** * Check activation status of this folder * * @return bool True if enabled, false if not */ public function is_active() { return true; // Unused } /** * Change activation status of this folder * * @param bool $active The desired subscription status: true = active, false = not active * * @return bool True on success, false on error */ public function activate($active) { return true; // Unused } /** * Check subscription status of this folder * * @return bool True if subscribed, false if not */ public function is_subscribed() { return true; // TODO } /** * Change subscription status of this folder * * @param bool $subscribed The desired subscription status: true = subscribed, false = not subscribed * * @return bool True on success, false on error */ public function subscribe($subscribed) { return true; // TODO } /** * Delete the specified object from this folder. * * @param array|string $object The Kolab object to delete or object UID * @param bool $expunge Should the folder be expunged? * * @return bool True if successful, false on error */ public function delete($object, $expunge = true) { if (!$this->valid) { return false; } $uid = is_array($object) ? $object['uid'] : $object; $success = $this->dav->delete($this->object_location($uid)); if ($success) { $this->cache->set($uid, false); } return $success; } /** * Delete all objects in a folder. * * Note: This method is used by kolab_addressbook plugin only * * @return bool True if successful, false on error */ public function delete_all() { if (!$this->valid) { return false; } // TODO: Maybe just deleting and re-creating a folder would be // better, but probably might not always work (ACL) $this->cache->synchronize(); foreach (array_keys($this->cache->folder_index()) as $uid) { $this->dav->delete($this->object_location($uid)); } $this->cache->purge(); return true; } /** * Restore a previously deleted object * * @param string $uid Object UID * * @return mixed Message UID on success, false on error */ public function undelete($uid) { if (!$this->valid) { return false; } // TODO return false; } /** * Move a Kolab object message to another IMAP folder * * @param string $uid Object UID * @param kolab_storage_dav_folder $target_folder Target folder to move object into * * @return bool True on success, false on failure */ public function move($uid, $target_folder) { if (!$this->valid) { return false; } $source = $this->object_location($uid); $target = $target_folder->object_location($uid); $success = $this->dav->move($source, $target) !== false; if ($success) { $this->cache->set($uid, false); $target_folder->reset(); $this->reset(); } return $success; } /** * Save an object in this folder. * * @param array $object The array that holds the data of the object. * @param string $type The type of the kolab object. * @param string $uid The UID of the old object if it existed before * * @return mixed False on error or object UID on success */ public function save(&$object, $type = null, $uid = null) { if (!$this->valid || empty($object)) { return false; } if (!$type) { $type = $this->type; } $result = false; if (empty($uid)) { if (empty($object['created'])) { $object['created'] = new DateTime('now'); } } else { $object['changed'] = new DateTime('now'); } // Make sure UID exists if (empty($object['uid'])) { if ($uid) { $object['uid'] = $uid; } else { $username = rcube::get_instance()->user->get_username(); $object['uid'] = strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($username), 0, 16)); } } // generate and save object message if ($content = $this->to_dav($object)) { $method = $uid ? 'update' : 'create'; $dav_type = $this->get_dav_type(); $result = $this->dav->{$method}($this->object_location($object['uid']), $content, $dav_type); // Note: $result can be NULL if the request was successful, but ETag wasn't returned if ($result !== false) { // insert/update object in the cache $object['etag'] = $result; $object['_raw'] = $content; $this->cache->save($object, $uid); $result = true; unset($object['_raw']); } } return $result; } /** * Fetch the object the DAV server and convert to internal format * * @param string $uid The object UID to fetch * @param string $type The object type expected (use wildcard '*' to accept all types) * @param string $folder Unused (kept for compat. with the parent class) * * @return mixed Hash array representing the Kolab object, a kolab_format instance or false if not found */ public function read_object($uid, $type = null, $folder = null) { if (!$this->valid) { return false; } $href = $this->object_location($uid); $objects = $this->dav->getData($this->href, $this->get_dav_type(), [$href]); if (!is_array($objects) || count($objects) != 1) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to fetch {$href}", ], true); return false; } return $this->from_dav($objects[0]); } /** * Fetch multiple objects from the DAV server and convert to internal format * * @param array $uids The object UIDs to fetch * * @return mixed Hash array representing the Kolab objects */ public function read_objects($uids) { if (!$this->valid) { return false; } if (empty($uids)) { return []; } $hrefs = []; foreach ($uids as $uid) { $hrefs[] = $this->object_location($uid); } $objects = $this->dav->getData($this->href, $this->get_dav_type(), $hrefs); if (!is_array($objects)) { rcube::raise_error([ 'code' => 900, 'message' => "Failed to fetch objects from {$this->href}", ], true); return false; } $objects = array_map([$this, 'from_dav'], $objects); foreach ($uids as $idx => $uid) { foreach ($objects as $oidx => $object) { if ($object && $object['uid'] == $uid) { $uids[$idx] = $object; unset($objects[$oidx]); continue 2; } } $uids[$idx] = false; } return $uids; } /** * Reset the folder status */ public function reset() { $this->cache->reset(); $this->attributes['ctag'] = false; } /** * Convert DAV object into PHP array * * @param array $object Object data in kolab_dav_client::fetchData() format * * @return array|false Object properties, False on error */ public function from_dav($object) { if (empty($object) || empty($object['data'])) { return false; } if ($this->type == 'event' || $this->type == 'task') { $ical = libcalendaring::get_ical(); $objects = $ical->import($object['data']); if (!count($objects) || empty($objects[0]['uid'])) { return false; } $result = $objects[0]; $result['_attachments'] = $result['attachments'] ?? []; unset($result['attachments']); } elseif ($this->type == 'contact') { if (stripos($object['data'], 'BEGIN:VCARD') !== 0) { return false; } // vCard properties not supported by rcube_vcard $map = [ 'uid' => 'UID', 'kind' => 'KIND', 'member' => 'MEMBER', 'x-kind' => 'X-ADDRESSBOOKSERVER-KIND', 'x-member' => 'X-ADDRESSBOOKSERVER-MEMBER', ]; // TODO: We should probably use Sabre/Vobject to parse the vCard $vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false, $map); if (!empty($vcard->displayname) || !empty($vcard->surname) || !empty($vcard->firstname) || !empty($vcard->email)) { $result = $vcard->get_assoc(); // Contact groups if (!empty($result['x-kind']) && implode('', $result['x-kind']) == 'group') { $result['_type'] = 'group'; $members = $result['x-member'] ?? []; unset($result['x-kind'], $result['x-member']); } elseif (!empty($result['kind']) && implode('', $result['kind']) == 'group') { $result['_type'] = 'group'; $members = $result['member'] ?? []; unset($result['kind'], $result['member']); } if (isset($members)) { $result['member'] = []; foreach ($members as $member) { if (strpos($member, 'urn:uuid:') === 0) { $result['member'][] = ['uid' => substr($member, 9)]; } elseif (strpos($member, 'mailto:') === 0) { $member = reset(rcube_mime::decode_address_list(urldecode(substr($member, 7)))); if (!empty($member['mailto'])) { $result['member'][] = ['email' => $member['mailto'], 'name' => $member['name']]; } } } } if (!empty($result['uid'])) { $result['uid'] = preg_replace('/^urn:uuid:/', '', implode('', $result['uid'])); } } else { return false; } } $result['etag'] = $object['etag']; $result['href'] = !empty($object['href']) ? $object['href'] : null; $result['uid'] = !empty($object['uid']) ? $object['uid'] : $result['uid']; return $result; } /** * Convert Kolab object into DAV format (iCalendar) */ public function to_dav($object) { $result = ''; if ($this->type == 'event' || $this->type == 'task') { $ical = libcalendaring::get_ical(); if (!empty($object['exceptions'])) { $object['recurrence']['EXCEPTIONS'] = $object['exceptions']; } $object['_type'] = $this->type; // pre-process attachments if (isset($object['_attachments']) && is_array($object['_attachments'])) { foreach ($object['_attachments'] as $key => $attachment) { if ($attachment === false) { // Deleted attachment unset($object['_attachments'][$key]); continue; } // make sure size is set if (!isset($attachment['size'])) { if (!empty($attachment['data'])) { if (is_resource($attachment['data'])) { // this need to be a seekable resource, otherwise // fstat() fails and we're unable to determine size // here nor in rcube_imap_generic before IMAP APPEND $stat = fstat($attachment['data']); $attachment['size'] = $stat ? $stat['size'] : 0; } else { $attachment['size'] = strlen($attachment['data']); } } elseif (!empty($attachment['path'])) { $attachment['size'] = filesize($attachment['path']); } $object['_attachments'][$key] = $attachment; } } } $object['attachments'] = $object['_attachments'] ?? []; unset($object['_attachments']); $result = $ical->export([$object], null, false, [$this, 'get_attachment']); } elseif ($this->type == 'contact') { // copy values into vcard object // TODO: We should probably use Sabre/Vobject to create the vCard // vCard properties not supported by rcube_vcard $map = ['uid' => 'UID', 'kind' => 'KIND']; $vcard = new rcube_vcard('', RCUBE_CHARSET, false, $map); if ((!empty($object['_type']) && $object['_type'] == 'group') || (!empty($object['type']) && $object['type'] == 'group') ) { $object['kind'] = 'group'; } foreach ($object as $key => $values) { [$field, $section] = rcube_utils::explode(':', $key); // avoid casting DateTime objects to array if (is_object($values) && $values instanceof DateTimeInterface) { $values = [$values]; } foreach ((array) $values as $value) { if (isset($value)) { $vcard->set($field, $value, $section); } } } $result = $vcard->export(false); if (!empty($object['kind']) && $object['kind'] == 'group') { $members = ''; foreach ((array) $object['member'] as $member) { $value = null; if (!empty($member['uid'])) { $value = 'urn:uuid:' . $member['uid']; } elseif (!empty($member['email']) && !empty($member['name'])) { $value = 'mailto:' . urlencode(sprintf('"%s" <%s>', addcslashes($member['name'], '"'), $member['email'])); } elseif (!empty($member['email'])) { $value = 'mailto:' . $member['email']; } if ($value) { $members .= "MEMBER:{$value}\r\n"; } } if ($members) { $result = preg_replace('/\r\nEND:VCARD/', "\r\n{$members}END:VCARD", $result); } /** Version 4.0 of the vCard format requires Cyrus >= 3.6.0, we'll use Version 3.0 for now $result = preg_replace('/\r\nVERSION:3\.0\r\n/', "\r\nVERSION:4.0\r\n", $result); $result = preg_replace('/\r\nN:[^\r]+/', '', $result); $result = preg_replace('/\r\nUID:([^\r]+)/', "\r\nUID:urn:uuid:\\1", $result); */ $result = preg_replace('/\r\nMEMBER:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-MEMBER:\\1", $result); $result = preg_replace('/\r\nKIND:([^\r]+)/', "\r\nX-ADDRESSBOOKSERVER-KIND:\\1", $result); } } if ($result) { // The content must be UTF-8, otherwise if we try to fetch the object // from server XML parsing would fail. $result = rcube_charset::clean($result); } return $result; } public function object_location($uid) { return unslashify($this->href) . '/' . urlencode($uid) . '.' . $this->get_dav_ext(); } /** * Get a folder DAV content type */ public function get_dav_type() { return kolab_storage_dav::get_dav_type($this->type); } /** * Get body of an attachment */ public function get_attachment($id, $event, $unused1 = null, $unused2 = false, $unused3 = null, $unused4 = false) { /** @var array $event Overwrite type from the parent class */ // Note: 'attachments' is defined when saving the data into the DAV server // '_attachments' is defined after fetching the object from the DAV server if (is_int($id) && isset($event['attachments'][$id])) { $attachment = $event['attachments'][$id]; } elseif (is_int($id) && isset($event['_attachments'][$id])) { $attachment = $event['_attachments'][$id]; } elseif (is_string($id) && !empty($event['attachments'])) { foreach ($event['attachments'] as $att) { if (!empty($att['id']) && $att['id'] === $id) { $attachment = $att; } } } elseif (is_string($id) && !empty($event['_attachments'])) { foreach ($event['_attachments'] as $att) { if (!empty($att['id']) && $att['id'] === $id) { $attachment = $att; } } } if (empty($attachment)) { return false; } if (!empty($attachment['path'])) { return file_get_contents($attachment['path']); } return $attachment['data'] ?? null; } /** * Get a DAV file extension for specified Kolab type */ public function get_dav_ext() { $types = [ 'event' => 'ics', 'task' => 'ics', 'contact' => 'vcf', ]; return $types[$this->type]; } + /** + * Set ACL for the folder. + * + * @param array $acl ACL list + * + * @return bool True if successful, false on error + */ + public function set_acl($acl) + { + if (!$this->valid) { + return false; + } + + // In Kolab the users (principals) are under /principals/user/ + // TODO: This might need to be configurable or discovered somehow + $path = '/principals/user/'; + if ($host_path = parse_url($this->dav->url, PHP_URL_PATH)) { + $path = '/' . trim($host_path, '/') . $path; + } + + $specials = ['all', 'authenticated', 'self']; + $request = []; + + foreach ($acl as $principal => $privileges) { + // Convert a user identifier into a principal href + if (!in_array($principal, $specials)) { + $principal = $path . $principal; + } + + $request[$principal] = $privileges; + } + + return $this->dav->setACL($this->href, $request); + } + /** * Return folder name as string representation of this object * * @return string Folder display name */ public function __toString() { return $this->attributes['name']; } } diff --git a/plugins/libkolab/lib/kolab_utils.php b/plugins/libkolab/lib/kolab_utils.php index 4100ba6f..2f4d0dfa 100644 --- a/plugins/libkolab/lib/kolab_utils.php +++ b/plugins/libkolab/lib/kolab_utils.php @@ -1,96 +1,120 @@ * @author Aleksander Machniak * * Copyright (C) 2012-2018, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_utils { public static function folder_form($form, $folder, $domain, $hidden_fields = [], $no_acl = false) { $rcmail = rcube::get_instance(); // add folder ACL tab if (!$no_acl && is_string($folder) && strlen($folder)) { $form['sharing'] = [ 'name' => rcube::Q($rcmail->gettext('libkolab.tabsharing')), 'content' => self::folder_acl_form($folder), ]; } $form_html = ''; if (is_array($hidden_fields)) { foreach ($hidden_fields as $field) { $hiddenfield = new html_hiddenfield($field); $form_html .= $hiddenfield->show() . "\n"; } } // create form output foreach ($form as $tab) { if (isset($tab['fields']) && is_array($tab['fields']) && empty($tab['content'])) { $table = new html_table(['cols' => 2, 'class' => 'propform']); foreach ($tab['fields'] as $col => $colprop) { $label = !empty($colprop['label']) ? $colprop['label'] : $rcmail->gettext("$domain.$col"); $table->add('title', html::label($colprop['id'], rcube::Q($label))); $table->add(null, $colprop['value']); } $content = $table->show(); } else { $content = $tab['content']; } if (!empty($content)) { $form_html .= html::tag('fieldset', null, html::tag('legend', null, rcube::Q($tab['name'])) . $content) . "\n"; } } return $form_html; } /** * Handler for ACL form template object + * + * @param string|kolab_storage_dav_folder $folder DAV folder object or IMAP folder name + * + * @return string HTML content */ - public static function folder_acl_form($folder) + private static function folder_acl_form($folder) { + if ($folder instanceof kolab_storage_dav_folder) { + return self::folder_dav_acl_form($folder); + } + $rcmail = rcube::get_instance(); $storage = $rcmail->get_storage(); $options = $storage->folder_info($folder); $rcmail->plugins->load_plugin('acl', true); // get sharing UI from acl plugin $acl = $rcmail->plugins->exec_hook('folder_form', [ 'form' => [], 'options' => $options, 'name' => $folder, ]); if (!empty($acl['form']['sharing']['content'])) { return $acl['form']['sharing']['content']; } return html::div('hint', $rcmail->gettext('libkolab.aclnorights')); } + + /** + * Handler for DAV ACL form template object + * + * @param kolab_storage_dav_folder $folder DAV folder object + * + * @return string HTML content + */ + private static function folder_dav_acl_form($folder) + { + if ($form = kolab_dav_acl::form($folder)) { + return $form; + } + + return html::div('hint', rcmail::get_instance()->gettext('libkolab.aclnorights')); + } } diff --git a/plugins/libkolab/libkolab.js b/plugins/libkolab/libkolab.js index 1b669846..5a2d049b 100644 --- a/plugins/libkolab/libkolab.js +++ b/plugins/libkolab/libkolab.js @@ -1,686 +1,999 @@ /** * Kolab groupware utilities * * @author Thomas Bruederli * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * Copyright (C) 2015-2018, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @licend The above is the entire license notice * for the JavaScript code in this file. */ var libkolab_audittrail = {}, libkolab = {}; libkolab_audittrail.quote_html = function(str) { return String(str).replace(//g, '>').replace(/"/g, '"'); }; // show object changelog in a dialog libkolab_audittrail.object_history_dialog = function(p) { // render dialog var $dialog = $(p.container); // close show dialog first if ($dialog.is(':ui-dialog')) $dialog.dialog('close'); // hide and reset changelog table $dialog.find('div.notfound-message').remove(); $dialog.find('.changelog-table').show().children('tbody') .html('' + rcmail.gettext('loading') + ''); // open jquery UI dialog $dialog.dialog({ modal: true, resizable: true, closeOnEscape: true, title: p.title, open: function() { $dialog.attr('aria-hidden', 'false'); }, close: function() { $dialog.dialog('destroy').attr('aria-hidden', 'true').hide(); }, buttons: [ { text: rcmail.gettext('close'), click: function() { $dialog.dialog('close'); }, 'class': 'cancel', autofocus: true } ], minWidth: 450, width: 650, height: 350, minHeight: 200 }) .show().children('.compare-button').hide(); // initialize event handlers for history dialog UI elements if (!$dialog.data('initialized')) { // compare button $dialog.find('.compare-button input').click(function(e) { var rev1 = $dialog.find('.changelog-table input.diff-rev1:checked').val(), rev2 = $dialog.find('.changelog-table input.diff-rev2:checked').val(); if (rev1 && rev2 && rev1 != rev2) { // swap revisions if the user got it wrong if (rev1 > rev2) { var tmp = rev2; rev2 = rev1; rev1 = tmp; } if (p.comparefunc) { p.comparefunc(rev1, rev2); } } else { alert('Invalid selection!') } if (!rcube_event.is_keyboard(e) && this.blur) { this.blur(); } return false; }); // delegate handlers for list actions $dialog.find('.changelog-table tbody').on('click', 'td.actions a', function(e) { var link = $(this), action = link.hasClass('restore') ? 'restore' : 'show', event = $('#eventhistory').data('event'), rev = link.attr('data-rev'); // ignore clicks on first row (current revision) if (link.closest('tr').hasClass('first')) { return false; } // let the user confirm the restore action if (action == 'restore' && !confirm(rcmail.gettext('revisionrestoreconfirm', p.module).replace('$rev', rev))) { return false; } if (p.listfunc) { p.listfunc(action, rev); } if (!rcube_event.is_keyboard(e) && this.blur) { this.blur(); } return false; }) .on('click', 'input.diff-rev1', function(e) { if (!this.checked) return true; var rev1 = this.value, selection_valid = false; $dialog.find('.changelog-table input.diff-rev2').each(function(i, elem) { $(elem).prop('disabled', elem.value <= rev1); if (elem.checked && elem.value > rev1) { selection_valid = true; } }); if (!selection_valid) { $dialog.find('.changelog-table input.diff-rev2:not([disabled])').last().prop('checked', true); } }); $dialog.addClass('changelog-dialog').data('initialized', true); } return $dialog; }; // callback from server with changelog data libkolab_audittrail.render_changelog = function(data, object, folder) { var Q = libkolab_audittrail.quote_html; var $dialog = $('.changelog-dialog') if (data === false || !data.length) { return false; } var i, change, accessible, op_append, first = data.length - 1, last = 0, is_writeable = !!folder.editable, op_labels = { RECEIVE: 'actionreceive', APPEND: 'actionappend', MOVE: 'actionmove', DELETE: 'actiondelete', READ: 'actionread', FLAGSET: 'actionflagset', FLAGCLEAR: 'actionflagclear' }, actions = ' ' + (is_writeable ? '' : ''), tbody = $dialog.find('.changelog-table tbody').html(''); for (i=first; i >= 0; i--) { change = data[i]; accessible = change.date && change.user; if (change.op == 'MOVE' && change.mailbox) { op_append = ' ⇢ ' + change.mailbox; } else if ((change.op == 'FLAGSET' || change.op == 'FLAGCLEAR') && change.flags) { op_append = ': ' + change.flags; } else { op_append = ''; } $('') .append('' + (accessible && change.op != 'DELETE' ? ' '+ '' : '')) .append('' + Q(i+1) + '') .append('' + Q(change.date || '') + '') .append('' + Q(change.user || 'undisclosed') + '') .append('' + Q(rcmail.gettext(op_labels[change.op] || '', 'libkolab') + op_append) + '') .append('' + (accessible && change.op != 'DELETE' ? actions.replace(/\{rev\}/g, change.rev) : '') + '') .appendTo(tbody); } if (first > 0) { $dialog.find('.compare-button').fadeIn(200); $dialog.find('.changelog-table tr.last input.diff-rev1').click(); } // set dialog size according to content libkolab_audittrail.dialog_resize($dialog.get(0), $dialog.height() + 15, 600); return $dialog; }; // resize and reposition (center) the dialog window libkolab_audittrail.dialog_resize = function(id, height, width) { var win = $(window), w = win.width(), h = win.height(); $(id).dialog('option', { height: Math.min(h-20, height+130), width: Math.min(w-20, width+50) }); }; /** * Open an attachment either in a browser window for inline view or download it */ libkolab.load_attachment = function(query, attachment) { query._frame = 1; // open attachment in frame if it's of a supported mimetype similar as in app.js if (attachment.id && attachment.mimetype && $.inArray(attachment.mimetype, rcmail.env.mimetypes) >= 0) { if (rcmail.open_window(rcmail.url('get-attachment', query), true, true)) { return; } } query._frame = null; query._download = 1; rcmail.goto_url('get-attachment', query, false); }; /** * Build attachments list element */ libkolab.list_attachments = function(list, container, edit, data, ondelete, onload) { var ul = $('