Page MenuHomePhorge

D4668.1775436150.diff
No OneTemporary

Authored By
Unknown
Size
156 KB
Referenced Files
None
Subscribers
None

D4668.1775436150.diff

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 @@
+<?php
+
+/*
+ * DAV Access Control Lists Management
+ *
+ * Copyright (C) Apheleia IT <contact@aphelaia-it.ch>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+/**
+ * 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, '<span></span>');
+ }
+
+ $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 = '<?xml version="1.0" encoding="utf-8"?>'
- . '<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:' . $ns[$component] . '">'
+ . '<d:propfind xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:card="urn:ietf:params:xml:ns:carddav">'
. '<d:prop>'
- . '<c:' . $homes[$component] . ' />'
+ . '<cal:calendar-home-set/>'
+ . '<card:addressbook-home-set/>'
+ . '<d:notification-URL/>'
. '</d:prop>'
. '</d:propfind>';
@@ -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 @@
. '<d:prop>'
. '<d:resourcetype />'
. '<d:displayname />'
- // . '<d:sync-token />'
+ . '<d:share-access/>' // draft-pot-webdav-resource-sharing-04
+ . '<d:owner/>' // RFC 3744 (ACL)
. '<cs:getctag />'
. $props
. '</d:prop>'
@@ -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: <allprop> does not include some of the properties we're interested in
$body = '<?xml version="1.0" encoding="utf-8"?>'
- . '<d:propfind xmlns:d="DAV:">'
- . '<d:allprop/>'
+ . '<d:propfind ' . $ns . '>'
+ . '<d:prop>'
+ . '<a:calendar-color/>'
+ . '<c:supported-calendar-component-set/>'
+ . '<cs:getctag/>'
+ . '<d:acl/>'
+ . '<d:current-user-privilege-set/>'
+ . '<d:resourcetype/>'
+ . '<d:displayname/>'
+ . '<d:share-access/>' // draft-pot-webdav-resource-sharing-04
+ . '<d:owner/>' // RFC 3744 (ACL)
+ . '<d:invite/>'
+ . '<k:alarms/>'
+ . '</d:prop>'
. '</d:propfind>';
// 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 = '<?xml version="1.0" encoding="utf-8"?>'
+ . ' <d:propfind xmlns:d="DAV:">'
+ . '<d:prop>'
+ . '<d:notificationtype/>'
+ . '</d:prop>'
+ . '</d:propfind>';
+
+ $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 = '<d:invite-' . $action . '/>';
+
+ // Note: <create-in> and <slug> are ignored by Cyrus
+
+ if (!empty($props['comment'])) {
+ $reply .= '<d:comment>' . htmlspecialchars($props['comment'], ENT_XML1, 'UTF-8') . '</d:comment>';
+ }
+
+ $headers = ['Content-Type' => 'application/davsharing+xml; charset=utf-8'];
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:invite-reply xmlns:d="DAV:">' . $reply . '</d:invite-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 = '<d:' . $idx . '/>';
+ } else {
+ $principal = '<d:href>' . htmlspecialchars($idx, ENT_XML1, 'UTF-8') . '</d:href>';
+ }
+
+ $grant = [];
+ $deny = [];
+
+ foreach ($privileges['grant'] ?? [] as $i => $p) {
+ $p = '<' . ($ns_privileges[$p] ?? "d:{$p}") . '/>';
+ $grant[$i] = '<d:privilege>' . $p . '</d:privilege>';
+ }
+ foreach ($privileges['deny'] ?? [] as $i => $p) {
+ $p = '<' . ($ns_privileges[$p] ?? "d:{$p}") . '/>';
+ $deny[$i] = '<d:privilege>' . $p . '</d:privilege>';
+ }
+
+ $acl[$idx] = '<d:ace>'
+ . '<d:principal>' . $principal . '</d:principal>'
+ . (count($grant) > 0 ? '<d:grant>' . implode('', $grant) . '</d:grant>' : '')
+ . (count($deny) > 0 ? '<d:deny>' . implode('', $deny) . '</d:deny>' : '')
+ . '</d:ace>';
+ }
+
+ $acl = implode('', $acl);
+ $ns = 'xmlns:d="DAV:"';
+
+ if (strpos($acl, '<c:')) {
+ $ns .= ' xmlns:c="urn:ietf:params:xml:ns:caldav"';
+ }
+ if (strpos($acl, '<cy:')) {
+ $ns .= ' xmlns:cy="http://cyrusimap.org/ns/"';
+ }
+
+ $body = '<?xml version="1.0" encoding="utf-8"?><d:acl ' . $ns . '>' . $acl . '</d: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 .= '<d:sharee>'
+ . '<d:href>' . htmlspecialchars($href, ENT_XML1, 'UTF-8') . '</d:href>'
+ . '<d:share-access><d:' . ($sharee['access'] ?? self::SHARING_NO_ACCESS) . '/></d:share-access>'
+ . '<d:' . ($sharee['status'] ?? 'noresponse') . '/>';
+
+ if (isset($sharee['comment']) && strlen($sharee['comment'])) {
+ $props .= '<d:comment>' . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8') . '</d:comment>';
+ }
+
+ if (isset($sharee['displayname']) && strlen($sharee['displayname'])) {
+ $props .= '<d:prop><d:displayname>'
+ . htmlspecialchars($sharee['comment'], ENT_XML1, 'UTF-8')
+ . '</d:displayname></d:prop>';
+ }
+
+ $props .= '</d:sharee>';
+ }
+
+ $headers = ['Content-Type' => 'application/davsharing+xml; charset=utf-8'];
+
+ $body = '<?xml version="1.0" encoding="utf-8"?>'
+ . '<d:share-resource xmlns:d="DAV:">' . $props . '</d:share-resource>';
+
+ $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 @@
+<?php
+
+/*
+ * DAV sharing based on draft-pot-webdav-resource-sharing standard
+ *
+ * Copyright (C) Apheleia IT <contact@aphelaia-it.ch>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+/**
+ * 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, '<span></span>');
+
+ 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/<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/<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 = $('<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($('<a>').text(o.display));
+ } else {
+ cl = $.inArray(cl, o.acl) >= 0 ? ' enabled' : ' disabled';
+ td.addClass(this.className + cl).html('<span/>');
+ }
+
+ $(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 @@
+<div id="acllist-container" class="table-widget">
+ <div id="acllist-content" class="content">
+ <h2 class="voice" id="aria-label-acltable"><roundcube:label name="libkolab.ariasummaryacltable" /></h2>
+ <roundcube:object name="acltable" id="acltable" class="records-table options-table" aria-labelledby="aria-label-acltable" role="listbox" />
+ </div>
+ <div id="acllist-footer" class="footer toolbar menu">
+ <roundcube:button command="acl-create" type="link" class="create disabled" classAct="create"
+ label="libkolab.add" title="libkolab.newuser" innerClass="inner" />
+ <roundcube:button name="aclmenulink" type="link"
+ label="actions" title="libkolab.actions" href="#acl-actions"
+ class="actions" innerClass="inner" data-popup="acl-menu" />
+ </div>
+</div>
+
+<div id="acl-menu" class="popupmenu" aria-hidden="true" data-align="bottom">
+ <h3 id="aria-label-aclactions" class="voice"><roundcube:label name="libkolab.arialabelaclactions" /></h3>
+ <ul class="menu listing iconized" role="menu" aria-labelledby="aria-label-aclactions">
+ <roundcube:button command="acl-edit" label="edit" type="link-menuitem" class="edit disabled" classAct="edit active" />
+ <roundcube:button command="acl-delete" label="delete" type="link-menuitem" class="delete disabled" classAct="delete active" />
+ </ul>
+</div>
+
+<div id="aclform" class="popupmenu formcontent" aria-labelledby="aria-label-aclform" role="form">
+ <h3 id="aria-label-aclform" class="voice"><roundcube:label name="libkolab.arialabelaclform" /></h3>
+ <div class="row form-group">
+ <label class="col-sm-4 col-form-label"><roundcube:label name="libkolab.identifier" /></label>
+ <roundcube:object name="acluser" id="acluser" class="proplist col-sm-8" />
+ </div>
+ <div class="row form-group">
+ <label class="col-sm-4 col-form-label"><roundcube:label name="libkolab.myrights" /></label>
+ <roundcube:object name="aclrights" class="proplist col-sm-8" />
+ </div>
+</div>
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$@<O00001
literal 0
Hc$@<O00001
diff --git a/plugins/libkolab/skins/larry/images/partial.png b/plugins/libkolab/skins/larry/images/partial.png
new file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/plugins/libkolab/skins/larry/libkolab.css b/plugins/libkolab/skins/larry/libkolab.css
--- a/plugins/libkolab/skins/larry/libkolab.css
+++ b/plugins/libkolab/skins/larry/libkolab.css
@@ -141,3 +141,104 @@
width: 60px;
padding-right: 0;
}
+
+#aclcontainer {
+ overflow-x: auto;
+ border: 1px solid #CCDDE4;
+ background-color: #D9ECF4;
+ height: 272px;
+ box-shadow: none;
+}
+
+#acllist-content {
+ position: relative;
+ height: 230px;
+ background-color: white;
+}
+
+#acllist-footer {
+ position: relative;
+}
+
+#acltable {
+ width: 100%;
+ border-collapse: collapse;
+ border: none;
+}
+
+#acltable th,
+#acltable td {
+ white-space: nowrap;
+ text-align: center;
+}
+
+#acltable thead tr th {
+ font-size: 11px;
+ font-weight: bold;
+}
+
+#acltable tbody td {
+ text-align: center;
+ height: 16px;
+ cursor: default;
+}
+
+#acltable thead tr > .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 @@
+<div id="aclcontainer" class="uibox listbox">
+<div id="acllist-content" class="scroller withfooter">
+ <h2 class="voice" id="aria-label-acltable"><roundcube:label name="libkolab.ariasummaryacltable" /></h2>
+ <roundcube:object name="acltable" id="acltable" class="records-table" aria-labelledby="aria-label-acltable" role="listbox" />
+</div>
+<div id="acllist-footer" class="boxfooter">
+ <roundcube:button command="acl-create" id="aclcreatelink" type="link" title="libkolab.newuser" class="listbutton add disabled" classAct="listbutton add" innerClass="inner" content="+" /><roundcube:button name="aclmenulink" id="aclmenulink" type="link" title="libkolab.actions" class="listbutton groupactions" onclick="return UI.toggle_popup('aclmenu', event)" innerClass="inner" content="&#9881;" aria-haspopup="true" aria-expanded="false" aria-owns="aclmenu-menu" />
+</div>
+</div>
+
+<div id="aclmenu" class="popupmenu" aria-hidden="true" data-align="bottom">
+ <h3 id="aria-label-aclactions" class="voice"><roundcube:label name="libkolab.arialabelaclactions" /></h3>
+ <ul id="aclmenu-menu" class="toolbarmenu selectable iconized" role="menu" aria-labelledby="aria-label-aclactions">
+ <li role="menuitem"><roundcube:button command="acl-edit" label="edit" type="link" class="icon" classAct="icon active" innerclass="icon edit" /></li>
+ <li role="menuitem"><roundcube:button command="acl-delete" label="delete" type="link" class="icon" classAct="icon active" innerclass="icon delete" /></li>
+ </ul>
+</div>
+
+<div id="aclform" class="propform" aria-labelledby="aria-label-aclform" role="form">
+ <h3 id="aria-label-aclform" class="voice"><roundcube:label name="libkolab.arialabelaclform" /></h3>
+ <fieldset class="thinbordered"><legend><roundcube:label name="libkolab.identifier" /></legend>
+ <roundcube:object name="acluser" id="acluser" size="35" class="proplist" />
+ </fieldset>
+ <fieldset class="thinbordered"><legend><roundcube:label name="libkolab.myrights" /></legend>
+ <roundcube:object name="aclrights" class="proplist" />
+ </fieldset>
+</div>
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 @@
+<?php
+
+/**
+ * libkolab class tests
+ *
+ * @author Aleksander Machniak <machniak@apheleia-it.ch>
+ *
+ * Copyright (C) Apheleia IT <contact@apheleia-it.ch>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+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('<del>test plain text</del>', $diff);
+
+ $text1 = '';
+ $text2 = 'test plain text';
+ $diff = libkolab::html_diff($text1, $text2);
+ $this->assertSame('<ins>test plain text</ins>', $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 = '<html><p>test plain text</p></html>';
+ $text2 = '';
+ $diff = libkolab::html_diff($text1, $text2);
+ $this->assertSame('<p class="diffmod"><del class="diffmod">test plain text</del></p><div class="diffmod pre"></div>', $diff);
+
+ $text1 = '';
+ $text2 = '<html><p>test plain text</p></html>';
+ $diff = libkolab::html_diff($text1, $text2);
+ $this->assertSame('<div class="diffmod pre"></div><p class="diffmod"><ins class="diffmod">test plain text</ins></p>', $diff);
+
+ $text1 = '<html><p>test plain text</p></html>';
+ $text2 = 'test plain text';
+ $diff = libkolab::html_diff($text1, $text2);
+ $this->assertSame('<p class="diffmod"><div class="diffmod pre">test plain text</p></div>', $diff);
+
+ $text1 = '<html><p>test plain text</p></html>';
+ $text2 = '<html><p>test</p></html>';
+ $diff = libkolab::html_diff($text1, $text2);
+ $this->assertSame('<p>test<del class="diffdel"> plain text</del></p>', $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;

File Metadata

Mime Type
text/plain
Expires
Mon, Apr 6, 12:42 AM (2 h, 6 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18832364
Default Alt Text
D4668.1775436150.diff (156 KB)

Event Timeline