From 0768f5fadf434b78efb76eadd8e1e6d96628f0cd Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Thu, 16 Sep 2021 20:53:43 +0200 Subject: [PATCH] WIP REST Api for contacts --- .../inc/class.addressbook_groupdav.inc.php | 31 +-- api/src/CalDAV.php | 185 ++++++++++++++++++ api/src/CalDAV/Handler.php | 19 +- api/src/Contacts/JsContact.php | 52 +++-- 4 files changed, 239 insertions(+), 48 deletions(-) diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index caeb42d7e1..f3d2db8406 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -73,9 +73,14 @@ class addressbook_groupdav extends Api\CalDAV\Handler $this->bo = new Api\Contacts(); + if (Api\CalDAV::isJSON()) + { + self::$path_attr = 'id'; + self::$path_extension = ''; + } // since 1.9.007 we allow clients to specify the URL when creating a new contact, as specified by CardDAV // LDAP does NOT have a carddav_name attribute --> stick with id mapped to LDAP attribute uid - if (version_compare($GLOBALS['egw_info']['apps']['api']['version'], '1.9.007', '<') || + elseif (version_compare($GLOBALS['egw_info']['apps']['api']['version'], '1.9.007', '<') || $this->bo->contact_repository != 'sql' || $this->bo->account_repository != 'sql' && strpos($_SERVER['REQUEST_URI'].'/','/addressbook-accounts/') !== false) { @@ -173,12 +178,12 @@ class addressbook_groupdav extends Api\CalDAV\Handler if ($options['root']['name'] == 'sync-collection' && $this->bo->total > $nresults) { --$this->sync_collection_token; - $files['sync-token-params'][] = true; // tel get_sync_collection_token that we have more entries + $files['sync-token-params'][] = true; // tell get_sync_collection_token that we have more entries } } else { - // return iterator, calling ourself to return result in chunks + // return iterator, calling ourselves to return result in chunks $files['files'] = new Api\CalDAV\PropfindIterator($this,$path,$filter,$files['files']); } return true; @@ -270,6 +275,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler } } + $is_jscontact = Api\CalDAV::isJSON(); foreach($contacts as &$contact) { // remove contact from requested multiget ids, to be able to report not found urls @@ -284,15 +290,16 @@ class addressbook_groupdav extends Api\CalDAV\Handler continue; } $props = array( - 'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', 'text/vcard'), + 'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', $is_jscontact ? JsContact::MIME_TYPE_JSCARD : 'text/vcard'), 'getlastmodified' => $contact['modified'], 'displayname' => $contact['n_fn'], ); if ($address_data) { - $content = $handler->getVCard($contact['id'],$this->charset,false); + $content = $is_jscontact ? JsContact::getJsCard($contact['id'], false) : + $handler->getVCard($contact['id'],$this->charset,false); $props['getcontentlength'] = bytes($content); - $props[] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content); + $props['address-data'] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content); } $files[] = $this->add_resource($path, $contact, $props); } @@ -351,16 +358,16 @@ class addressbook_groupdav extends Api\CalDAV\Handler $etag .= ':'.implode('-',$filter['owner']); } $props = array( - 'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', 'text/vcard'), + 'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', $is_jscontact ? JsContact::MIME_TYPE_JSCARDGROUP : 'text/vcard'), 'getlastmodified' => Api\DateTime::to($list['list_modified'],'ts'), 'displayname' => $list['list_name'], 'getetag' => '"'.$etag.'"', ); if ($address_data) { - $content = $handler->getGroupVCard($list); + $content = $is_jscontact ? JsContact::getJsCardGroup($list) : $handler->getGroupVCard($list); $props['getcontentlength'] = bytes($content); - $props[] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content); + $props['address-data'] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content); } $files[] = $this->add_resource($path, $list, $props); @@ -452,7 +459,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler } else { - switch($filter['attrs']['collation']) // todo: which other collations allowed, we are allways unicode + switch($filter['attrs']['collation']) // todo: which other collations allowed, we are always unicode { case 'i;unicode-casemap': default: @@ -590,7 +597,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler return $contact; } // jsContact or vCard - if (JsContact::isJsContact()) + if (Api\CalDAV::isJSON()) { $options['data'] = $contact['list_id'] ? JsContact::getJsCardGroup($contact) : JsContact::getJsCard($contact); $options['mimetype'] = $contact['list_id'] ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD; @@ -628,7 +635,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler return $oldContact; } - if (JsContact::isJsContact()) + if (Api\CalDAV::isJSON()) { $contact = JsContact::parseJsCard($options['content']); // just output it again for now diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index ccf7885eed..9073b17c4b 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -18,6 +18,8 @@ use EGroupware\Api\CalDAV\Principals; // explicit import non-namespaced classes require_once(__DIR__.'/WebDAV/Server.php'); + +use EGroupware\Api\Contacts\JsContact; use HTTP_WebDAV_Server; use calendar_hooks; @@ -976,6 +978,21 @@ class CalDAV extends HTTP_WebDAV_Server parent::http_PROPFIND('REPORT'); } + /** + * Check if clients want's or sends JSON + * + * @return bool + */ + public static function isJSON(string $type=null) + { + if (!isset($type)) + { + $type = in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'POST', 'PROPPATCH']) ? + $_SERVER['HTTP_CONTENT_TYPE'] : $_SERVER['HTTP_ACCEPT']; + } + return (bool)preg_match('#application/([^+ ;]+\+)?json#', $type); + } + /** * GET method handler * @@ -989,6 +1006,10 @@ class CalDAV extends HTTP_WebDAV_Server $id = $app = $user = null; if (!$this->_parse_path($options['path'],$id,$app,$user) || $app == 'principals') { + if (self::isJSON()) + { + return $this->jsonIndex($options); + } return $this->autoindex($options); } if (($handler = self::app_handler($app))) @@ -999,6 +1020,170 @@ class CalDAV extends HTTP_WebDAV_Server return '501 Not Implemented'; } + const JSON_OPTIONS = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR; + const JSON_OPTIONS_PRETTY = JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR; + + /** + * JSON encode incl. modified pretty-print + * + * @param $data + * @return array|string|string[]|null + */ + public static function json_encode($data, $pretty = true) + { + if (!$pretty) + { + return self::json_encode($data, self::JSON_OPTIONS); + } + return preg_replace('/: {\n\s*(.*?)\n\s*(},?\n)/', ': { $1 $2', + json_encode($data, self::JSON_OPTIONS_PRETTY)); + } + + /** + * PROPFIND/REPORT like output for GET request on collection with Accept: application/(.*+)?json + * + * For addressbook-collections we give a REST-like output without any other properties + * { + * "/addressbook/ID": { + * JsContact-data + * }, + * ... + * } + * + * @param array $options + * @return bool|string|void + */ + protected function jsonIndex(array $options) + { + header('Content-Type: application/json; charset=utf-8'); + $is_addressbook = strpos($options['path'], '/addressbook') !== false; + $propfind_options = array( + 'path' => $options['path'], + 'depth' => 1, + 'props' => $is_addressbook ? [ + 'address-data' => self::mkprop(self::CARDDAV, 'address-data', '') + ] : 'all', + 'other' => [], + ); + + // sync-collection report via GET parameter sync-token + if (isset($_GET['sync-token'])) + { + $propfind_options['root'] = ['name' => 'sync-collection']; + $propfind_options['other'][] = ['name' => 'sync-token', 'data' => $_GET['sync-token']]; + $propfind_options['other'][] = ['name' => 'sync-level', 'data' => $_GET['sync-level'] ?? 1]; + + // clients want's pagination + if (isset($_GET['nresults'])) + { + $propfind_options['other'][] = ['name' => 'nresults', 'data' => (int)$_GET['nresults']]; + } + } + + // ToDo: client want data filtered + if (isset($_GET['filters'])) + { + + } + + // properties to NOT get the default address-data for addressbook-collections and "all" for the rest + if (isset($_GET['props'])) + { + $propfind_options['props'] = []; + foreach((array)$_GET['props'] as $value) + { + $parts = explode(':', $value); + $name = array_pop($parts); + $ns = $parts ? implode(':', $parts) : 'DAV:'; + $propfind_options['props'][$name] = self::mkprop($ns, $name, ''); + } + } + $files = array(); + if (($ret = $this->REPORT($propfind_options,$files)) !== true) + { + return $ret; // no collection + } + + echo "{\n"; + $prefix = " "; + foreach($files['files'] as $resource) + { + $path = $resource['path']; + echo $prefix.json_encode($path, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE).': '; + if (!isset($resource['props'])) + { + echo 'null'; // deleted in sync-report + } + /*elseif (isset($resource['props']['address-data'])) + { + echo $resource['props']['address-data']['val']; + }*/ + else + { + $props = $propfind_options['props'] === 'all' ? $resource['props'] : + array_intersect_key($resource['props'], $propfind_options['props']); + + if (count($props) > 1) + { + $props = self::jsonProps($props); + } + else + { + $props = current($props)['val']; + } + echo self::json_encode($props); + } + $prefix = ",\n "; + } + // add sync-token to response + if (isset($files['sync-token'])) + { + echo $prefix.'"sync-token": '.json_encode(!is_callable($files['sync-token']) ? $files['sync-token'] : + call_user_func_array($files['sync-token'], (array)$files['sync-token-params']), JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE); + } + echo "\n}\n"; + + // exit now, so WebDAV::GET does NOT add Content-Type: application/octet-stream + exit; + } + + /** + * Nicer way to display/encode DAV properties + * + * @param array $props + * @return array + */ + protected function jsonProps(array $props) + { + $json = []; + foreach($props as $key => $prop) + { + if (is_scalar($prop['val'])) + { + $value = is_int($key) && $prop['val'] === '' ? + /*$prop['ns'].':'.*/$prop['name'] : $prop['val']; + } + // check if this is a property-object + elseif (count($prop) === 3 && isset($prop['name']) && isset($prop['ns']) && isset($prop['val'])) + { + $value = $prop['name'] === 'address-data' ? $prop['val'] : self::jsonProps($prop['val']); + } + else + { + $value = $prop; + } + if (is_int($key)) + { + $json[] = $value; + } + else + { + $json[/*($prop['ns'] === 'DAV:' ? '' : $prop['ns'].':').*/$prop['name']] = $value; + } + } + return $json; + } + /** * Display an automatic index (listing and properties) for a collection * diff --git a/api/src/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index 54a89940fa..092c995d83 100644 --- a/api/src/CalDAV/Handler.php +++ b/api/src/CalDAV/Handler.php @@ -712,16 +712,23 @@ abstract class Handler //error_log(__METHOD__."('$path', $user, more_results=$more_results) this->sync_collection_token=".$this->sync_collection_token); if ($more_results) { - $error = -' - '.htmlspecialchars($this->caldav->base_uri.$this->caldav->path).' + if (Api\CalDAV::isJSON()) + { + $error = ",\n".' "more-results": true'; + } + else + { + $error = + ' + ' . htmlspecialchars($this->caldav->base_uri . $this->caldav->path) . ' HTTP/1.1 507 Insufficient Storage '; - if ($this->caldav->crrnd) - { - $error = str_replace(array('caldav->crrnd) + { + $error = str_replace(array('read($contact))) { throw new Api\Exception\NotFound(); } - return json_encode(array_filter([ + $data = array_filter([ 'uid' => $contact['uid'], 'prodId' => 'EGroupware Addressbook '.$GLOBALS['egw_info']['apps']['api']['version'], 'created' => self::UTCDateTime($contact['created']), @@ -90,7 +72,12 @@ class JsContact 'egroupware.org/customfields' => self::customfields($contact), 'egroupware.org/assistant' => $contact['assistent'], 'egroupware.org/fileAs' => $contact['fileas'], - ]), self::JSON_OPTIONS); + ]); + if ($encode) + { + return Api\CalDAV::json_encode($data); + } + return $data; } /** @@ -526,14 +513,19 @@ class JsContact * @param ?string $street2=null 2. address line * @return array[] array of objects with attributes type and value */ - protected static function streetComponents(string $street, ?string $street2=null) + protected static function streetComponents(?string $street, ?string $street2=null) { - $components = [['type' => 'name', 'value' => $street]]; - - if (!empty($street2)) + $components = []; + foreach(func_get_args() as $street) { - $components[] = ['type' => 'separator', 'value' => "\n"]; - $components[] = ['type' => 'name', 'value' => $street2]; + if ($components) + { + $components[] = ['type' => 'separator', 'value' => "\n"]; + } + if (!empty($street)) + { + $components[] = ['type' => 'name', 'value' => $street]; + } } return $components; } @@ -968,7 +960,7 @@ class JsContact $vCard->setAttribute('UID',$list['list_uid']); */ - return json_encode($group, self::JSON_OPTIONS); + return Api\CalDAV::json_encode($group); } /**