diff --git a/.gitignore b/.gitignore --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,8 @@ *.tar.gz.gpg *.patch .phpunit.result.cache -plugins/*/config.inc.php -program -skins -tests -vendor +./plugins/*/config.inc.php +./program +./skins +./tests +./vendor diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -187,6 +187,8 @@ $this->register_action('resources-autocomplete', [$this, 'resources_autocomplete']); $this->register_action('talk-room-create', [$this, 'talk_room_create']); + $this->rc->plugins->register_action('plugin.share-invitation', $this->ID, [$this, 'share_invitation']); + $this->add_hook('refresh', [$this, 'refresh']); // remove undo information... @@ -1030,7 +1032,7 @@ $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); foreach ((array) $this->driver->search_calendars($query, $source) as $id => $prop) { - $editname = $prop['editname']; + $editname = $prop['editname'] ?? ''; unset($prop['editname']); // force full name to be displayed $prop['active'] = false; @@ -1623,6 +1625,19 @@ return $events; } + /** + * Handle invitations to a shared folder + */ + public function share_invitation() + { + $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); + $invitation = rcube_utils::get_input_value('invitation', rcube_utils::INPUT_POST); + + if ($calendar = $this->driver->accept_share_invitation($invitation)) { + $this->rc->output->command('plugin.share-invitation', ['id' => $id, 'source' => $calendar]); + } + } + /** * Handler for keep-alive requests * This will check for updated data in active calendars and sync them to the client diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -3572,7 +3572,7 @@ if (node && node.id && me.calendars[node.id]) { me.select_calendar(node.id, true); rcmail.enable_command('calendar-edit', 'calendar-showurl', 'calendar-showfburl', true); - rcmail.enable_command('calendar-delete', me.calendars[node.id].editable); + rcmail.enable_command('calendar-delete', me.has_permission(me.calendars[node.id], 'xa')); rcmail.enable_command('calendar-remove', me.calendars[node.id] && me.calendars[node.id].removable); } }); diff --git a/plugins/calendar/drivers/caldav/caldav_calendar.php b/plugins/calendar/drivers/caldav/caldav_calendar.php --- a/plugins/calendar/drivers/caldav/caldav_calendar.php +++ b/plugins/calendar/drivers/caldav/caldav_calendar.php @@ -30,6 +30,7 @@ public $alarms = false; public $history = false; public $subscriptions = false; + public $share_invitation = null; public $categories = []; public $storage; @@ -75,17 +76,15 @@ $this->alarms = !isset($this->attributes['alarms']) || $this->attributes['alarms']; if ($this->storage->get_namespace() == 'personal') { - $this->editable = true; $this->rights = 'lrswikxteav'; } else { - $rights = $this->storage->get_myrights(); - if ($rights) { - $this->rights = $rights; - if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) { - $this->editable = strpos($rights, 'i'); - ; - } - } + $this->rights = $this->storage->get_myrights(); + } + + $this->editable = strpos($this->rights, 'i') !== false; + + if (!empty($this->attributes['invitation'])) { + $this->share_invitation = $this->attributes['invitation']; } } } diff --git a/plugins/calendar/drivers/caldav/caldav_driver.php b/plugins/calendar/drivers/caldav/caldav_driver.php --- a/plugins/calendar/drivers/caldav/caldav_driver.php +++ b/plugins/calendar/drivers/caldav/caldav_driver.php @@ -118,6 +118,51 @@ return $calendar; } + protected function _to_calendar_props($cal, $prefs = []) + { + $is_user = false; // ($cal instanceof caldav_user_calendar); + + $result = [ + 'id' => $cal->id, + 'name' => $cal->get_name(), + 'listname' => $cal->get_name(), + 'editname' => $cal->get_foldername(), + 'title' => null, + '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) { + $result += [ + 'default' => $cal->default, + 'rights' => $cal->rights, + 'showalarms' => $cal->alarms, + 'history' => !empty($this->bonnie_api), + 'subtype' => $cal->subtype, + 'caldavurl' => '', // $cal->get_caldav_url(), + ]; + } + + if ($cal->subscriptions) { + $result['subscribed'] = $cal->is_subscribed(); + } + + if (!empty($cal->share_invitation)) { + $result['share_invitation'] = $cal->share_invitation; + $result['active'] = true; + } + + return $result; + } + /** * Get a list of available calendars from this source. * @@ -179,78 +224,46 @@ $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', - ]; + $calendars[$cal->id] = $this->_to_calendar_props($cal, $prefs); - // @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(), - ]; - } + $calendars[$cal->id]['children'] = true; // TODO: determine if that folder indeed has child folders + $calendars[$cal->id]['parent'] = $parent_id; /* } */ - 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; - } - } + // 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; @@ -423,43 +436,72 @@ { $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; - } + // find calendar folders, except other user's folders + if ($source == 'folders') { + foreach ((array) $this->storage->search_folders('event', $query, ['other']) as $folder) { + $calendar = new caldav_calendar($folder, $this->cal); + $this->calendars[$calendar->id] = $calendar; + } + } + // find other user's calendars (invitations) + elseif ($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; + } + */ + + foreach ($this->storage->get_share_invitations('event', $query) as $invitation) { + $calendar = new caldav_calendar($invitation, $this->cal); + $this->calendars[$calendar->id] = $calendar; + + if (count($this->calendars) > $limit) { + $this->search_more_results = true; } + } + } + + // don't list the birthday/invitations calendars + $this->rc->config->set('calendar_contact_birthdays', false); + $this->rc->config->set('kolab_invitation_calendars', false); - // 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(); } + /** + * Accept an invitation to a shared folder + * + * @param string $href Invitation location href + * + * @return array|false + */ + public function accept_share_invitation($href) + { + $folder = $this->storage->accept_share_invitation('event', $href); + + if ($folder === false) { + return false; + } + + $calendar = $this->_to_calendar($folder); + + $prefs['kolab_calendars'] = $this->rc->config->get('kolab_calendars', []); + + $prop = $this->_to_calendar_props($calendar, $prefs['kolab_calendars']); + + // Activate the folder + $prefs['kolab_calendars'][$prop['id']]['active'] = true; + + $this->rc->user->save_prefs($prefs); + + return $prop; + } + /** * Get events from source. * @@ -676,6 +718,11 @@ 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' => [ @@ -685,6 +732,6 @@ ], ]; - return kolab_utils::folder_form($form, '', 'calendar', [], true); + return kolab_utils::folder_form($form, $folder ?? null, 'calendar', []); } } diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php --- a/plugins/calendar/drivers/calendar_driver.php +++ b/plugins/calendar/drivers/calendar_driver.php @@ -862,6 +862,18 @@ return true; } + /** + * Accept an invitation to a shared folder + * + * @param string $href Invitation location href + * + * @return array|false + */ + public function accept_share_invitation($href) + { + return false; + } + /** * Handler for user_delete plugin hook * diff --git a/plugins/calendar/drivers/database/database_driver.php b/plugins/calendar/drivers/database/database_driver.php --- a/plugins/calendar/drivers/database/database_driver.php +++ b/plugins/calendar/drivers/database/database_driver.php @@ -92,7 +92,7 @@ $arr['name'] = html::quote($arr['name']); $arr['listname'] = html::quote($arr['name']); $arr['rights'] = 'lrswikxteav'; - $arr['editable'] = true; + $arr['editable'] = true; $this->calendars[$arr['calendar_id']] = $arr; $calendar_ids[] = $db->quote($arr['calendar_id']); diff --git a/plugins/calendar/lib/calendar_ui.php b/plugins/calendar/lib/calendar_ui.php --- a/plugins/calendar/lib/calendar_ui.php +++ b/plugins/calendar/lib/calendar_ui.php @@ -310,18 +310,14 @@ // enrich calendar properties with settings from the driver if (empty($prop['virtual'])) { unset($prop['user_id']); + $feed = ['_cal' => $this->cal->ical_feed_hash($id) . '.ics', 'action' => 'feed']; $prop['alarms'] = $this->cal->driver->alarms; $prop['attendees'] = $this->cal->driver->attendees; $prop['freebusy'] = $this->cal->driver->freebusy; $prop['attachments'] = $this->cal->driver->attachments; $prop['undelete'] = $this->cal->driver->undelete; - $prop['feedurl'] = $this->cal->get_url( - [ - '_cal' => $this->cal->ical_feed_hash($id) . '.ics', - 'action' => 'feed', - ] - ); + $prop['feedurl'] = $this->cal->get_url($feed); $jsenv[$id] = $prop; } @@ -338,7 +334,7 @@ if (!empty($prop['virtual'])) { $classes[] = 'virtual'; - } elseif (empty($prop['editable'])) { + } elseif (!empty($prop['rights']) && strpos($prop['rights'], 'i') === false && strpos($prop['rights'], 'w') === false) { $classes[] = 'readonly'; } if (!empty($prop['subscribed'])) { @@ -358,7 +354,7 @@ $label_id = 'cl:' . $id; $content = html::a( ['class' => 'calname', 'id' => $label_id, 'title' => $title, 'href' => '#'], - rcube::Q(!empty($prop['editname']) ? $prop['editname'] : $prop['listname']) + rcube::Q(!empty($prop['listname']) ? $prop['listname'] : $prop['name']) ); if (empty($prop['virtual'])) { @@ -383,8 +379,9 @@ 'title' => $this->cal->gettext('quickview'), 'role' => 'checkbox', 'aria-checked' => 'false', + 'style' => !empty($prop['share_invitation']) ? 'display:none' : null, ], - '' + ' ' ); if (!isset($prop['subscriptions']) || $prop['subscriptions'] !== false) { @@ -406,7 +403,7 @@ 'type' => 'checkbox', 'name' => '_cal[]', 'value' => $id, - 'checked' => !empty($prop['active']), + 'checked' => !empty($prop['active']) && empty($prop['share_invitation']), 'aria-labelledby' => $label_id, ]) . html::span('actions', $actions) @@ -457,10 +454,7 @@ $select = new html_select($attrib); foreach ((array) $this->cal->driver->list_calendars() as $id => $prop) { - if ( - !empty($prop['editable']) - || (!empty($prop['rights']) && strpos($prop['rights'], 'i') !== false) - ) { + if (!empty($prop['rights']) && strpos($prop['rights'], 'i') !== false) { $select->add($prop['name'], $id); } } diff --git a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php --- a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php +++ b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts.php @@ -33,6 +33,7 @@ public $readonly = true; public $undelete = false; public $groups = true; + public $share_invitation = null; public $coltypes = [ 'name' => ['limit' => 1], @@ -76,9 +77,9 @@ 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; @@ -122,18 +123,15 @@ // Set readonly and rights flags according to folder permissions if ($this->ready) { - if ($this->storage->get_owner() == $_SESSION['username']) { + if ($this->storage->get_namespace() == 'personal') { $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; - } - } + $this->rights = $this->storage->get_myrights(); + $this->readonly = strpos($this->rights, 'i') === false; } + + $this->share_invitation = $this->storage->attributes['invitation'] ?? null; } } diff --git a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts_driver.php b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts_driver.php --- a/plugins/kolab_addressbook/drivers/carddav/carddav_contacts_driver.php +++ b/plugins/kolab_addressbook/drivers/carddav/carddav_contacts_driver.php @@ -25,6 +25,8 @@ class carddav_contacts_driver { + public $search_more_results = false; + protected $plugin; protected $rc; protected $sources; @@ -55,6 +57,45 @@ return $this->sources; } + /** + * Search for shared or otherwise not listed addressbooks the user has access to + * + * @param string $query Search string + * @param string $source Section/source to search + * + * @return array List of addressbooks + */ + public function search_folders($query, $source) + { + $this->search_more_results = false; + $storage = self::get_storage(); + $result = []; + + // find addressbook folders, except other user's folders + if ($source == 'folders') { + foreach ((array) $storage->search_folders('event', $query, ['other']) as $folder) { + $abook = new carddav_contacts($folder); + $result[] = $this->abook_prop($folder->id, $abook); + } + } + // find other user's addressbooks (invitations) + elseif ($source == 'users') { + // we have slightly more space, so display twice the number + $limit = $this->rc->config->get('autocomplete_max', 15) * 2; + + foreach ($storage->get_share_invitations('contact', $query) as $invitation) { + $abook = new carddav_contacts($invitation); + $result[] = $this->abook_prop($invitation->id, $abook); + + if (count($result) > $limit) { + $this->search_more_results = true; + } + } + } + + return $result; + } + /** * Getter for the rcube_addressbook instance * @@ -118,7 +159,8 @@ $name = ''; if ($source && ($book = $this->get_address_book($source))) { - $name = $book->get_name(); + $name = $book->get_foldername(); + $folder = $book->storage; } $foldername = new html_inputfield(['name' => '_name', 'id' => '_name', 'size' => 30]); @@ -140,7 +182,7 @@ $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); } /** @@ -174,6 +216,55 @@ } } + /** + * Subscribe to a folder + * + * @param string $id Folder identifier + * @param array $options Action options + * + * @return bool + */ + public function folder_subscribe($id, $options = []) + { + /* + $success = false; + if ($id && ($folder = kolab_storage::get_folder(kolab_storage::id_decode($id)))) { + if (isset($options['permanent'])) { + $success |= $folder->subscribe(intval($options['permanent'])); + } + + if (isset($options['active'])) { + $success |= $folder->activate(intval($options['active'])); + } + } + return $success; + */ + + return true; + } + + /** + * Accept an invitation to a shared folder + * + * @param string $href Invitation location href + * + * @return array|false + */ + public function accept_share_invitation($href) + { + $storage = self::get_storage(); + + $folder = $storage->accept_share_invitation('contact', $href); + + if ($folder === false) { + return false; + } + + $abook = new carddav_contacts($folder); + + return $this->abook_prop($folder->id, $abook); + } + /** * Helper method to build a hash array of address book properties */ @@ -197,12 +288,11 @@ return [ 'id' => $id, 'name' => $abook->get_name(), - 'listname' => $abook->get_foldername(), + 'listname' => $abook->get_name(), '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(), @@ -210,6 +300,7 @@ 'kolab' => true, 'carddav' => true, 'audittrail' => false, // !empty($this->plugin->bonnie_api), + 'share_invitation' => $abook->share_invitation, ]; } } diff --git a/plugins/kolab_addressbook/drivers/kolab/kolab_contacts_driver.php b/plugins/kolab_addressbook/drivers/kolab/kolab_contacts_driver.php --- a/plugins/kolab_addressbook/drivers/kolab/kolab_contacts_driver.php +++ b/plugins/kolab_addressbook/drivers/kolab/kolab_contacts_driver.php @@ -25,6 +25,8 @@ */ class kolab_contacts_driver { + public $search_more_results = false; + protected $plugin; protected $rc; @@ -60,6 +62,87 @@ return $sources; } + /** + * Search for shared or otherwise not listed addressbooks the user has access to + * + * @param string $query Search string + * @param string $source Section/source to search + * + * @return array List of addressbooks + */ + public function search_folders($query, $source) + { + $this->search_more_results = false; + $results = []; + $sources = []; + $folders = []; + + // find unsubscribed IMAP folders that have "event" type + if ($source == 'folders') { + foreach ((array) kolab_storage::search_folders('contact', $query, ['other']) as $folder) { + $folders[$folder->id] = $folder; + $sources[$folder->id] = new kolab_contacts($folder->name); + } + } + // search other user's namespace via LDAP + elseif ($source == 'users') { + // We have slightly more space, so display twice the number + $limit = $this->rc->config->get('autocomplete_max', 15) * 2; + + foreach (kolab_storage::search_users($query, 0, [], $limit * 10) as $user) { + $folders = []; + // search for contact folders shared by this user + foreach (kolab_storage::list_user_folders($user, 'contact', false) as $foldername) { + $folders[] = new kolab_storage_folder($foldername, 'contact'); + } + + $count = 0; + if (count($folders)) { + $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); + $folders[$userfolder->id] = $userfolder; + $sources[$userfolder->id] = $userfolder; + + foreach ($folders as $folder) { + $folders[$folder->id] = $folder; + $sources[$folder->id] = new kolab_contacts($folder->name); + $count++; + } + } + + if ($count >= $limit) { + $this->search_more_results = true; + break; + } + } + } + + $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); + + // build results list + foreach ($sources as $id => $source) { + $folder = $folders[$id]; + $imap_path = explode($delim, $folder->name); + + // find parent + do { + array_pop($imap_path); + $parent_id = kolab_storage::folder_id(implode($delim, $imap_path)); + } while (count($imap_path) > 1 && empty($folders[$parent_id])); + + // restore "real" parent ID + if ($parent_id && empty($folders[$parent_id])) { + $parent_id = kolab_storage::folder_id($folder->get_parent()); + } + + $prop = $this->abook_prop($id, $source); + $prop['parent'] = $parent_id; + + $results[] = $prop; + } + + return $results; + } + /** * Getter for the rcube_addressbook instance * @@ -228,6 +311,31 @@ } } + /** + * Subscribe to a folder + * + * @param string $id Folder identifier + * @param array $options Action options + * + * @return bool + */ + public function folder_subscribe($id, $options = []) + { + $success = false; + + if ($id && ($folder = kolab_storage::get_folder(kolab_storage::id_decode($id)))) { + if (isset($options['permanent'])) { + $success |= $folder->subscribe(intval($options['permanent'])); + } + + if (isset($options['active'])) { + $success |= $folder->activate(intval($options['active'])); + } + } + + return $success; + } + /** * Helper method to build a hash array of address book properties */ diff --git a/plugins/kolab_addressbook/kolab_addressbook.js b/plugins/kolab_addressbook/kolab_addressbook.js --- a/plugins/kolab_addressbook/kolab_addressbook.js +++ b/plugins/kolab_addressbook/kolab_addressbook.js @@ -121,11 +121,12 @@ { var source = !this.env.group ? this.env.source : null, sources = this.env.address_sources || {}, - props = source && sources[source] && sources[source].kolab ? sources[source] : { removable: false, rights: '' }; + props = source && sources[source] && sources[source].kolab ? sources[source] : { removable: false, rights: '' }, + can_delete = props.rights.indexOf('x') >= 0 || props.rights.indexOf('a') >= 0; this.enable_command('book-create', true); - this.enable_command('book-edit', props.rights.indexOf('a') >= 0); - this.enable_command('book-delete', props.rights.indexOf('x') >= 0 || props.rights.indexOf('a') >= 0); + this.enable_command('book-edit', can_delete); + this.enable_command('book-delete', can_delete); this.enable_command('book-remove', props.removable); this.enable_command('book-showurl', !!props.carddavurl || source == this.env.kolab_addressbook_carddav_ldap); }; diff --git a/plugins/kolab_addressbook/kolab_addressbook.php b/plugins/kolab_addressbook/kolab_addressbook.php --- a/plugins/kolab_addressbook/kolab_addressbook.php +++ b/plugins/kolab_addressbook/kolab_addressbook.php @@ -34,7 +34,6 @@ public $driver; public $bonnie_api = false; - private $folders; private $sources; private $rc; private $ui; @@ -90,6 +89,8 @@ $this->register_action('plugin.contact-diff', [$this, 'contact_diff']); $this->register_action('plugin.contact-restore', [$this, 'contact_restore']); + $this->register_action('plugin.share-invitation', [$this, 'share_invitation']); + // get configuration for the Bonnie API $this->bonnie_api = libkolab::get_bonnie_api(); @@ -305,6 +306,7 @@ 'href' => $this->rc->url(['_source' => $id]), 'rel' => $source['id'], 'id' => $label_id, + 'class' => 'listname', 'onclick' => "return " . rcmail_output::JS_OBJECT_NAME . ".command('list','" . rcube::JQ($id) . "',this)", ], $name) ); @@ -322,7 +324,7 @@ if ($search_mode) { $jsdata[$id]['group'] = implode(' ', $classes); - if (!$source['virtual']) { + if (empty($source['virtual'])) { $inner .= html::tag('input', [ 'type' => 'checkbox', 'name' => '_source[]', @@ -862,7 +864,6 @@ return $args; } - /** * Handler for plugin actions */ @@ -889,88 +890,28 @@ } /** - * + * Search for addressbook folders not subscribed yet */ public function book_search() { - $results = []; - $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); - $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); - - kolab_storage::$encode_ids = true; - $search_more_results = false; - $this->sources = []; - $this->folders = []; - - // find unsubscribed IMAP folders that have "event" type - if ($source == 'folders') { - foreach ((array)kolab_storage::search_folders('contact', $query, ['other']) as $folder) { - $this->folders[$folder->id] = $folder; - $this->sources[$folder->id] = new kolab_contacts($folder->name); - } - } - // search other user's namespace via LDAP - elseif ($source == 'users') { - $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number - foreach (kolab_storage::search_users($query, 0, [], $limit * 10) as $user) { - $folders = []; - // search for contact folders shared by this user - foreach (kolab_storage::list_user_folders($user, 'contact', false) as $foldername) { - $folders[] = new kolab_storage_folder($foldername, 'contact'); - } - - $count = 0; - if (count($folders)) { - $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); - $this->folders[$userfolder->id] = $userfolder; - $this->sources[$userfolder->id] = $userfolder; - - foreach ($folders as $folder) { - $this->folders[$folder->id] = $folder; - $this->sources[$folder->id] = new kolab_contacts($folder->name); - ; - $count++; - } - } - - if ($count >= $limit) { - $search_more_results = true; - break; - } - } - } + $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); + $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); - $delim = $this->rc->get_storage()->get_hierarchy_delimiter(); + $jsdata = []; + $results = []; // build results list - foreach ($this->sources as $id => $source) { - $folder = $this->folders[$id]; - $imap_path = explode($delim, $folder->name); - - // find parent - do { - array_pop($imap_path); - $parent_id = kolab_storage::folder_id(implode($delim, $imap_path)); - } while (count($imap_path) > 1 && empty($this->folders[$parent_id])); - - // restore "real" parent ID - if ($parent_id && empty($this->folders[$parent_id])) { - $parent_id = kolab_storage::folder_id($folder->get_parent()); - } - - $prop = $this->driver->abook_prop($id, $source); - $prop['parent'] = $parent_id; - - $html = $this->addressbook_list_item($id, $prop, $jsdata, true); + foreach ($this->driver->search_folders($query, $source) as $prop) { + $html = $this->addressbook_list_item($prop['id'], $prop, $jsdata, true); unset($prop['group']); - $prop += (array)$jsdata[$id]; + $prop += (array)$jsdata[$prop['id']]; $prop['html'] = $html; $results[] = $prop; } // report more results available - if ($search_more_results) { + if ($this->driver->search_more_results) { $this->rc->output->show_message('autocompletemore', 'notice'); } @@ -978,25 +919,22 @@ } /** - * + * Handler for address book subscription action */ public function book_subscribe() { - $success = false; $id = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC); + $options = [ + 'permanent' => $_POST['_permanent'] ?? null, + 'active' => $_POST['_active'] ?? null, + 'groups' => !empty($_POST['_groups']), + ]; - if ($id && ($folder = kolab_storage::get_folder(kolab_storage::id_decode($id)))) { - if (isset($_POST['_permanent'])) { - $success |= $folder->subscribe(intval($_POST['_permanent'])); - } - if (isset($_POST['_active'])) { - $success |= $folder->activate(intval($_POST['_active'])); - } - + if ($success = $this->driver->folder_subscribe($id, $options)) { // list groups for this address book - if (!empty($_POST['_groups'])) { - $abook = new kolab_contacts($folder->name); - foreach ((array)$abook->list_groups() as $prop) { + if ($options['groups']) { + $abook = $this->driver->get_address_book($id); + foreach ((array) $abook->list_groups() as $prop) { $prop['source'] = $id; $prop['id'] = $prop['ID']; unset($prop['ID']); @@ -1014,7 +952,6 @@ $this->rc->output->send(); } - /** * Handler for address book delete action (AJAX) */ @@ -1039,6 +976,19 @@ $this->rc->output->send(); } + /** + * Handle invitations to a shared folder + */ + public function share_invitation() + { + $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); + $invitation = rcube_utils::get_input_value('invitation', rcube_utils::INPUT_POST); + + if ($addressbook = $this->driver->accept_share_invitation($invitation)) { + $this->rc->output->command('plugin.share-invitation', ['id' => $id, 'source' => $addressbook]); + } + } + /** * Returns value of kolab_addressbook_prio setting */ diff --git a/plugins/libkolab/config.inc.php.dist b/plugins/libkolab/config.inc.php.dist --- a/plugins/libkolab/config.inc.php.dist +++ b/plugins/libkolab/config.inc.php.dist @@ -76,6 +76,12 @@ // possible units: s, m, h, d, w $config['kolab_users_cache_ttl'] = '10d'; +// Enable sharing of DAV folders. Default: null (disabled) +// Possible options: +// 'acl': Use standard DAV ACL permissions system +// 'sharing': Use draft-pot-webdav-resource-sharing standard (for Cyrus-DAV v3) +$config['kolab_dav_sharing'] = null; + // JSON-RPC endpoint configuration of the Bonnie web service providing historic data for groupware objects $config['kolab_bonnie_api'] = null; /* diff --git a/plugins/libkolab/lib/kolab_dav_acl.php b/plugins/libkolab/lib/kolab_dav_acl.php new file mode 100644 --- /dev/null +++ b/plugins/libkolab/lib/kolab_dav_acl.php @@ -0,0 +1,543 @@ + + * + * 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 + if (strpos($myrights, 'a') === false) { + 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 --- a/plugins/libkolab/lib/kolab_dav_client.php +++ b/plugins/libkolab/lib/kolab_dav_client.php @@ -23,6 +23,23 @@ 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 const INVITE_ACCEPTED = 'accepted'; + public const INVITE_DECLINED = 'declined'; + + public const NOTIFICATION_SHARE_INVITE = 'share-invite-notification'; + public const NOTIFICATION_SHARE_REPLY = 'share-reply-notification'; + + public const SHARING_READ = 'read'; + public const SHARING_READ_WRITE = 'read-write'; + public const SHARING_NO_ACCESS = 'no-access'; + public const SHARING_OWNER = 'shared-owner'; + public const SHARING_NOT_SHARED = 'not-shared'; + public $url; protected $user; @@ -113,19 +130,17 @@ } /** - * Discover DAV home (root) collection of specified type. + * Discover (common) DAV home collections. * - * @param string $component Component to filter by (VEVENT, VTODO, VCARD) - * - * @return string|false Home collection location or False on error + * @return array|false Homes locations or False on error */ - public function discover($component = 'VEVENT') + public function discover() { if ($cache = $this->get_cache()) { - $cache_key = "discover.{$component}." . md5($this->url); + $cache_key = "discover." . md5($this->url); - if ($response = $cache->get($cache_key)) { - return $response; + if ($homes = $cache->get($cache_key)) { + return $homes; } } @@ -159,22 +174,12 @@ $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 = '' - . '' + . '' . '' - . '' + . '' + . '' + . '' . '' . ''; @@ -185,27 +190,54 @@ } $elements = $response->getElementsByTagName('response'); + $homes = []; - foreach ($elements as $element) { - foreach ($element->getElementsByTagName($homes[$component]) as $prop) { - $root_href = $prop->nodeValue; - break; + if ($element = $response->getElementsByTagName('response')->item(0)) { + if ($prop = $element->getElementsByTagName('prop')->item(0)) { + foreach ($prop->childNodes as $home) { + if ($home->firstChild && $home->firstChild->localName == 'href') { + $href = $home->firstChild->nodeValue; + + if ($path && strpos($href, $path) === 0) { + $href = substr($href, strlen($path)); + } + + $homes[$home->localName] = $href; + } + } } } - if (empty($root_href)) { - return false; + if ($cache) { + $cache->set($cache_key, $homes); } - if ($path && strpos($root_href, $path) === 0) { - $root_href = substr($root_href, strlen($path)); - } + return $homes; + } - if ($cache) { - $cache->set($cache_key, $root_href); + /** + * Get user home folder of specified type + * + * @param string $type Home type or component name + * + * @return string|null Folder location href + */ + public function getHome($type) + { + $options = [ + 'VEVENT' => 'calendar-home-set', + 'VTODO' => 'calendar-home-set', + 'VCARD' => 'addressbook-home-set', + 'NOTIFICATION' => 'notification-URL', + ]; + + $homes = $this->discover(); + + if (is_array($homes) && isset($options[$type])) { + return $homes[$options[$type]] ?? null; } - return $root_href; + return null; } /** @@ -217,9 +249,9 @@ */ public function listFolders($component = 'VEVENT') { - $root_href = $this->discover($component); + $root_href = $this->getHome($component); - if ($root_href === false) { + if ($root_href === null) { return false; } @@ -238,7 +270,8 @@ . '' . '' . '' - // . '' + . '' // draft-pot-webdav-resource-sharing-04 + . '' // RFC 3744 (ACL) . '' . $props . '' @@ -315,7 +348,7 @@ */ public function delete($location) { - $response = $this->request($location, 'DELETE', '', ['Depth' => 1, 'Prefer' => 'return-minimal']); + $response = $this->request($location, 'DELETE'); return $response !== false; } @@ -346,9 +379,30 @@ */ 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 = '' - . '' - . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' // draft-pot-webdav-resource-sharing-04 + . '' // RFC 3744 (ACL) + . '' + . '' + . '' . ''; // Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it) @@ -489,6 +543,134 @@ return $response !== false; } + /** + * Fetch DAV notifications + * + * @param ?array $types Notification types to return + * + * @return false|array Notification objects on success, False on error + */ + public function listNotifications($types = []) + { + $root_href = $this->getHome('NOTIFICATION'); + + if ($root_href === null) { + return false; + } + + $body = '' + . ' ' + . '' + . '' + . '' + . ''; + + $response = $this->request($root_href, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']); + + if (empty($response)) { + return false; + } + + $objects = []; + + foreach ($response->getElementsByTagName('response') as $element) { + $type = $element->getElementsByTagName('notificationtype')->item(0); + + if ($type && $type->firstChild) { + $type = $type->firstChild->localName; + + if (empty($types) || in_array($type, $types)) { + $href = $element->getElementsByTagName('href')->item(0); + if ($notification = $this->getNotification($href->nodeValue)) { + $objects[] = $notification; + } + } + } + } + + return $objects; + } + + /** + * Get a single DAV notification + * + * @param string $location Notification href + * + * @return false|array Notification data on success, False on error + */ + public function getNotification($location) + { + $response = $this->request($location, 'GET', '', ['Content-Type' => 'application/davnotification+xml']); + + if (empty($response)) { + return false; + } + + // Note: Cyrus implements draft-pot-webdav-resource-sharing v02, not the most recent one(s), + // and even v02 support is broken in some places + + if ($access = $response->getElementsByTagName('access')->item(0)) { + $access = $access->firstChild; + $access = $access->localName; // 'read' or 'read-write' + } + + foreach (['invite-noresponse', 'invite-accepted', 'invite-declined', 'invite-invalid', 'invite-deleted'] as $name) { + if ($node = $response->getElementsByTagName($name)->item(0)) { + $result['status'] = str_replace('invite-', '', $node->localName); + } + } + + if ($organizer = $response->getElementsByTagName('organizer')->item(0)) { + if ($href = $organizer->getElementsByTagName('href')->item(0)) { + $organizer = $href->nodeValue; + } + // There should be also 'displayname', but Cyrus uses 'common-name', + // we'll ignore it for now anyway. + } elseif ($organizer = $response->getElementsByTagName('principal')->item(0)) { + if ($href = $organizer->getElementsByTagName('href')->item(0)) { + $organizer = $href->nodeValue; + } + // There should be also 'displayname', but Cyrus uses 'common-name', + // we'll ignore it for now anyway. + } + + $components = []; + if ($set_element = $response->getElementsByTagName('supported-calendar-component-set')->item(0)) { + foreach ($set_element->getElementsByTagName('comp') as $comp_element) { + $components[] = $comp_element->attributes->getNamedItem('name')->nodeValue; + } + } + + $result = [ + 'href' => $location, + 'access' => $access, + 'types' => $components, + 'organizer' => $organizer, + ]; + + // Cyrus uses 'summary', but it's 'comment' in more recent standard + foreach (['dtstamp', 'summary', 'comment'] as $name) { + if ($node = $response->getElementsByTagName($name)->item(0)) { + $result[$name] = $node->nodeValue; + } + } + + // Note: In more recent standard there are 'displayname' and 'resourcetype' props + + // Note: 'hosturl' exists in v2, but starting from v3 'sharer-resource-uri' is used + if ($hosturl = $response->getElementsByTagName('hosturl')->item(0)) { + if ($href = $hosturl->getElementsByTagName('href')->item(0)) { + $result['resource-uri'] = $href->nodeValue; + } + } elseif ($hosturl = $response->getElementsByTagName('sharer-resource-uri')->item(0)) { + if ($href = $hosturl->getElementsByTagName('href')->item(0)) { + $result['resource-uri'] = $href->nodeValue; + } + } + + return $result; + } + /** * Fetch DAV objects metadata (ETag, href) a folder * @@ -603,6 +785,140 @@ return $objects; } + /** + * Accept/Deny a share invitation (draft-pot-webdav-resource-sharing) + * + * @param string $location Notification location + * @param string $action Reply action ('accepted' or 'declined') + * @param array $props Additional reply properties (slug, comment) + * + * @return bool True on success, False on error + */ + public function inviteReply($location, $action = self::INVITE_ACCEPTED, $props = []) + { + $reply = ''; + + // Note: and are ignored by Cyrus + + if (!empty($props['comment'])) { + $reply .= '' . htmlspecialchars($props['comment'], ENT_XML1, 'UTF-8') . ''; + } + + $headers = ['Content-Type' => 'application/davsharing+xml; charset=utf-8']; + + $body = '' + . '' . $reply . ''; + + $response = $this->request($location, 'POST', $body, $headers); + + return $response !== false; + } + + /** + * 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; + } + + /** + * Share a reasource (draft-pot-webdav-resource-sharing) + * + * @param string $location Resource location + * @param array $sharees Sharees list + * + * @return bool True on success, False on error + */ + public function shareResource($location, $sharees = []) + { + $props = ''; + + foreach ($sharees as $href => $sharee) { + $props .= '' + . '' . htmlspecialchars($href, ENT_XML1, 'UTF-8') . '' + . '' + . ''; + + if (isset($sharee['comment']) && strlen($sharee['comment'])) { + $props .= '' . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8') . ''; + } + + if (isset($sharee['displayname']) && strlen($sharee['displayname'])) { + $props .= '' + . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8') + . ''; + } + + $props .= ''; + } + + $headers = ['Content-Type' => 'application/davsharing+xml; charset=utf-8']; + + $body = '' + . '' . $props . ''; + + $response = $this->request($location, 'POST', $body, $headers); + + return $response !== false; + } + /** * Parse XML content */ @@ -688,8 +1004,7 @@ $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]; + $types[] = $node->localName; } } @@ -702,6 +1017,122 @@ '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; + } + + if ($owner = $element->getElementsByTagName('owner')->item(0)) { + if ($owner->firstChild) { + $result['owner'] = $owner->firstChild->nodeValue; + } + } + + // 'share-access' from draft-pot-webdav-resource-sharing + if ($share = $element->getElementsByTagName('share-access')->item(0)) { + if ($share->firstChild) { + $result['share-access'] = $share->firstChild->localName; + } + } + + // 'invite' from draft-pot-webdav-resource-sharing + if ($invite_element = $element->getElementsByTagName('invite')->item(0)) { + $invites = []; + foreach ($invite_element->childNodes as $sharee) { + /** @var DOMElement $sharee */ + $href = $sharee->getElementsByTagName('href')->item(0)->nodeValue; + $status = 'noresponse'; + + if ($comment = $sharee->getElementsByTagName('comment')->item(0)) { + $comment = $comment->nodeValue; + } + + if ($displayname = $sharee->getElementsByTagName('displayname')->item(0)) { + $displayname = $displayname->nodeValue; + } + + if ($access = $sharee->getElementsByTagName('share-access')->item(0)) { + $access = $access->firstChild->localName; + } else { + $access = self::SHARING_NOT_SHARED; + } + + foreach (['invite-noresponse', 'invite-accepted', 'invite-declined', 'invite-invalid', 'invite-deleted'] as $name) { + if ($node = $sharee->getElementsByTagName($name)->item(0)) { + $status = str_replace('invite-', '', $node->localName); + } + } + + $invites[$href] = [ + 'access' => $access, + 'status' => $status, + 'comment' => $comment, + 'displayname' => $displayname, + ]; + } + + $result['invite'] = $invites; + } + foreach (['alarms'] as $tag) { if ($el = $element->getElementsByTagName($tag)->item(0)) { if (strlen($el->nodeValue) > 0) { diff --git a/plugins/libkolab/lib/kolab_dav_sharing.php b/plugins/libkolab/lib/kolab_dav_sharing.php new file mode 100644 --- /dev/null +++ b/plugins/libkolab/lib/kolab_dav_sharing.php @@ -0,0 +1,473 @@ + + * + * 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 sharing management functionality + */ +class kolab_dav_sharing +{ + public const PRIVILEGE_READ = 'read'; + public const PRIVILEGE_WRITE = 'write'; + + /** @var ?kolab_storage_dav_folder $folder Current folder */ + private static $folder; + + /** @var array Special principals */ + private static $specials = []; + + + /** + * Handler for actions from the Sharing 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 nor can share + if (strpos($myrights, 'a') === false && strpos($myrights, '1') === false) { + 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 sharees 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 sharing 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, + ]; + + 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 sharing 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}")); + } + + $ul = ''; + + if (count($fields) == 1) { + $ul = html::tag('li', null, $fields['user']); + } else { + // Create list with radio buttons + 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 sharees 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_sharees(); + + // 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, + ]; + + // 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 => $sharee) { + if (!in_array($sharee['access'], [kolab_dav_client::SHARING_READ, kolab_dav_client::SHARING_READ_WRITE])) { + continue; + } + + $access = $sharee['access']; + $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 = []; + foreach ($cols as $right) { + $enabled = ( + $right == self::PRIVILEGE_READ + && ($access == kolab_dav_client::SHARING_READ || $access == kolab_dav_client::SHARING_READ_WRITE) + ) || ( + $right == self::PRIVILEGE_WRITE + && $access == kolab_dav_client::SHARING_READ_WRITE + ); + $class = $enabled ? 'enabled' : 'disabled'; + $table->add('acl' . $right . ' ' . $class, ''); + + if ($enabled) { + $rights[] = $right; + } + } + + $js_table[$userid] = $access; + } + + $rcmail->output->set_env('acl', $js_table); + $rcmail->output->set_env('acl_specials', self::$specials); + + return $table->show(); + } + + /** + * Handler for sharee 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; + } + + $sharees = $folder->get_sharees(); + $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) { + continue; + } + + if (in_array(self::PRIVILEGE_WRITE, $acl)) { + $access = kolab_dav_client::SHARING_READ_WRITE; + } elseif (in_array(self::PRIVILEGE_READ, $acl)) { + $access = kolab_dav_client::SHARING_READ; + } else { + $access = kolab_dav_client::SHARING_NO_ACCESS; + } + + if (isset($sharees[$user])) { + $sharees[$user]['access'] = $access; + } else { + $sharees[$user] = ['access' => $access]; + } + + $updates[] = [ + 'id' => rcube_utils::html_identifier($user), + 'username' => $user, + 'display' => $username, + 'acl' => $acl, + 'old' => $oldid, + ]; + } + + if (count($updates) > 0 && $folder->set_sharees($sharees)) { + 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 sharee 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; + } + + $sharees = $folder->get_sharees(); + + foreach ($users as $user) { + $user = trim($user); + $sharees[$user]['access'] = kolab_dav_client::SHARING_NO_ACCESS; + } + + if ($folder->set_sharees($sharees)) { + 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'); + } + } + + /** + * 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_storage_dav.php b/plugins/libkolab/lib/kolab_storage_dav.php --- a/plugins/libkolab/lib/kolab_storage_dav.php +++ b/plugins/libkolab/lib/kolab_storage_dav.php @@ -247,8 +247,10 @@ // sanity checks if (!isset($prop['name']) || !is_string($prop['name']) || !strlen($prop['name'])) { - self::$last_error = 'cannotbeempty'; - return false; + if (empty($prop['id'])) { + self::$last_error = 'cannotbeempty'; + return false; + } } elseif (strlen($prop['name']) > 256) { self::$last_error = 'nametoolong'; return false; @@ -269,9 +271,9 @@ $rcube = rcube::get_instance(); $uid = rtrim(chunk_split(md5($prop['name'] . $rcube->get_user_name() . uniqid('-', true)), 12, '-'), '-'); $type = $this->get_dav_type($prop['type']); - $home = $this->dav->discover($type); + $home = $this->dav->getHome($type); - if ($home === false) { + if ($home === null) { return false; } @@ -560,4 +562,114 @@ return $types[$type]; } + + /** + * Accept a share invitation. + * + * @param string $type Folder type (contact, event, task) + * @param string $location Invitation object location + * + * @return kolab_storage_dav_folder|false A new folder object, False on error + */ + public function accept_share_invitation($type, $location) + { + // Note: The 'create-in' property is not supported by Cyrus, and even then we + // can't specify the new folder location. The new folder will be created + // at implementation-specific location. To find that location we'll compare list of folders + // before and after accepting the invitation. + + $old_folders = $this->dav->listFolders($this->get_dav_type($type)); + + $result = $this->dav->inviteReply($location); + + if (!$result) { + return false; + } + + $new_folders = $this->dav->listFolders($this->get_dav_type($type)); + + if (is_array($old_folders) && is_array($new_folders)) { + foreach ($new_folders as $newfolder) { + foreach ($old_folders as $oldfolder) { + if ($oldfolder['href'] === $newfolder['href']) { + continue 2; + } + } + + return new kolab_storage_dav_folder($this->dav, $newfolder, $type); + } + } + + return false; + } + + /** + * Get a list of share invitations + * + * @param string $type Folder type (contact, event, task) + * @param string $filter Search string + * + * @return array List of kolab_storage_dav_folder objects + */ + public function get_share_invitations($type, $filter) + { + $result = []; + + if (strlen($filter) === 0) { + return $result; + } + + $notifications = $this->dav->listNotifications([kolab_dav_client::NOTIFICATION_SHARE_INVITE]); + + if (is_array($notifications)) { + foreach ($notifications as $idx => $note) { + // Sanity checks + if (empty($note['resource-uri'])) { + continue; + } + + // Skip accepted invitations + if (!empty($note['status']) && $note['status'] == 'accepted') { + continue; + } + + // Filter by folder type + if (($type == 'contact' && !empty($note['types'])) + || ($type == 'event' && !in_array('VEVENT', $note['types'])) + || ($type == 'task' && !in_array('VTODO', $note['types'])) + ) { + continue; + } + + $owner = explode('/', trim($note['organizer'] ?? '', '/')); + $owner = end($owner); + $path = explode('/', trim($note['resource-uri'], '/')); + $name = end($path); + $displayname = $note['displayname'] ?? ''; + + // Filter by the input text + if (stripos($owner . ' ' . $name . ' ' . $displayname, $filter) === false) { + continue; + } + + $attrs = [ + 'owner' => $owner, + 'name' => $displayname ?: $name, + 'href' => $note['resource-uri'], + 'invitation' => $note['href'], + 'alarms' => false, + 'myrights' => [$note['access'] ?? 'read'], + 'resource_type' => ['shared', 'collection'], + ]; + + $result[] = new kolab_storage_dav_folder($this->dav, $attrs, $type); + } + + usort($result, function ($a, $b) { + return strcoll($a->href, $b->href); + }); + } + + return $result; + } } diff --git a/plugins/libkolab/lib/kolab_storage_dav_folder.php b/plugins/libkolab/lib/kolab_storage_dav_folder.php --- a/plugins/libkolab/lib/kolab_storage_dav_folder.php +++ b/plugins/libkolab/lib/kolab_storage_dav_folder.php @@ -51,7 +51,45 @@ $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; } /** @@ -63,16 +101,20 @@ */ 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 global shared folders? - // TODO: Support shared folders + if (isset($this->attributes['owner'])) { + // Assume username/email is the last part of a principal href + $path = explode('/', trim($this->attributes['owner'], '/')); + $this->owner = end($path); + } else { + $rcube = rcube::get_instance(); + $this->owner = $rcube->get_user_name(); + } return $this->owner; } @@ -99,7 +141,15 @@ */ public function get_namespace() { - // TODO: Support shared folders + // TODO: Support for global shared folders? + + $owner = $this->get_owner(); + $user = rcube::get_instance()->get_user_name(); + + if ($owner != $user) { + return 'other'; + } + return 'personal'; } @@ -110,7 +160,13 @@ */ public function get_name() { - return kolab_storage_dav::object_name($this->attributes['name']); + $name = $this->attributes['name']; + + if ($this->get_namespace() == 'other') { + $name = $this->get_owner() . ': ' . $name; + } + + return $name; } /** @@ -123,9 +179,18 @@ 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; } /** @@ -187,14 +252,98 @@ } /** - * 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(); + } + + $rights = $this->attributes['myrights'] ?? []; + $types = $this->attributes['resource_type'] ?? []; + $acl = 'lr'; + + // Convert DAV privileges to IMAP-like ACL format + foreach ($rights as $right) { + switch ($right) { + case 'all': + $acl .= 'lrswikxteav'; + break; + case 'admin': + $acl .= 'a'; + break; + case 'read': + case 'read-free-busy': + case 'read-current-user-privilege-set': + $acl .= 'lr'; + break; + case 'read-write': + $acl .= 'lrwni'; + break; + case 'write': + //case 'write-properties': + //case 'write-content': + $acl .= 'wni'; + break; + case 'bind': + $acl .= 'pk'; + break; + case 'unbind': + $acl .= 'xte'; + break; + case 'share': + $acl .= '1'; + break; + } + } + + // Shared collection can be deleted even if myrights says otherwise + if (in_array('shared', $types)) { + $acl .= 'x'; + } + + if (!empty($this->attributes['share-access'])) { + if ($this->attributes['share-access'] === kolab_dav_client::SHARING_OWNER) { + $acl .= 'a'; + } elseif ($this->attributes['share-access'] === kolab_dav_client::SHARING_READ + || $this->attributes['share-access'] === kolab_dav_client::SHARING_READ_WRITE + ) { + $acl .= 'x'; + } + } + + // Note: We return a string for compat. with the parent class + return implode('', array_unique(str_split($acl))); + } + + /** + * Get the invitations' sharees + * + * @return array Sharees list + */ + public function get_sharees() + { + if (!isset($this->attributes['invite'])) { + $this->get_folder_info(); + } + + $sharees = []; + foreach ($this->attributes['invite'] ?? [] as $principal => $sharee) { + // Convert a principal href into a user identifier + if (stripos($principal, 'mailto:') === 0) { + $principal = substr($principal, 7); + } elseif (strpos($principal, '/') !== false) { + $tokens = explode('/', trim($principal, '/')); + $principal = end($tokens); + } + + $sharees[$principal] = $sharee; + } + + return $sharees; } /** @@ -770,6 +919,77 @@ 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); + } + + /** + * Set folder sharing invites. + * + * @param array $sharees Sharees list + * + * @return bool True if successful, false on error + */ + public function set_sharees($sharees) + { + 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; + } + + $request = []; + + foreach ($sharees as $principal => $sharee) { + // Convert a user identifier into a principal href or mailto: href + if (strpos($principal, '@')) { + $principal = 'mailto:' . $principal; + } else { + $principal = $path . $principal; + } + + $request[$principal] = $sharee; + } + + return $this->dav->shareResource($this->href, $request); + } + /** * Return folder name as string representation of this object * diff --git a/plugins/libkolab/lib/kolab_utils.php b/plugins/libkolab/lib/kolab_utils.php --- a/plugins/libkolab/lib/kolab_utils.php +++ b/plugins/libkolab/lib/kolab_utils.php @@ -27,14 +27,17 @@ { public static function folder_form($form, $folder, $domain, $hidden_fields = [], $no_acl = false) { - $rcmail = rcube::get_instance(); + $rcmail = rcmail::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), - ]; + if (!$no_acl && $folder) { + if (($folder instanceof kolab_storage_dav_folder) || (is_string($folder) && strlen($folder))) { + $sharing_content = self::folder_acl_form($folder); + $form['sharing'] = [ + 'name' => rcube::Q($rcmail->gettext('libkolab.tabsharing')), + 'content' => $sharing_content, + ]; + } } $form_html = ''; @@ -71,10 +74,18 @@ /** * 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) { - $rcmail = rcube::get_instance(); + if ($folder instanceof kolab_storage_dav_folder) { + return self::folder_dav_acl_form($folder); + } + + $rcmail = rcmail::get_instance(); $storage = $rcmail->get_storage(); $options = $storage->folder_info($folder); @@ -93,4 +104,29 @@ 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) + { + $rcmail = rcmail::get_instance(); + $sharing = $rcmail->config->get('kolab_dav_sharing'); + + if (!$sharing) { + return null; + } + + $class = 'kolab_dav_' . $sharing; + + if ($form = $class::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 --- a/plugins/libkolab/libkolab.js +++ b/plugins/libkolab/libkolab.js @@ -25,7 +25,7 @@ * for the JavaScript code in this file. */ -var libkolab_audittrail = {}, libkolab = {}; +var libkolab_audittrail = {}, libkolab = {}, libkolab_invitations = {}; libkolab_audittrail.quote_html = function(str) { @@ -306,6 +306,8 @@ // render the results for folderlist search function render_search_results(results) { + libkolab_invitations = {}; + if (results.length) { // create treelist widget to present the search results if (!search_results_widget) { @@ -340,17 +342,28 @@ e.stopPropagation(); e.bubbles = false; + // Share invitation + if (search_results[id] && 'share_invitation' in search_results[id] && search_results[id].share_invitation) { + libkolab_invitations[id] = { li: li, list: me }; + rcmail.http_post( + 'plugin.share-invitation', + { id: id, invitation: search_results[id].share_invitation, status: 'accepted' }, + rcmail.set_busy(true, 'libkolab.invitation-accepting') + ); + return false; + } + // activate + subscribe if ($(e.target).hasClass('subscribed')) { search_results[id].subscribed = true; $(e.target).attr('aria-checked', 'true'); li.children().first() .toggleClass('subscribed') - .find('input[type=checkbox]').get(0).checked = true; + .find('input[type=checkbox]').prop('checked', true); if (has_children && search_results[id].group == 'other user') { li.find('ul li > div').addClass('subscribed') - .find('a.subscribed').attr('aria-checked', 'true');; + .find('a.subscribed').attr('aria-checked', 'true'); } } else if (!this.checked) { @@ -416,6 +429,15 @@ item.find('a.subscribed, span.subscribed').hide(); } + // disable click on shared invitations (it will be fixed back in add_result2list) + if ('share_invitation' in prop && prop.share_invitation) { + var elem = item.find('a.listname').first(); + if (elem.length) { + elem.data('onclick', elem.attr('onclick')) + .attr('onclick', 'return false'); + } + } + prop.li = item.parent().get(0); me.triggerEvent('add-item', prop); } @@ -427,12 +449,15 @@ // helper method to (recursively) add a search result item to the main list widget function add_result2list(id, li, active) { - var node = search_results_widget.get_node(id), + var cl, data, + childs = [], + node = search_results_widget.get_node(id), prop = search_results[id], + classes = prop.group || '', parent_id = prop.parent || null, has_children = node.children && node.children.length, dom_node = has_children ? li.children().first().clone(true, true) : li.children().first(), - childs = []; + name_node = dom_node.find('a.listname'); // find parent node and insert at the right place if (parent_id && me.get_node(parent_id)) { @@ -447,6 +472,41 @@ dom_node.children('span,a').first().html(Q(prop.name)); } + if (name_node && (data = name_node.data('onclick'))) { + name_node.attr('onclick', data).removeData('onclick'); + } + + // Handle id change (switch IDs for various elements/properties of the list row) + if (prop.id && prop.id != id) { + if (cl = dom_node.attr('class')) { + dom_node.attr('class', cl.replace(id, prop.id)); + } else { + // for addressbook copy 'class' attribute + if (cl = dom_node.parent().attr('class')) { + classes += ' ' + cl; + } + // and remove the checkbox + dom_node.children(':not(a)').hide(); + } + dom_node.children('a').each(function() { + if (this.id && this.id.includes(id)) { + this.id = this.id.replace(id, prop.id); + } + }); + dom_node.find('input[type=checkbox]').each(function() { + if (this.value == id) { + this.value = prop.id; + } + }); + if (data) { + name_node.attr({ + onclick: data.replace(id, prop.id), + href: name_node.attr('href').replace(id, prop.id), + rel: name_node.attr('rel').replace(id, prop.id), + }); + } + } + // replace virtual node with a real one if (me.get_node(id)) { $(me.get_item(id, true)).children().first() @@ -465,8 +525,8 @@ // move this result item to the main list widget me.insert({ - id: id, - classes: [ prop.group || '' ], + id: prop.id || id, + classes: [ classes ], virtual: prop.virtual, html: dom_node, level: node.level, @@ -477,7 +537,7 @@ delete prop.html; prop.active = active; - me.triggerEvent('insert-item', { id: id, data: prop, item: li }); + me.triggerEvent('insert-item', { id: prop.id || id, data: prop, item: li }); // register childs, too if (childs.length) { @@ -508,6 +568,26 @@ } } + this.accept_invitation = function (id, prop) { + var li = libkolab_invitations[id].li; + + search_results[id] = prop; + + if (prop.active) { + li.find('input[type=checkbox]').prop('disabled', false).prop('checked', true); + } + + if (prop.listname) { + li.find('a.calname').text(prop.listname); + } + + li.find('a.quickview').show(); + + add_result2list(id, li, prop.active) + + delete libkolab_invitations[id]; + } + // do some magic when search is performed on the widget this.addEventListener('search', function(search) { // hide search results @@ -623,6 +703,312 @@ kolab_folderlist.prototype = rcube_treelist_widget.prototype; } +// =============== ACL UI =============== + +// Display new-entry form +rcube_webmail.prototype.acl_create = function () { + this.acl_init_form(); +}; + +// Display ACL edit form +rcube_webmail.prototype.acl_edit = function () { + var id = this.acl_list.get_single_selection(); + if (id) { + this.acl_init_form(id); + } +}; + +// ACL entry delete +rcube_webmail.prototype.acl_delete = function () { + var users = this.acl_get_usernames(); + + if (users && users.length) { + this.confirm_dialog(this.get_label('libkolab.deleteconfirm'), 'delete', function () { + rcmail.http_post('plugin.davacl', { + _act: 'delete', + _user: users.join(','), + _target: rcmail.env.acl_target, + }, rcmail.set_busy(true, 'libkolab.deleting')); + }); + } +}; + +// Save ACL data +rcube_webmail.prototype.acl_save = function () { + var data, type, rights = [], user = $('#acluser', this.acl_form).val(); + + $('#rights :checkbox', this.acl_form).map(function () { + if (this.checked) { + rights.push(this.value); + } + }); + + if (type = $('input:checked[name=usertype]', this.acl_form).val()) { + if (type != 'user') { + user = type; + } + } + + if (!user) { + this.alert_dialog(this.get_label('libkolab.nouser')); + return; + } + + if (!rights.length) { + this.alert_dialog(this.get_label('libkolab.norights')); + return; + } + + data = { + _act: 'save', + _user: user, + _acl: rights.join(','), + _target: this.env.acl_target, + }; + + if (this.acl_id) { + data._old = this.acl_id; + } + + this.http_post('plugin.davacl', data, this.set_busy(true, 'libkolab.saving')); +}; + +// Cancel/Hide the form +rcube_webmail.prototype.acl_cancel = function () { + this.ksearch_blur(); + this.acl_popup.dialog('close'); +}; + +// Update data after save (and hide form) +rcube_webmail.prototype.acl_update = function (o) { + // delete old row + if (o.old) { + this.acl_remove_row(o.old); + } + // make sure the same ID doesn't exist + else if (this.env.acl[o.id]) { + this.acl_remove_row(o.id); + } + + // add new row + this.acl_add_row(o, true); + // hide autocomplete popup + this.ksearch_blur(); + // hide form + this.acl_popup.dialog('close'); +}; + +// ACL table initialization +rcube_webmail.prototype.acl_list_init = function () { + this.acl_list = new rcube_list_widget(this.gui_objects.acltable, + { multiselect: true, draggable: false, keyboard: true }); + + this.acl_list + .addEventListener('select', function (list) { + rcmail.enable_command('acl-delete', list.get_selection().length > 0); + rcmail.enable_command('acl-edit', list.get_selection().length == 1); + list.focus(); + }) + .addEventListener('dblclick', function (list) { + rcmail.acl_edit(); + }) + .addEventListener('keypress', function (list) { + if (list.key_pressed == list.ENTER_KEY) { + rcmail.command('acl-edit'); + } else if (list.key_pressed == list.DELETE_KEY || list.key_pressed == list.BACKSPACE_KEY) { + if (!rcmail.acl_form || !rcmail.acl_form.is(':visible')) { + rcmail.command('acl-delete'); + } + } + }) + .init(); +}; + +// Returns names of users in selected rows +rcube_webmail.prototype.acl_get_usernames = function () { + var users = [], n, len, id, row, + list = this.acl_list, + selection = list.get_selection(); + + for (n = 0, len = selection.length; n < len; n++) { + if ((row = list.rows[selection[n]]) && (id = $(row.obj).data('userid'))) { + users.push(id); + } + } + + return users; +}; + +// Removes ACL table row +rcube_webmail.prototype.acl_remove_row = function (id) { + var list = this.acl_list; + + list.remove_row(id); + list.clear_selection(); + + // we don't need it anymore (remove id conflict) + $('#rcmrow' + id).remove(); + this.env.acl[id] = null; + + this.enable_command('acl-delete', list.get_selection().length > 0); + this.enable_command('acl-edit', list.get_selection().length == 1); +}; + +// Adds ACL table row +rcube_webmail.prototype.acl_add_row = function (o, sel) { + var n, len, ids = [], spec = [], id = o.id, list = this.acl_list, + table = this.gui_objects.acltable, + row = $('thead > tr', table).clone(); + + // Update new row + $('th', row).map(function () { + var td = $(''), + title = $(this).attr('title'), + cl = this.className.replace(/^acl/, ''); + + if (title) { + td.attr('title', title); + } + + if (cl == 'user') { + td.addClass(cl).attr('title', o.title).append($('').text(o.display)); + } else { + cl = $.inArray(cl, o.acl) >= 0 ? ' enabled' : ' disabled'; + td.addClass(this.className + cl).html(''); + } + + $(this).replaceWith(td); + }); + + row = row.attr({ id: 'rcmrow' + id, 'data-userid': o.username }).get(0); + + this.env.acl[id] = o.acl; + + // sorting... (create an array of user identifiers, then sort it) + for (n in this.env.acl) { + if (this.env.acl[n]) { + if (this.env.acl_specials.length && $.inArray(n, this.env.acl_specials) >= 0) { + spec.push(n); + } else { + ids.push(n); + } + } + } + + ids.sort(); + // specials on the top + ids = spec.concat(ids); + + // find current id + for (n = 0, len = ids.length; n < len; n++) { + if (ids[n] == id) { + break; + } + } + + // add row + if (!$('tbody > tr', table).length) { + list.insert_row(row); + } else { + if (n) { + $('#rcmrow' + ids[n - 1]).after(row); + } else { + $('#rcmrow' + ids[n + 1]).before(row); + } + list.init_row(row); + list.rowcount++; + } + + if (sel) { + list.select_row(o.id); + } +}; + +// Initializes and shows ACL create/edit form +rcube_webmail.prototype.acl_init_form = function (id) { + var row, td, val = '', type = 'user', + ul = $('#rights'), + checkboxes = $(':checkbox', ul), + name_input = $('#acluser'), + type_list = $('#usertype'); + + if (!this.acl_form) { + var fn = function () { + $(this).closest('li').find('[type=radio]').prop('checked', true); + }; + name_input.click(fn).keypress(fn); + + checkboxes.on('input', function (event) { + if (event.target.checked) { + checkboxes.each(function (i, box) { + if (box == event.target) { + return false; + } + + box.checked = true; + }); + } + }); + } + + this.acl_form = $('#aclform'); + + if (id && (row = this.acl_list.rows[id])) { + row = row.obj; + checkboxes.each(function () { + td = $('td.' + this.id, row); + this.checked = td.length && td.hasClass('enabled'); + }); + + if (!this.env.acl_specials.length || $.inArray(id, this.env.acl_specials) < 0) { + val = $(row).data('userid'); + } else { + type = id; + } + } else { + // mark read rights by default + checkboxes.prop('checked', false).filter('#aclread').prop('checked', true).trigger('input'); + } + + name_input.val(val); + $('input[type=radio][value=' + type + ']').prop('checked', true); + + this.acl_id = id; + + var buttons = {}, me = this, body = document.body; + + buttons[this.get_label('save')] = function (e) { + me.command('acl-save'); + }; + buttons[this.get_label('cancel')] = function (e) { + me.command('acl-cancel'); + }; + + // display it as popup + this.acl_popup = this.show_popup_dialog( + this.acl_form.show(), + id ? this.get_label('libkolab.editperms') : this.get_label('libkolab.newuser'), + buttons, + { + button_classes: ['mainaction submit', 'cancel'], + modal: true, + closeOnEscape: true, + close: function (e, ui) { + (me.is_framed() ? parent.rcmail : me).ksearch_hide(); + me.acl_form.appendTo(body).hide(); + $(this).remove(); + window.focus(); // focus iframe + }, + } + ); + + if (type == 'user') { + name_input.focus(); + } else { + $('input:checked', type_list).focus(); + } +}; + window.rcmail && rcmail.addEventListener('init', function(e) { var loading_lock; @@ -683,4 +1069,37 @@ }, true); } } + + if (rcmail.gui_objects.acltable) { + rcmail.acl_list_init(); + + rcmail.enable_command('acl-create', 'acl-save', 'acl-cancel', true); + rcmail.enable_command('acl-delete', 'acl-edit', false); + + // enable autocomplete on user input + if (rcmail.env.kolab_autocomplete) { + var inst = rcmail.is_framed() ? parent.rcmail : rcmail; + inst.init_address_input_events($('#acluser'), { action: 'settings/plugin.acl-autocomplete' }); + + // pass config settings and localized texts to autocomplete context + inst.set_env({ autocomplete_max: rcmail.env.autocomplete_max, autocomplete_min_length: rcmail.env.autocomplete_min_length }); + inst.add_label('autocompletechars', rcmail.labels.autocompletechars); + inst.add_label('autocompletemore', rcmail.labels.autocompletemore); + + // fix inserted value + inst.addEventListener('autocomplete_insert', function (e) { + if (e.field.id != 'acluser') { + return; + } + + e.field.value = e.insert.replace(/[ ,;]+$/, ''); + }); + } + } + + rcmail.addEventListener('plugin.share-invitation', function(data) { + if (data.id in libkolab_invitations) { + libkolab_invitations[data.id].list.accept_invitation(data.id, data.source); + } + }); }); diff --git a/plugins/libkolab/libkolab.php b/plugins/libkolab/libkolab.php --- a/plugins/libkolab/libkolab.php +++ b/plugins/libkolab/libkolab.php @@ -35,6 +35,8 @@ */ public function init() { + $rcmail = rcube::get_instance(); + // load local config $this->load_config(); $this->require_plugin('libcalendaring'); @@ -50,7 +52,13 @@ // For Chwala $this->add_hook('folder_mod', ['kolab_storage', 'folder_mod']); - $rcmail = rcube::get_instance(); + // For DAV ACL + if ($sharing = $rcmail->config->get('kolab_dav_sharing')) { + $class = 'kolab_dav_' . $sharing; + $this->register_action('plugin.davacl', "$class::actions"); + $this->register_action('plugin.davacl-autocomplete', "$class::autocomplete"); + } + try { kolab_format::$timezone = new DateTimeZone($rcmail->config->get('timezone', 'GMT')); } catch (Exception $e) { diff --git a/plugins/libkolab/localization/en_US.inc b/plugins/libkolab/localization/en_US.inc --- a/plugins/libkolab/localization/en_US.inc +++ b/plugins/libkolab/localization/en_US.inc @@ -30,3 +30,35 @@ $labels['objectchangelognotavailable'] = 'History is not available for this object.'; $labels['tabsharing'] = 'Sharing'; $labels['aclnorights'] = 'You do not have administrator rights for this folder.'; + +// Folder ACL (Sharing tab) +$labels['add'] = 'Add'; +$labels['all'] = 'Anyone'; +$labels['authenticated'] = 'Authenticated users'; +$labels['identifier'] = 'Identifier'; +$labels['newuser'] = 'Add entry'; +$labels['actions'] = 'Access right actions...'; +$labels['username'] = 'User:'; +$labels['myrights'] = 'Access Rights'; +$labels['editperms'] = 'Edit permissions'; +$labels['aclall'] = 'All'; +$labels['aclread'] = 'Read'; +$labels['aclwrite'] = 'Write'; +$labels['aclread-free-busy'] = 'Free-Busy'; +$labels['acllongall'] = 'All (administration)'; + +$labels['ariasummaryacltable'] = 'List of access rights'; +$labels['arialabelaclactions'] = 'List actions'; +$labels['arialabelaclform'] = 'Access rights form'; + +$messages['deleting'] = 'Deleting access rights...'; +$messages['saving'] = 'Saving access rights...'; +$messages['updatesuccess'] = 'Successfully changed access rights'; +$messages['deletesuccess'] = 'Successfully deleted access rights'; +$messages['createsuccess'] = 'Successfully added access rights'; +$messages['updateerror'] = 'Unable to update access rights'; +$messages['deleteerror'] = 'Unable to delete access rights'; +$messages['createerror'] = 'Unable to add access rights'; +$messages['deleteconfirm'] = 'Are you sure, you want to remove access rights of selected user(s)?'; +$messages['norights'] = 'No rights has been specified!'; +$messages['nouser'] = 'No username has been specified!'; diff --git a/plugins/libkolab/skins/elastic/templates/acl.html b/plugins/libkolab/skins/elastic/templates/acl.html new file mode 100644 --- /dev/null +++ b/plugins/libkolab/skins/elastic/templates/acl.html @@ -0,0 +1,33 @@ +
+
+

