From e9998161a58c99db5298a1629421ee0603945b72 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Sun, 19 Sep 2021 11:09:44 +0200 Subject: [PATCH] finished REST API for contacts modulo docu and bugs ;) - JsCardGroup now used for distribution lists - responses are not in "responses" attribute (no longer in root of object) - fix sometimes empty / different members between PROPFIND/REPORT/JSON-GET and GET of group (caused by wrongly implemented limit to given AB) - JSON pretty-print only if requested by Accept: application/pretty+json - fix invalid JSON for errors (caused by opening {"responses": already sent --- .../inc/class.addressbook_groupdav.inc.php | 33 +++++++---- api/src/CalDAV.php | 42 +++++++------- api/src/CalDAV/Handler.php | 2 +- api/src/Contacts/JsContact.php | 55 ++++++++++--------- api/src/Contacts/Sql.php | 6 +- api/src/Contacts/Storage.php | 3 +- 6 files changed, 79 insertions(+), 62 deletions(-) diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index 43936b4928..75f7bd2804 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -350,7 +350,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler { foreach($lists as $list) { - $list[self::$path_attr] = $list['list_carddav_name']; + $list[self::$path_attr] = $is_jscontact ? 'list-'.$list['list_id'] : $list['list_carddav_name']; $etag = $list['list_id'].':'.$list['list_etag']; // for all-in-one addressbook, add selected ABs to etag if (isset($filter['owner']) && is_array($filter['owner'])) @@ -365,7 +365,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler ); if ($address_data) { - $content = $is_jscontact ? JsContact::getJsCardGroup($list) : $handler->getGroupVCard($list); + $content = $is_jscontact ? JsContact::getJsCardGroup($list, false) : $handler->getGroupVCard($list); $props['getcontentlength'] = bytes($content); $props['address-data'] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content); } @@ -597,10 +597,10 @@ class addressbook_groupdav extends Api\CalDAV\Handler return $contact; } // jsContact or vCard - if (Api\CalDAV::isJSON()) + if (($type=Api\CalDAV::isJSON())) { - $options['data'] = $contact['list_id'] ? JsContact::getJsCardGroup($contact) : - JsContact::getJsCard($contact); + $options['data'] = $contact['list_id'] ? JsContact::getJsCardGroup($contact, $type) : + JsContact::getJsCard($contact, $type); $options['mimetype'] = ($contact['list_id'] ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD).';charset=utf-8'; } @@ -1057,16 +1057,24 @@ class addressbook_groupdav extends Api\CalDAV\Handler $non_deleted_tids = array_keys($tids); } $keys = ['tid' => $non_deleted_tids]; + // with REST/JSON we only use our id, but DELETE request has neither Accept nor Content-Type header to detect JSON request - if ((string)$id === (string)(int)$id) + if (preg_match('/^(list-)?(\d+)$/', $id, $matches)) { - $keys['id'] = $id; + if (!empty($matches[1])) + { + $keys = ['list_id' => $matches[2]]; + } + else + { + $keys['id'] = $id; + } } else { $keys[self::$path_attr] = $id; } - $contact = $this->bo->read($keys); + $contact = isset($keys['list_id']) ? false: $this->bo->read($keys); // if contact not found and accounts stored NOT like contacts, try reading it without path-extension as id if (is_null($contact) && $this->bo->so_accounts && ($c = $this->bo->read($test=basename($id, '.vcf')))) @@ -1086,12 +1094,13 @@ class addressbook_groupdav extends Api\CalDAV\Handler $limit_in_ab[] = $GLOBALS['egw_info']['user']['account_id']; } /* we are currently not syncing distribution-lists/groups to /addressbook/ as - * Apple clients use that only as directory gateway - elseif ($account_lid == 'addressbook') // /addressbook/ contains all readably contacts + * Apple clients use that only as directory gateway*/ + elseif (Api\CalDAV::isJSON() && $account_lid == 'addressbook') // /addressbook/ contains all readably contacts { $limit_in_ab = array_keys($this->bo->grants); - }*/ - if (!$contact && ($contact = $this->bo->read_lists(array('list_'.self::$path_attr => $id),'contact_uid',$limit_in_ab))) + } + if (!$contact && ($contact = $this->bo->read_lists(isset($keys['list_id']) ? $keys : + ['list_'.self::$path_attr => $id],'contact_uid',$limit_in_ab))) { $contact = array_shift($contact); $contact['n_fn'] = $contact['n_family'] = $contact['list_name']; diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index 74efce702c..a9e97289e4 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -994,9 +994,9 @@ class CalDAV extends HTTP_WebDAV_Server } /** - * Check if clients want's or sends JSON + * Check if client want or sends JSON * - * @return bool + * @return bool|string false: no json, true: application/json, string: application/(string)+json */ public static function isJSON(string $type=null) { @@ -1005,7 +1005,8 @@ class CalDAV extends HTTP_WebDAV_Server $type = in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'POST', 'PROPPATCH']) ? $_SERVER['HTTP_CONTENT_TYPE'] : $_SERVER['HTTP_ACCEPT']; } - return (bool)preg_match('#application/([^+ ;]+\+)?json#', $type); + return preg_match('#application/(([^+ ;]+)\+)?json#', $type, $matches) ? + (empty($matches[1]) ? true : $matches[2]) : false; } /** @@ -1021,9 +1022,9 @@ class CalDAV extends HTTP_WebDAV_Server $id = $app = $user = null; if (!$this->_parse_path($options['path'],$id,$app,$user) || $app == 'principals') { - if (self::isJSON()) + if (($json = self::isJSON())) { - return $this->jsonIndex($options); + return $this->jsonIndex($options, $json === 'pretty'); } return $this->autoindex($options); } @@ -1048,7 +1049,7 @@ class CalDAV extends HTTP_WebDAV_Server { if (!$pretty) { - return self::json_encode($data, self::JSON_OPTIONS); + return json_encode($data, self::JSON_OPTIONS); } return preg_replace('/: {\n\s*(.*?)\n\s*(},?\n)/', ': { $1 $2', json_encode($data, self::JSON_OPTIONS_PRETTY)); @@ -1066,9 +1067,10 @@ class CalDAV extends HTTP_WebDAV_Server * } * * @param array $options + * @param bool $pretty =false true: pretty-print JSON * @return bool|string|void */ - protected function jsonIndex(array $options) + protected function jsonIndex(array $options, bool $pretty) { header('Content-Type: application/json; charset=utf-8'); $is_addressbook = strpos($options['path'], '/addressbook') !== false; @@ -1118,9 +1120,8 @@ class CalDAV extends HTTP_WebDAV_Server { return $ret; // no collection } - - echo "{\n"; - $prefix = " "; + // set start as prefix, to no have it in front of exceptions + $prefix = "{\n\t\"responses\": {\n"; foreach($files['files'] as $resource) { $path = $resource['path']; @@ -1129,10 +1130,6 @@ class CalDAV extends HTTP_WebDAV_Server { 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'] : @@ -1146,17 +1143,24 @@ class CalDAV extends HTTP_WebDAV_Server { $props = current($props)['val']; } - echo self::json_encode($props); + echo self::json_encode($props, $pretty); } - $prefix = ",\n "; + $prefix = ",\n"; } - // add sync-token to response + // happens with an empty response + if ($prefix !== ",\n") + { + echo $prefix; + $prefix = ",\n"; + } + echo "\n\t}"; + // add sync-token and more-results to response if (isset($files['sync-token'])) { - echo $prefix.'"sync-token": '.json_encode(!is_callable($files['sync-token']) ? $files['sync-token'] : + echo $prefix."\t".'"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"; + echo "\n}"; // exit now, so WebDAV::GET does NOT add Content-Type: application/octet-stream exit; diff --git a/api/src/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index 092c995d83..440984de0f 100644 --- a/api/src/CalDAV/Handler.php +++ b/api/src/CalDAV/Handler.php @@ -714,7 +714,7 @@ abstract class Handler { if (Api\CalDAV::isJSON()) { - $error = ",\n".' "more-results": true'; + $error = ",\n\t".'"more-results": true'; } else { diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index 66599fcb86..b6b0feeb94 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -30,7 +30,7 @@ class JsContact * Get jsCard for given contact * * @param int|array $contact - * @param bool $encode=true true: JSON encode, false: return raw data eg. from listing + * @param bool|"pretty" $encode=true true: JSON encode, "pretty": JSON encode with pretty-print, false: return raw data eg. from listing * @return string|array * @throws Api\Exception\NotFound */ @@ -68,13 +68,13 @@ class JsContact 'url' => !empty($contact['url']) ? ['resource' => $contact['url'], 'type' => 'uri', 'contexts' => ['work' => true]] : null, 'url_home' => !empty($contact['url_home']) ? ['resource' => $contact['url_home'], 'type' => 'uri', 'contexts' => ['private' => true]] : null, ]), - 'addresses' => [ + 'addresses' => array_filter([ 'work' => self::address($contact, 'work', 1), // as it's the more prominent in our UI 'home' => self::address($contact, 'home'), - ], + ]), 'photos' => self::photos($contact), 'anniversaries' => self::anniversaries($contact), - 'notes' => [self::localizedString($contact['note'])], + 'notes' => empty($contact['note']) ? null : [self::localizedString($contact['note'])], 'categories' => self::categories($contact['cat_id']), 'egroupware.org/customfields' => self::customfields($contact), 'egroupware.org/assistant' => $contact['assistent'], @@ -82,7 +82,7 @@ class JsContact ]); if ($encode) { - return Api\CalDAV::json_encode($data, self::JSON_OPTIONS_ERROR); + return Api\CalDAV::json_encode($data, $encode === "pretty"); } return $data; } @@ -474,16 +474,17 @@ class JsContact $js2attr = self::$jsAddress2attr; if ($type === 'work') $js2attr += self::$jsAddress2workAttr; - $address = array_map(static function($attr) use ($contact, $prefix) + $address = array_filter(array_map(static function($attr) use ($contact, $prefix) { return $contact[$prefix.$attr]; }, $js2attr) + [ 'street' => self::streetComponents($contact[$prefix.'street'], $contact[$prefix.'street2']), + ]); + // only add contexts and preference to non-empty address + return !$address ? [] : $address+[ 'contexts' => [$type => true], 'pref' => $preference, ]; - - return array_filter($address); } /** @@ -984,33 +985,35 @@ class JsContact * Get jsCardGroup for given group * * @param int|array $group - * @return string + * @param bool|"pretty" $encode=true true: JSON, "pretty": JSON pretty-print, false: array + * @return array|string * @throws Api\Exception\NotFound */ - public static function getJsCardGroup($group) + public static function getJsCardGroup($group, $encode=true) { if (is_scalar($group) && !($group = self::getContacts()->read_lists($group))) { throw new Api\Exception\NotFound(); } - /* - $vCard = new Horde_Icalendar_Vcard($version); - $vCard->setAttribute('PRODID','-//EGroupware//NONSGML EGroupware Addressbook '.$GLOBALS['egw_info']['apps']['api']['version'].'//'. - strtoupper($GLOBALS['egw_info']['user']['preferences']['common']['lang'])); - - $vCard->setAttribute('N',$list['list_name'],array(),true,array($list['list_name'],'','','','')); - $vCard->setAttribute('FN',$list['list_name']); - - $vCard->setAttribute('X-ADDRESSBOOKSERVER-KIND','group'); - foreach($list['members'] as $uid) + $data = array_filter([ + 'uid' => $group['list_uid'], + 'name' => $group['list_name'], + 'card' => self::getJsCard([ + 'uid' => $group['list_uid'], + 'n_fn' => $group['list_name'], // --> fullName + 'modified' => $group['list_modified'], // no other way to send modification date + ], false), + 'members' => [], + ]); + foreach($group['members'] as $uid) { - $vCard->setAttribute('X-ADDRESSBOOKSERVER-MEMBER','urn:uuid:'.$uid); + $data['members'][$uid] = true; } - $vCard->setAttribute('REV',Api\DateTime::to($list['list_modified'],'Y-m-d\TH:i:s\Z')); - $vCard->setAttribute('UID',$list['list_uid']); - */ - - return Api\CalDAV::json_encode($group, self::JSON_OPTIONS_ERROR); + if ($encode) + { + $data = Api\CalDAV::json_encode($data, $encode === 'pretty'); + } + return $data; } /** diff --git a/api/src/Contacts/Sql.php b/api/src/Contacts/Sql.php index 0714ea806b..481d2b9fb0 100644 --- a/api/src/Contacts/Sql.php +++ b/api/src/Contacts/Sql.php @@ -810,14 +810,14 @@ class Sql extends Api\Storage { if ($limit_in_ab) { - $in_ab_join = " JOIN $this->lists_table ON $this->lists_table.list_id=$this->ab2list_table.list_id AND $this->lists_table."; + $in_ab_join = " JOIN $this->lists_table ON $this->lists_table.list_id=$this->ab2list_table.list_id AND "; if (!is_bool($limit_in_ab)) { - $in_ab_join .= $this->db->expression($this->lists_table, array('list_owner'=>$limit_in_ab)); + $in_ab_join .= $this->db->expression($this->table_name, $this->table_name.'.', ['contact_owner' => $limit_in_ab]); } else { - $in_ab_join .= "list_owner=$this->table_name.contact_owner"; + $in_ab_join .= "$this->lists_table.list_owner=$this->table_name.contact_owner"; } } foreach($this->db->select($this->ab2list_table,"$this->ab2list_table.list_id,$this->table_name.$member_attr", diff --git a/api/src/Contacts/Storage.php b/api/src/Contacts/Storage.php index 5ea1b43f3a..0cb66f4b6d 100755 --- a/api/src/Contacts/Storage.php +++ b/api/src/Contacts/Storage.php @@ -1209,7 +1209,8 @@ class Storage * * @param array $keys column-name => value(s) pairs, eg. array('list_uid'=>$uid) * @param string $member_attr ='contact_uid' null: no members, 'contact_uid', 'contact_id', 'caldav_name' return members as that attribute - * @param boolean $limit_in_ab =false if true only return members from the same owners addressbook + * @param boolean|int|array $limit_in_ab =false if true only return members from the same owners addressbook, + * if int|array only return members from the given owners addressbook(s) * @return array with list_id => array(list_id,list_name,list_owner,...) pairs */ function read_lists($keys,$member_attr=null,$limit_in_ab=false)