CardDAV support

This commit is contained in:
Aleksander Machniak 2022-10-14 16:34:19 +02:00
parent f53ff8edec
commit 0159e3c115
9 changed files with 268 additions and 142 deletions

View file

@ -1,5 +1,10 @@
<?php
// Backend type (kolab, carddav)
$config['kolab_addressbook_driver'] = "kolab";
// CalDAV server location (required when kolab_addressbook_driver = carddav)
$config['kolab_addressbook_carddav_server'] = "http://localhost";
// This option allows to set addressbooks priority or to disable some
// of them. Disabled addressbooks will be not shown in the UI. Default: 0.
@ -15,7 +20,8 @@ $config['kolab_addressbook_prio'] = 0;
// %u - Current webmail user name
// %n - Folder name
// %i - Folder UUID
// $config['kolab_addressbook_carddav_url'] = 'http://%h/iRony/addressbooks/%u/%i';
// For example: 'http://%h/iRony/addressbooks/%u/%i'
$config['kolab_addressbook_carddav_url'] = null;
// Name of LDAP addressbook (a key in ldap_public configuration array) for which
// the CardDAV URI will be displayed if kolab_addressbook_carddav_url is set.
@ -31,5 +37,3 @@ $config['kolab_addressbook_prio'] = 0;
// ignore these properties and allow modifications which then result in sync errors because the server
// denies such updates.
$config['kolab_addressbook_carddav_ldap'] = '';
?>

View file

