Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117922576
D4668.1775436150.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
156 KB
Referenced Files
None
Subscribers
None
D4668.1775436150.diff
View Options
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="⚙" 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
Details
Attached
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)
Attached To
Mode
D4668: ACL management for DAV folders
Attached
Detach File
Event Timeline