+ +
+ +
+ + + +
+

+
+ + +
+
+ + +
+
diff --git a/plugins/libkolab/skins/larry/images/enabled.png b/plugins/libkolab/skins/larry/images/enabled.png new file mode 100644 index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 GIT binary patch literal 0 Hc$@ .user { + width: 30%; + border-left: none; +} + +#acltable.advanced thead tr > .user { + width: 25%; +} + +#acltable tbody td.user { + text-align: left; +} + +#acltable tbody td.partial { + background-image: url(images/partial.png); + background-position: center; + background-repeat: no-repeat; +} + +#acltable tbody td.enabled { + background-image: url(images/enabled.png); + background-position: center; + background-repeat: no-repeat; +} + +#acltable tbody tr.selected td.partial { + background-color: #019bc6; + background-image: url(images/partial.png), -moz-linear-gradient(top, #019bc6 0%, #017cb4 100%); + background-image: url(images/partial.png), linear-gradient(top, #019bc6 0%, #017cb4 100%); +} + +#acltable tbody tr.selected td.enabled { + background-color: #019bc6; + background-image: url(images/enabled.png), linear-gradient(top, #019bc6 0%, #017cb4 100%); +} + +#aclform { + display: none; +} + +#aclform div { + padding: 0; + text-align: center; + clear: both; +} + +#aclform ul { + list-style: none; + margin: 0.2em; + padding: 0; +} + +#aclform ul li label { + margin-left: 0.5em; +} + +ul.toolbarmenu li span.delete { + background-position: 0 -1509px; +} diff --git a/plugins/libkolab/skins/larry/templates/acl.html b/plugins/libkolab/skins/larry/templates/acl.html new file mode 100644 --- /dev/null +++ b/plugins/libkolab/skins/larry/templates/acl.html @@ -0,0 +1,27 @@ +
+
+