@ -31,12 +31,13 @@ class kolab_addressbook extends rcube_plugin
{
public $task = '?(?!logout).*';
public $driver;
public $bonnie_api = false;
private $sources;
private $folders;
private $rc;
private $ui;
public $bonnie_api = false;
private $driver_class;
const GLOBAL_FIRST = 0;
const PERSONAL_FIRST = 1;
@ -53,8 +54,11 @@ class kolab_addressbook extends rcube_plugin
// load required plugin
$this->require_plugin('libkolab');
$driver = $this->rc->config->get('kolab_addressbook_driver') ?: 'kolab';
require_once(dirname(__FILE__) . '/lib/rcube_' . $driver . '_contacts.php');
$this->load_config();
$this->driver = $this->rc->config->get('kolab_addressbook_driver') ?: 'kolab';
$this->driver_class = 'rcube_' . $this->driver . '_contacts';
require_once(dirname(__FILE__) . '/lib/' . $this->driver_class . '.php');
// register hooks
$this->add_hook('addressbooks_list', array($this, 'address_sources'));
@ -82,7 +86,6 @@ class kolab_addressbook extends rcube_plugin
// Load UI elements
if ($this->api->output->type == 'html') {
$this->load_config();
require_once($this->home . '/lib/kolab_addressbook_ui.php');
$this->ui = new kolab_addressbook_ui($this);
@ -104,9 +107,11 @@ class kolab_addressbook extends rcube_plugin
$this->add_hook('preferences_save', array($this, 'prefs_save'));
}
$this->add_hook('folder_delete', array($this, 'prefs_folder_delete'));
$this->add_hook('folder_rename', array($this, 'prefs_folder_rename'));
$this->add_hook('folder_update', array($this, 'prefs_folder_update'));
if ($this->driver == 'kolab') {
$this->add_hook('folder_delete', array($this, 'prefs_folder_delete'));
$this->add_hook('folder_rename', array($this, 'prefs_folder_rename'));
$this->add_hook('folder_update', array($this, 'prefs_folder_update'));
}
}
/**
@ -212,10 +217,22 @@ class kolab_addressbook extends rcube_plugin
$out .= $this->addressbook_list_item($id, $source, $jsdata) . '</li>';
}
$filter = function($source) { return !empty($source['kolab']) && empty($source['hidden']); };
$folders = array_filter($sources, $filter);
// render a hierarchical list of kolab contact folders
kolab_storage::folder_hierarchy($this->folders, $tree);
if ($tree && !empty($tree->children)) {
$out .= $this->folder_tree_html($tree, $sources, $jsdata);
// TODO: Move this to the drivers
if ($this->driver == 'kolab') {
kolab_storage::folder_hierarchy($folders, $tree);
if ($tree && !empty($tree->children)) {
$out .= $this->folder_tree_html($tree, $sources, $jsdata);
}
}
else {
foreach ($folders as $j => $source) {
$id = strval(strlen($source['id']) ? $source['id'] : $j);
$out .= $this->addressbook_list_item($id, $source, $jsdata) . '</li>';
}
}
$this->rc->output->set_env('contactgroups', array_filter($jsdata, function($src){ return $src['type'] == 'group'; }));
@ -303,7 +320,7 @@ class kolab_addressbook extends rcube_plugin
), $name)
);
if (isset($source['subscribed'])) {
if ($this->driver == 'kolab' && isset($source['subscribed'])) {
$inner .= html::span(array(
'class' => 'subscribed',
'title' => $this->gettext('foldersubscribe'),
@ -396,7 +413,6 @@ class kolab_addressbook extends rcube_plugin
return $args;
}
/**
* Getter for the rcube_addressbook instance
*
@ -407,16 +423,8 @@ class kolab_addressbook extends rcube_plugin
public function get_address_book($p)
{
if ($p['id']) {
$id = kolab_storage::id_decode($p['id']);
$folder = kolab_storage::get_folder($id);
// try with unencoded (old-style) identifier
if ((!$folder || $folder->type != 'contact') && $id != $p['id']) {
$folder = kolab_storage::get_folder($p['id']);
}
if ($folder && $folder->type == 'contact') {
$p['instance'] = new rcube_kolab_contacts($folder->name);
if ($source = $this->driver_class::get_address_book($p['id'])) {
$p['instance'] = $source;
// flag source as writeable if 'i' right is given
if ($p['writeable'] && $this->rc->action == 'save' && strpos($p['instance']->rights, 'i') !== false) {
@ -431,57 +439,33 @@ class kolab_addressbook extends rcube_plugin
return $p;
}
/**
* List addressbook sources list
*/
private function _list_sources()
{
// already read sources
if (isset($this->sources))
if (isset($this->sources)) {
return $this->sources;
}
kolab_storage::$encode_ids = true;
$this->sources = array();
$this->folders = array();
$this->sources = [];
$abook_prio = $this->addressbook_prio();
// Personal address source(s) disabled?
if ($abook_prio == self::GLOBAL_ONLY) {
if ($abook_prio == kolab_addressbook::GLOBAL_ONLY) {
return $this->sources;
}
// get all folders that have "contact" type
$folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact'));
if (PEAR::isError($folders)) {
rcube::raise_error(array(
'code' => 600, 'type' => 'php',
'file' => __FILE__, 'line' => __LINE__,
'message' => "Failed to list contact folders from Kolab server:" . $folders->getMessage()),
true, false);
}
else {
// we need at least one folder to prevent from errors in Roundcube core
// when there's also no sql nor ldap addressbook (Bug #2086)
if (empty($folders)) {
if ($folder = kolab_storage::create_default_folder('contact')) {
$folders = array(new kolab_storage_folder($folder, 'contact'));
}
}
// convert to UTF8 and sort
foreach ($folders as $folder) {
// create instance of rcube_contacts
$abook_id = $folder->id;
$abook = new rcube_kolab_contacts($folder->name);
$this->sources[$abook_id] = $abook;
$this->folders[$abook_id] = $folder;
}
foreach ($this->driver_class::list_folders() as $id => $source) {
$this->sources[$id] = $source;
}
return $this->sources;
}
/**
* Plugin hook called before rendering the contact form or detail view
*
@ -801,17 +785,17 @@ class kolab_addressbook extends rcube_plugin
*/
private function _sort_form_fields($contents, $source)
{
$block = array();
$block = [];
foreach (array_keys($source->coltypes) as $col) {
if (isset($contents[$col]))
$block[$col] = $contents[$col];
}
foreach (array_keys($source->coltypes) as $col) {
if (isset($contents[$col])) {
$block[$col] = $contents[$col];
}
}
return $block;
return $block;
}
/**
* Handler for user preferences form (preferences_list hook)
*
@ -959,9 +943,9 @@ class kolab_addressbook extends rcube_plugin
*/
public function book_search()
{
$results = array();
$query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC);
$source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC);
$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;
@ -1109,12 +1093,6 @@ class kolab_addressbook extends rcube_plugin
*/
private function addressbook_prio()
{
// Load configuration
if (!$this->config_loaded) {
$this->load_config();
$this->config_loaded = true;
}
$abook_prio = (int) $this->rc->config->get('kolab_addressbook_prio');
// Make sure any global addressbooks are defined

View file

@ -54,6 +54,10 @@ class kolab_addressbook_ui
// Include stylesheet (for directorylist)
$this->plugin->include_stylesheet($this->plugin->local_skin_path().'/kolab_addressbook.css');
if ($this->plugin->driver != 'kolab') {
return;
}
// include kolab folderlist widget if available
if (in_array('libkolab', $this->plugin->api->loaded_plugins())) {
$this->plugin->api->include_script('libkolab/libkolab.js');
@ -66,20 +70,20 @@ class kolab_addressbook_ui
$idx = 0;
if ($dav_url = $this->rc->config->get('kolab_addressbook_carddav_url')) {
$options[] = 'book-showurl';
$this->rc->output->set_env('kolab_addressbook_carddav_url', true);
$options[] = 'book-showurl';
$this->rc->output->set_env('kolab_addressbook_carddav_url', true);
// set CardDAV URI for specified ldap addressbook
if ($ldap_abook = $this->rc->config->get('kolab_addressbook_carddav_ldap')) {
$dav_ldap_url = strtr($dav_url, array(
'%h' => $_SERVER['HTTP_HOST'],
'%u' => urlencode($this->rc->get_user_name()),
'%i' => 'ldap-directory',
'%n' => '',
));
$this->rc->output->set_env('kolab_addressbook_carddav_ldap', $ldap_abook);
$this->rc->output->set_env('kolab_addressbook_carddav_ldap_url', $dav_ldap_url);
}
// set CardDAV URI for specified ldap addressbook
if ($ldap_abook = $this->rc->config->get('kolab_addressbook_carddav_ldap')) {
$dav_ldap_url = strtr($dav_url, array(
'%h' => $_SERVER['HTTP_HOST'],
'%u' => urlencode($this->rc->get_user_name()),
'%i' => 'ldap-directory',
'%n' => '',
));
$this->rc->output->set_env('kolab_addressbook_carddav_ldap', $ldap_abook);
$this->rc->output->set_env('kolab_addressbook_carddav_ldap_url', $dav_ldap_url);
}
}
foreach ($options as $command) {

View file

@ -312,6 +312,64 @@ class rcube_kolab_contacts extends rcube_addressbook
$this->filter = null;
}
/**
* List addressbook sources (folders)
*/
public static function list_folders()
{
kolab_storage::$encode_ids = true;
// get all folders that have "contact" type
$folders = kolab_storage::sort_folders(kolab_storage::get_folders('contact'));
if (PEAR::isError($folders)) {
rcube::raise_error([
'code' => 600, 'file' => __FILE__, 'line' => __LINE__,
'message' => "Failed to list contact folders from Kolab server:" . $folders->getMessage()
],
true, false);
return [];
}
// we need at least one folder to prevent from errors in Roundcube core
// when there's also no sql nor ldap addressbook (Bug #2086)
if (empty($folders)) {
if ($folder = kolab_storage::create_default_folder('contact')) {
$folders = [new kolab_storage_folder($folder, 'contact')];
}
}
$sources = [];
foreach ($folders as $folder) {
$sources[$folder->id] = new rcube_kolab_contacts($folder->name);
}
return $sources;
}
/**
* Getter for the rcube_addressbook instance
*
* @param string $id Addressbook (folder) ID
*
* @return ?rcube_kolab_contacts
*/
public static function get_address_book($id)
{
$folderId = kolab_storage::id_decode($id);
$folder = kolab_storage::get_folder($folderId);
// try with unencoded (old-style) identifier
if ((!$folder || $folder->type != 'contact') && $folderId != $id) {
$folder = kolab_storage::get_folder($id);
}
if ($folder && $folder->type == 'contact') {
return new rcube_kolab_contacts($folder->name);
}
}
/**
* List all active contact groups of this source
*

View file

@ -73,7 +73,6 @@ class kolab_dav_client
}
try {
$request = $this->initRequest($this->url . $path, $method, $request_config);
$request->setAuth($this->user, $this->password);
@ -135,7 +134,8 @@ class kolab_dav_client
. '</d:prop>'
. '</d:propfind>';
$response = $this->request('/' . $roots[$component], 'PROPFIND', $body);
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$response = $this->request('/' . $roots[$component], 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
$elements = $response->getElementsByTagName('response');
@ -150,10 +150,22 @@ class kolab_dav_client
$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:caldav">'
. '<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:' . $ns[$component] . '">'
. '<d:prop>'
. '<c:calendar-home-set />'
. '<c:' . $homes[$component] . ' />'
. '</d:prop>'
. '</d:propfind>';
@ -178,28 +190,41 @@ class kolab_dav_client
$root_href = '/' . $roots[$component] . '/' . rawurlencode($this->user);
}
if ($component == 'VCARD') {
$add_ns = '';
$add_props = '';
}
else {
$add_ns = ' xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/"';
$add_props = '<c:supported-calendar-component-set /><a:calendar-color />';
}
$body = '<?xml version="1.0" encoding="utf-8"?>'
. '<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:a="http://apple.com/ns/ical/">'
. '<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"' . $add_ns . '>'
. '<d:prop>'
. '<d:resourcetype />'
. '<d:displayname />'
// . '<d:sync-token />'
. '<cs:getctag />'
. '<c:supported-calendar-component-set />'
. '<a:calendar-color />'
. $add_props
. '</d:prop>'
. '</d:propfind>';
$response = $this->request($root_href, 'PROPFIND', $body);
// Note: Cyrus CardDAV service requires Depth:1 (CalDAV works without it)
$response = $this->request($root_href, 'PROPFIND', $body, ['Depth' => 1, 'Prefer' => 'return-minimal']);
if (empty($response)) {
return false;
}
$folders = [];
foreach ($response->getElementsByTagName('response') as $element) {
$folder = $this->getFolderPropertiesFromResponse($element);
if ($folder['type'] === $component) {
// Note: Addressbooks don't have 'type' specified
if (($component == 'VCARD' && in_array('addressbook', $folder['resource_type']))
|| $folder['type'] === $component
) {
$folders[] = $folder;
}
}
@ -210,9 +235,17 @@ class kolab_dav_client
/**
* Create a DAV object in a folder
*/
public function create($location, $content)
public function create($location, $content, $component = 'VEVENT')
{
$response = $this->request($location, 'PUT', $content, ['Content-Type' => 'text/calendar; charset=utf-8']);
$ctype = [
'VEVENT' => 'text/calendar',
'VTODO' => 'text/calendar',
'VCARD' => 'text/vcard',
];
$headers = ['Content-Type' => $ctype[$component] . '; charset=utf-8'];
$response = $this->request($location, 'PUT', $content, $headers);
if ($response !== false) {
$etag = $this->responseHeaders['etag'];
@ -230,9 +263,9 @@ class kolab_dav_client
/**
* Update a DAV object in a folder
*/
public function update($location, $content)
public function update($location, $content, $component = 'VEVENT')
{
return $this->create($location, $content);
return $this->create($location, $content, $component);
}
/**

View file

@ -100,14 +100,18 @@ class kolab_storage_dav
/**
* Getter for a specific storage folder
*
* @param string Folder to access
* @param string Expected folder type
* @param string $id Folder to access
* @param string $type Expected folder type
*
* @return object kolab_storage_folder The folder object
* @return ?object kolab_storage_folder The folder object
*/
public function get_folder($folder, $type = null)
public function get_folder($id, $type = null)
{
// TODO
foreach ($this->get_folders($type) as $folder) {
if ($folder->id == $id) {
return $folder;
}
}
}
/**

View file

@ -319,8 +319,8 @@ class kolab_storage_dav_cache extends kolab_storage_cache
$sql_data = $this->_serialize($object);
$sql_data['folder_id'] = $this->folder_id;
$sql_data['uid'] = $object['uid'];
$sql_data['etag'] = $object['etag'];
$sql_data['uid'] = rcube_charset::clean($object['uid']);
$sql_data['etag'] = rcube_charset::clean($object['etag']);
$args = [];
$cols = ['folder_id', 'uid', 'etag', 'changed', 'data', 'tags', 'words'];
@ -524,8 +524,15 @@ class kolab_storage_dav_cache extends kolab_storage_cache
// In Oracle we can't put long data inline, others we don't support yet
if (strpos($this->db->db_provider, 'mysql') !== 0) {
$extra_args = [];
$params = [$this->folder_id, $object['uid'], $object['etag'], $sql_data['changed'],
$sql_data['data'], $sql_data['tags'], $sql_data['words']];
$params = [
$this->folder_id,
rcube_charset::clean($object['uid']),
rcube_charset::clean($object['etag']),
$sql_data['changed'],
$sql_data['data'],
$sql_data['tags'],
$sql_data['words']
];
foreach ($this->extra_cols as $col) {
$params[] = $sql_data[$col];
@ -552,8 +559,8 @@ class kolab_storage_dav_cache extends kolab_storage_cache
$values = array(
$this->db->quote($this->folder_id),
$this->db->quote($object['uid']),
$this->db->quote($object['etag']),
$this->db->quote(rcube_charset::clean($object['uid'])),
$this->db->quote(rcube_charset::clean($object['etag'])),
$this->db->now(),
$this->db->quote($sql_data['changed']),
$this->db->quote($sql_data['data']),

View file

@ -21,12 +21,12 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
class kolab_storage_cache_contact extends kolab_storage_cache
class kolab_storage_dav_cache_contact extends kolab_storage_dav_cache
{
protected $extra_cols_max = 255;
protected $extra_cols = ['type', 'name', 'firstname', 'surname', 'email'];
protected $data_props = ['type', 'name', 'firstname', 'middlename', 'prefix', 'suffix', 'surname', 'email', 'organization', 'member'];
protected $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email:address'];
protected $fulltext_cols = ['name', 'firstname', 'surname', 'middlename', 'email'];
/**
* Helper method to convert the given Kolab object into a dataset to be written to cache
@ -36,20 +36,20 @@ class kolab_storage_cache_contact extends kolab_storage_cache
protected function _serialize($object)
{
$sql_data = parent::_serialize($object);
$sql_data['type'] = $object['_type'];
$sql_data['type'] = $object['_type'] ?: 'contact';
// columns for sorting
$sql_data['name'] = rcube_charset::clean($object['name'] . $object['prefix']);
$sql_data['firstname'] = rcube_charset::clean($object['firstname'] . $object['middlename'] . $object['surname']);
$sql_data['surname'] = rcube_charset::clean($object['surname'] . $object['firstname'] . $object['middlename']);
$sql_data['email'] = rcube_charset::clean(is_array($object['email']) ? $object['email'][0] : $object['email']);
$sql_data['email'] = '';
if (is_array($sql_data['email'])) {
$sql_data['email'] = $sql_data['email']['address'];
}
// avoid value being null
if (empty($sql_data['email'])) {
$sql_data['email'] = '';
foreach ($object as $colname => $value) {
list($col, $field) = explode(':', $colname);
if ($col == 'email' && !empty($value)) {
$sql_data['email'] = is_array($value) ? $value[0] : $value;
break;
}
}
// use organization if name is empty
@ -78,21 +78,18 @@ class kolab_storage_cache_contact extends kolab_storage_cache
public function get_words($object)
{
$data = '';
foreach ($this->fulltext_cols as $colname) {
foreach ($object as $colname => $value) {
list($col, $field) = explode(':', $colname);
if ($field) {
$a = [];
foreach ((array)$object[$col] as $attr)
$a[] = $attr[$field];
$val = join(' ', $a);
}
else {
$val = is_array($object[$col]) ? join(' ', $object[$col]) : $object[$col];
$val = '';
if (in_array($col, $this->fulltext_cols)) {
$val = is_array($value) ? join(' ', $value) : $value;
}
if (strlen($val))
if (strlen($val)) {
$data .= $val . ' ';
}
}
return array_unique(rcube_utils::normalize_string($data, true));

View file

@ -32,12 +32,9 @@ class kolab_storage_dav_folder extends kolab_storage_folder
public function __construct($dav, $attributes, $type_annotation = '')
{
$this->attributes = $attributes;
$this->href = $this->attributes['href'];
// Here we assume the last element of the folder path is the folder ID
// if that's not the case, we should consider generating an ID
$href = explode('/', unslashify($this->href));
$this->id = $href[count($href) - 1];
$this->href = $this->attributes['href'];
$this->id = md5($this->href);
$this->dav = $dav;
$this->valid = true;
@ -410,8 +407,9 @@ class kolab_storage_dav_folder extends kolab_storage_folder
// generate and save object message
if ($content = $this->to_dav($object)) {
$method = $uid ? 'update' : 'create';
$result = $this->dav->{$method}($this->object_location($object['uid']), $content);
$method = $uid ? 'update' : 'create';
$dav_type = $this->get_dav_type();
$result = $this->dav->{$method}($this->object_location($object['uid']), $content, $dav_type);
// Note: $result can be NULL if the request was successful, but ETag wasn't returned
if ($result !== false) {
@ -473,10 +471,24 @@ class kolab_storage_dav_folder extends kolab_storage_folder
$result = $events[0];
}
else if ($this->type == 'contact') {
if (stripos($object['data'], 'BEGIN:VCARD') !== 0) {
return false;
}
$vcard = new rcube_vcard($object['data'], RCUBE_CHARSET, false);
if (!empty($vcard->displayname) || !empty($vcard->surname) || !empty($vcard->firstname) || !empty($vcard->email)) {
$result = $vcard->get_assoc();
}
else {
return false;
}
}
$result['etag'] = $object['etag'];
$result['href'] = $object['href'];
$result['uid'] = $object['uid'] ?: $result['uid'];
$result['uid'] = $object['uid'] ?: $result['uid'];
return $result;
}
@ -496,6 +508,35 @@ class kolab_storage_dav_folder extends kolab_storage_folder
$result = $ical->export([$object]);
}
else if ($this->type == 'contact') {
// copy values into vcard object
$vcard = new rcube_vcard('', RCUBE_CHARSET, false, ['uid' => 'UID']);
$vcard->set('groups', null);
foreach ($object as $key => $values) {
list($field, $section) = rcube_utils::explode(':', $key);
// avoid casting DateTime objects to array
if (is_object($values) && is_a($values, 'DateTime')) {
$values = [$values];
}
foreach ((array) $values as $value) {
if (isset($value)) {
$vcard->set($field, $value, $section);
}
}
}
$result = $vcard->export(false);
}
if ($result) {
// The content must be UTF-8, otherwise if we try to fetch the object
// from server XML parsing would fail.
$result = rcube_charset::clean($result);
}
return $result;
}