+ +
+ +
+ + + +
+

+
+ +
+
+ +
+
diff --git a/plugins/libkolab/tests/LibkolabTest.php b/plugins/libkolab/tests/LibkolabTest.php new file mode 100644 --- /dev/null +++ b/plugins/libkolab/tests/LibkolabTest.php @@ -0,0 +1,76 @@ + + * + * Copyright (C) Apheleia IT + * + * 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 LibkolabTest extends PHPUnit\Framework\TestCase +{ + public function test_html_diff_plain() + { + // Empty input + $text1 = ''; + $text2 = ''; + $diff = libkolab::html_diff($text1, $text2); + $this->assertSame('', $diff); + + $text1 = 'test plain text'; + $text2 = ''; + $diff = libkolab::html_diff($text1, $text2); + $this->assertSame('test plain text', $diff); + + $text1 = ''; + $text2 = 'test plain text'; + $diff = libkolab::html_diff($text1, $text2); + $this->assertSame('test plain text', $diff); + + $text1 = 'test plain text'; + $text2 = 'test plain text'; + $diff = libkolab::html_diff($text1, $text2); + $this->assertSame('test plain text', $diff); + + // TODO: more cases e.g. multiline + } + + public function test_html_diff_html() + { + $text1 = '

test plain text

'; + $text2 = ''; + $diff = libkolab::html_diff($text1, $text2); + $this->assertSame('

test plain text

', $diff); + + $text1 = ''; + $text2 = '

test plain text

'; + $diff = libkolab::html_diff($text1, $text2); + $this->assertSame('

test plain text

', $diff); + + $text1 = '

test plain text

'; + $text2 = 'test plain text'; + $diff = libkolab::html_diff($text1, $text2); + $this->assertSame('

test plain text

', $diff); + + $text1 = '

test plain text

'; + $text2 = '

test

'; + $diff = libkolab::html_diff($text1, $text2); + $this->assertSame('

test plain text

', $diff); + + // TODO: more cases e.g. multiline + } +} diff --git a/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php b/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php --- a/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php +++ b/plugins/tasklist/drivers/caldav/tasklist_caldav_driver.php @@ -96,16 +96,15 @@ $alarms = !isset($folder->attributes['alarms']) || $folder->attributes['alarms']; } else { $alarms = false; - $rights = 'lr'; $editable = false; - if ($myrights = $folder->get_myrights()) { - $rights = $myrights; - if (strpos($rights, 't') !== false || strpos($rights, 'd') !== false) { - $editable = strpos($rights, 'i') !== false; - } + $rights = $folder->get_myrights(); + $editable = strpos($rights, 'i') !== false; + $norename = strpos($rights, 'x') === false; + + if (!empty($folder->attributes['invitation'])) { + $invitation = $folder->attributes['invitation']; + $active = true; } - $info = $folder->get_folder_info(); - $norename = !$editable || $info['norename'] || $info['protected']; } $list_id = $folder->id; @@ -113,14 +112,14 @@ return [ 'id' => $list_id, 'name' => $folder->get_name(), - 'listname' => $folder->get_foldername(), + 'listname' => $folder->get_name(), 'editname' => $folder->get_foldername(), 'color' => $folder->get_color('0000CC'), 'showalarms' => $prefs[$list_id]['showalarms'] ?? $alarms, 'editable' => $editable, 'rights' => $rights, 'norename' => $norename, - 'active' => !isset($prefs[$list_id]['active']) || !empty($prefs[$list_id]['active']), + 'active' => $active ?? (!isset($prefs[$list_id]['active']) || !empty($prefs[$list_id]['active'])), 'owner' => $folder->get_owner(), 'parentfolder' => $folder->get_parent(), 'default' => $folder->default, @@ -133,6 +132,7 @@ 'class' => trim($folder->get_namespace() . ($folder->default ? ' default' : '')), 'caldavuid' => '', // $folder->get_uid(), 'history' => !empty($this->bonnie_api), + 'share_invitation' => $invitation ?? null, ]; } @@ -416,49 +416,32 @@ */ public function search_lists($query, $source) { - /* - $this->search_more_results = false; - $this->lists = $this->folders = array(); - - // find unsubscribed IMAP folders that have "event" type - if ($source == 'folders') { - foreach ((array)kolab_storage::search_folders('task', $query, array('other')) as $folder) { - $this->folders[$folder->id] = $folder; - $this->lists[$folder->id] = $this->folder_props($folder, array()); - } - } - // search other user's namespace via LDAP - else if ($source == 'users') { - $limit = $this->rc->config->get('autocomplete_max', 15) * 2; // we have slightly more space, so display twice the number - foreach (kolab_storage::search_users($query, 0, array(), $limit * 10) as $user) { - $folders = array(); - // search for tasks folders shared by this user - foreach (kolab_storage::list_user_folders($user, 'task', false) as $foldername) { - $folders[] = new kolab_storage_folder($foldername, 'task'); - } + $this->search_more_results = false; + $this->lists = $this->folders = []; - if (count($folders)) { - $userfolder = new kolab_storage_folder_user($user['kolabtargetfolder'], '', $user); - $this->folders[$userfolder->id] = $userfolder; - $this->lists[$userfolder->id] = $this->folder_props($userfolder, array()); + // find unsubscribed IMAP folders that have "event" type + if ($source == 'folders') { + foreach ((array) $this->storage->search_folders('task', $query, ['other']) as $folder) { + $this->folders[$folder->id] = $folder; + $this->lists[$folder->id] = $this->folder_props($folder, []); + } + } + // find other user's calendars (invitations) + elseif ($source == 'users') { + // we have slightly more space, so display twice the number + $limit = $this->rc->config->get('autocomplete_max', 15) * 2; - foreach ($folders as $folder) { - $this->folders[$folder->id] = $folder; - $this->lists[$folder->id] = $this->folder_props($folder, array()); - $count++; - } - } + foreach ($this->storage->get_share_invitations('task', $query) as $invitation) { + $this->folders[$invitation->id] = $invitation; + $this->lists[$invitation->id] = $this->folder_props($invitation, []); - if ($count >= $limit) { - $this->search_more_results = true; - break; - } - } + if (count($this->lists) > $limit) { + $this->search_more_results = true; } + } + } - return $this->get_lists(); - */ - return []; + return $this->get_lists(); } /** @@ -1434,6 +1417,31 @@ return false; } + /** + * Accept an invitation to a shared folder + * + * @param string $href Invitation location href + * + * @return array|false + */ + public function accept_share_invitation($href) + { + $folder = $this->storage->accept_share_invitation('task', $href); + + if ($folder === false) { + return false; + } + + // Activate the folder + $prefs['kolab_tasklists'] = $this->rc->config->get('kolab_tasklists', []); + $prefs['kolab_tasklists'][$folder->id]['active'] = true; + + $tasklist = $this->folder_props($folder, $prefs['kolab_tasklists']); + + $this->rc->user->save_prefs($prefs); + + return $tasklist; + } /** * Get attachment properties @@ -1568,7 +1576,8 @@ $this->_read_lists(); if (!empty($list['id']) && ($list = $this->lists[$list['id']])) { - $folder_name = $this->get_folder($list['id'])->name; + $folder = $this->get_folder($list['id']); + $folder_name = $folder->name; } else { $folder_name = ''; } @@ -1591,6 +1600,6 @@ $form['properties']['fields'][$f] = $fieldprop[$f]; } - return kolab_utils::folder_form($form, $folder_name, 'tasklist', $hidden_fields, true); + return kolab_utils::folder_form($form, $folder ?? null, 'tasklist', $hidden_fields); } } diff --git a/plugins/tasklist/drivers/tasklist_driver.php b/plugins/tasklist/drivers/tasklist_driver.php --- a/plugins/tasklist/drivers/tasklist_driver.php +++ b/plugins/tasklist/drivers/tasklist_driver.php @@ -286,6 +286,18 @@ return false; } + /** + * Accept an invitation to a shared folder + * + * @param string $href Invitation location href + * + * @return array|false + */ + public function accept_share_invitation($href) + { + return false; + } + /** * Get attachment properties * diff --git a/plugins/tasklist/tasklist.js b/plugins/tasklist/tasklist.js --- a/plugins/tasklist/tasklist.js +++ b/plugins/tasklist/tasklist.js @@ -177,7 +177,7 @@ }); tasklists_widget.addEventListener('select', function(node) { var id = $(this).data('id'); - rcmail.enable_command('list-edit', me.has_permission(me.tasklists[node.id], 'wa')); + rcmail.enable_command('list-edit', me.has_permission(me.tasklists[node.id], 'xwa')); rcmail.enable_command('list-delete', me.has_permission(me.tasklists[node.id], 'xa')); rcmail.enable_command('list-import', me.has_permission(me.tasklists[node.id], 'i')); rcmail.enable_command('list-remove', me.tasklists[node.id] && me.tasklists[node.id].removable); diff --git a/plugins/tasklist/tasklist.php b/plugins/tasklist/tasklist.php --- a/plugins/tasklist/tasklist.php +++ b/plugins/tasklist/tasklist.php @@ -129,6 +129,8 @@ $this->register_action('itip-delegate', [$this, 'mail_itip_delegate']); $this->add_hook('refresh', [$this, 'refresh']); + $this->rc->plugins->register_action('plugin.share-invitation', $this->ID, [$this, 'share_invitation']); + $this->collapsed_tasks = array_filter(explode(',', $this->rc->config->get('tasklist_collapsed_tasks', ''))); } elseif ($args['task'] == 'mail') { if ($args['action'] == 'show' || $args['action'] == 'preview') { @@ -1461,6 +1463,18 @@ return (empty($task['organizer']) || in_array(strtolower($task['organizer']['email']), $emails)); } + /** + * Handle invitations to a shared folder + */ + public function share_invitation() + { + $id = rcube_utils::get_input_value('id', rcube_utils::INPUT_POST); + $invitation = rcube_utils::get_input_value('invitation', rcube_utils::INPUT_POST); + + if ($tasklist = $this->driver->accept_share_invitation($invitation)) { + $this->rc->output->command('plugin.share-invitation', ['id' => $id, 'source' => $tasklist]); + } + } /******* UI functions ********/ diff --git a/plugins/tasklist/tasklist_ui.php b/plugins/tasklist/tasklist_ui.php --- a/plugins/tasklist/tasklist_ui.php +++ b/plugins/tasklist/tasklist_ui.php @@ -273,7 +273,7 @@ if (!empty($prop['virtual'])) { $classes[] = 'virtual'; - } elseif (empty($prop['editable'])) { + } elseif (strpos($prop['rights'], 'i') === false && strpos($prop['rights'], 'w') === false) { $classes[] = 'readonly'; } if (!empty($prop['subscribed'])) { @@ -285,30 +285,50 @@ if (!$activeonly || !empty($prop['active'])) { $label_id = 'tl:' . $id; + $listname = !empty($prop['listname']) ? $prop['listname'] : $prop['name']; + $actions = ''; + $chbox = html::tag('input', [ 'type' => 'checkbox', 'name' => '_list[]', 'value' => $id, - 'checked' => !empty($prop['active']), + 'checked' => !empty($prop['active']) && empty($prop['share_invitation']), 'title' => $this->plugin->gettext('activate'), 'aria-labelledby' => $label_id, ]); - $actions = ''; if (!empty($prop['removable'])) { $actions .= html::a(['href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')], ' '); } - $actions .= html::a(['href' => '#', 'class' => 'quickview', 'title' => $this->plugin->gettext('focusview'), 'role' => 'checkbox', 'aria-checked' => 'false'], ' '); + + $actions .= html::a( + [ + 'href' => '#', + 'class' => 'quickview', + 'title' => $this->plugin->gettext('focusview'), + 'role' => 'checkbox', + 'aria-checked' => 'false', + 'style' => !empty($prop['share_invitation']) ? 'display:none' : null, + ], + ' ' + ); + if (isset($prop['subscribed'])) { - $actions .= html::a(['href' => '#', 'class' => 'subscribed', 'title' => $this->plugin->gettext('tasklistsubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'], ' '); + $actions .= html::a( + [ + 'href' => '#', + 'class' => 'subscribed', + 'title' => $this->plugin->gettext('tasklistsubscribe'), + 'role' => 'checkbox', + 'aria-checked' => $prop['subscribed'] ? 'true' : 'false', + ], + ' ' + ); } return html::div( implode(' ', $classes), - html::a( - ['class' => 'listname', 'title' => $title, 'href' => '#', 'id' => $label_id], - !empty($prop['listname']) ? $prop['listname'] : $prop['name'] - ) + html::a(['class' => 'listname', 'title' => $title, 'href' => '#', 'id' => $label_id], $listname) . (!empty($prop['virtual']) ? '' : $chbox . html::span('actions', $actions)) ); } @@ -352,7 +372,7 @@ } foreach ((array) $this->plugin->driver->get_lists() as $id => $prop) { - if (!empty($prop['editable']) || strpos($prop['rights'], 'i') !== false) { + if (!empty($prop['rights']) && strpos($prop['rights'], 'i') !== false) { $select->add($prop['name'], $id); if (!$default || !empty($prop['default'])) { $default = $id;