diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index 75f7bd2804..aecac673ef 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -61,6 +61,11 @@ class addressbook_groupdav extends Api\CalDAV\Handler */ var $home_set_pref; + /** + * Prefix for JsCardGroup id + */ + const JS_CARDGROUP_ID_PREFIX = 'list-'; + /** * Constructor * @@ -350,7 +355,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler { foreach($lists as $list) { - $list[self::$path_attr] = $is_jscontact ? 'list-'.$list['list_id'] : $list['list_carddav_name']; + $list[self::$path_attr] = $is_jscontact ? self::JS_CARDGROUP_ID_PREFIX.$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'])) @@ -637,9 +642,33 @@ class addressbook_groupdav extends Api\CalDAV\Handler return $oldContact; } - if (Api\CalDAV::isJSON()) + $type = null; + if (($is_json=Api\CalDAV::isJSON($type))) { - $contact = JsContact::parseJsCard($options['content']); + if (strpos($type, JsContact::MIME_TYPE_JSCARD) === false && strpos($type, JsContact::MIME_TYPE_JSCARDGROUP) === false) + { + if (!empty($id)) + { + $type = strpos($id, self::JS_CARDGROUP_ID_PREFIX) === 0 ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD; + } + else + { + $json = json_decode($options['content'], true); + $type = is_array($json['members']) ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD; + } + } + $contact = $type === JsContact::MIME_TYPE_JSCARD ? + JsContact::parseJsCard($options['content']) : JsContact::parseJsCardGroup($options['content']); + + if (!empty($id) && strpos($id, self::JS_CARDGROUP_ID_PREFIX) === 0) + { + $id = substr($id, strlen(self::JS_CARDGROUP_ID_PREFIX)); + } + elseif (empty($id)) + { + $contact['cardav_name'] = $contact['uid'].'.vcf'; + $contact['owner'] = $user; + } /* uncomment to return parsed data for testing header('Content-Type: application/json'); @@ -687,7 +716,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler $contactId = -1; $retval = '201 Created'; } - $is_group = $contact['##X-ADDRESSBOOKSERVER-KIND'] == 'group'; + $is_group = isset($type) && $type === JsContact::MIME_TYPE_JSCARDGROUP || $contact['##X-ADDRESSBOOKSERVER-KIND'] === 'group'; if ($oldContact && $is_group !== isset($oldContact['list_id'])) { throw new Api\Exception\AssertionFailed(__METHOD__."(,'$id',$user,'$prefix') can contact into group or visa-versa!"); @@ -756,7 +785,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler if ($this->http_if_match) $contact['etag'] = self::etag2value($this->http_if_match); // ignore photo for JSON/REST, it's not yet supported - $contact['photo_unchanged'] = Api\CalDAV::isJSON(); //false; // photo needs saving + $contact['photo_unchanged'] = $is_json; //false; // photo needs saving if (!($save_ok = $is_group ? $this->save_group($contact, $oldContact) : $this->bo->save($contact))) { if ($this->debug) error_log(__METHOD__."(,$id) save(".array2string($contact).") failed, Ok=$save_ok"); @@ -775,7 +804,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler { if (($contact = $this->bo->read_list($save_ok))) { - // re-read group to get correct etag (not dublicate etag code here) + // re-read group to get correct etag (not duplicate etag code here) $contact = $this->read($contact['list_'.self::$path_attr], $options['path']); } } @@ -787,14 +816,17 @@ class addressbook_groupdav extends Api\CalDAV\Handler } // send evtl. necessary response headers: Location, etag, ... - $this->put_response_headers($contact, $options['path'], $retval, self::$path_attr != 'id'); + $this->put_response_headers($contact, $options['path'], $retval, + // JSON uses 'id', while CardDAV uses carddav_name !== 'id' + (self::$path_attr !== 'id') === !$is_json, null, + $is_group && $is_json ? self::JS_CARDGROUP_ID_PREFIX : ''); if ($this->debug > 1) error_log(__METHOD__."(,'$id', $user, '$prefix') returning ".array2string($retval)); return $retval; } /** - * Save distribition-list / group + * Save distribution-list / group * * @param array $contact * @param array|false $oldContact @@ -813,18 +845,21 @@ class addressbook_groupdav extends Api\CalDAV\Handler $contact['owner'], null, $data))) { // update members given in $contact['##X-ADDRESSBOOKSERVER-MEMBER'] - $new_members = $contact['##X-ADDRESSBOOKSERVER-MEMBER']; - if ($new_members[1] == ':' && ($n = unserialize($new_members))) + $new_members = $contact['members'] ?: $contact['##X-ADDRESSBOOKSERVER-MEMBER']; + if (is_string($new_members) && $new_members[1] === ':' && ($n = unserialize($new_members))) { $new_members = $n['values']; } else { - $new_members = array($new_members); + $new_members = (array)$new_members; } foreach($new_members as &$uid) { - $uid = substr($uid,9); // cut off "urn:uuid:" prefix + if (substr($uid, 0, 9) === 'urn:uuid:') + { + $uid = substr($uid,9); // cut off "urn:uuid:" prefix + } } if ($oldContact) { @@ -861,7 +896,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler // reread as update of list-members updates etag and modified if (($contact = $this->bo->read_list($list_id))) { - // re-read group to get correct etag (not dublicate etag code here) + // re-read group to get correct etag (not duplicate etag code here) $contact = $this->read($contact['list_'.self::$path_attr]); } } @@ -1059,7 +1094,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler $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 (preg_match('/^(list-)?(\d+)$/', $id, $matches)) + if (preg_match('/^('.self::JS_CARDGROUP_ID_PREFIX.')?(\d+)$/', $id, $matches)) { if (!empty($matches[1])) { diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index a9e97289e4..5a0f0dc62d 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -996,9 +996,10 @@ class CalDAV extends HTTP_WebDAV_Server /** * Check if client want or sends JSON * + * @param string &$type=null * @return bool|string false: no json, true: application/json, string: application/(string)+json */ - public static function isJSON(string $type=null) + public static function isJSON(string &$type=null) { if (!isset($type)) { diff --git a/api/src/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index 440984de0f..6c360a769a 100644 --- a/api/src/CalDAV/Handler.php +++ b/api/src/CalDAV/Handler.php @@ -570,8 +570,9 @@ abstract class Handler * @param int|string $retval * @param boolean $path_attr_is_name =true true: path_attr is ca(l|rd)dav_name, false: id (GroupDAV needs Location header) * @param string $etag =null etag, to not calculate it again (if != null) + * @param string $prefix='' prefix for id */ - function put_response_headers($entry, $path, $retval, $path_attr_is_name=true, $etag=null) + function put_response_headers($entry, $path, $retval, $path_attr_is_name=true, $etag=null, string $prefix='') { //error_log(__METHOD__."(".array2string($entry).", '$path', ".array2string($retval).", path_attr_is_name=$path_attr_is_name, etag=".array2string($etag).")"); // we should not return an etag here, as EGroupware never stores ical/vcard byte-by-byte @@ -589,9 +590,9 @@ abstract class Handler // send Location header only on success AND if we dont use caldav_name as path-attribute or if ((is_bool($retval) ? $retval : $retval[0] === '2') && (!$path_attr_is_name || // POST with add-member query parameter - $_SERVER['REQUEST_METHOD'] == 'POST' && isset($_GET['add-member'])) || - // in case we choose to use a different name for the resourece, give the client a hint - basename($path) !== $this->new_id) + $_SERVER['REQUEST_METHOD'] == 'POST') || + // in case we choose to use a different name for the resource, give the client a hint + basename($path) !== $prefix.$this->new_id) { $path = preg_replace('|(.*)/[^/]*|', '\1/', $path); header('Location: '.$this->base_uri.$path.$this->new_id); diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index b6b0feeb94..a5d71b647e 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -41,14 +41,14 @@ class JsContact throw new Api\Exception\NotFound(); } $data = array_filter([ - 'uid' => $contact['uid'], + 'uid' => self::uid($contact['uid']), 'prodId' => 'EGroupware Addressbook '.$GLOBALS['egw_info']['apps']['api']['version'], 'created' => self::UTCDateTime($contact['created']), 'updated' => self::UTCDateTime($contact['modified']), //'kind' => '', // 'individual' or 'org' //'relatedTo' => [], 'name' => self::nameComponents($contact), - 'fullName' => self::localizedString($contact['n_fn']), + 'fullName' => $contact['n_fn'], //'nickNames' => [], 'organizations' => array_filter(['org' => self::organization($contact)]), 'titles' => self::titles($contact), @@ -74,11 +74,11 @@ class JsContact ]), 'photos' => self::photos($contact), 'anniversaries' => self::anniversaries($contact), - 'notes' => empty($contact['note']) ? null : [self::localizedString($contact['note'])], + 'notes' => empty($contact['note']) ? null : [$contact['note']], 'categories' => self::categories($contact['cat_id']), - 'egroupware.org/customfields' => self::customfields($contact), - 'egroupware.org/assistant' => $contact['assistent'], - 'egroupware.org/fileAs' => $contact['fileas'], + 'egroupware.org:customfields' => self::customfields($contact), + 'egroupware.org:assistant' => $contact['assistent'], + 'egroupware.org:fileAs' => $contact['fileas'], ]); if ($encode) { @@ -119,7 +119,7 @@ class JsContact break; case 'fullName': - $contact['n_fn'] = self::parseLocalizedString($value); + $contact['n_fn'] = self::parseString($value); break; case 'organizations': @@ -156,7 +156,7 @@ class JsContact case 'notes': $contact['note'] = implode("\n", array_map(static function ($note) { - return self::parseLocalizedString($note); + return self::parseString($note); }, $value)); break; @@ -164,15 +164,15 @@ class JsContact $contact['cat_id'] = self::parseCategories($value); break; - case 'egroupware.org/customfields': + case 'egroupware.org:customfields': $contact += self::parseCustomfields($value); break; - case 'egroupware.org/assistant': + case 'egroupware.org:assistant': $contact['assistent'] = $value; break; - case 'egroupware.org/fileAs': + case 'egroupware.org:fileAs': $contact['fileas'] = $value; break; @@ -188,28 +188,47 @@ class JsContact } } } - catch (\JsonException $e) { - throw new JsContactParseException("Error parsing JSON: ".$e->getMessage(), 422, $e); - } - catch (\InvalidArgumentException $e) { - throw new JsContactParseException("Error parsing JsContact field '$name': ". - str_replace('"', "'", $e->getMessage()), 422); - } - catch (\TypeError $e) { - $message = $e->getMessage(); - if (preg_match('/must be of the type ([^ ]+), ([^ ]+) given/', $message, $matches)) - { - $message = "$matches[1] expected, but got $matches[2]: ". - str_replace('"', "'", json_encode($value, self::JSON_OPTIONS_ERROR)); - } - throw new JsContactParseException("Error parsing JsContact field '$name': $message", 422, $e); - } catch (\Throwable $e) { - throw new JsContactParseException("Error parsing JsContact field '$name': ". $e->getMessage(), 422, $e); + self::handleExceptions($e, 'JsContact Card', $name, $value); } return $contact; } + const URN_UUID_PREFIX = 'urn:uuid:'; + const UUID_PREG = '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i'; + + /** + * Get UID with either "urn:uuid:" prefix for UUIDs or just the text + * + * @param string $uid + * @return string + */ + protected static function uid(string $uid) + { + return preg_match(self::UUID_PREG, $uid) ? self::URN_UUID_PREFIX.$uid : $uid; + } + + /** + * Parse and optionally generate UID + * + * @param string|null $uid + * @param bool $generate_when_empty true: generate UID if empty, false: throw error + * @return string without urn:uuid: prefix + * @throws \InvalidArgumentException + */ + protected static function parseUid(string $uid=null, $generate_when_empty=false) + { + if (empty($uid) || strlen($uid) < 12) + { + if (!$generate_when_empty) + { + throw new \InvalidArgumentException("Invalid or missing UID: ".json_encode($uid)); + } + $uid = \HTTP_WebDAV_Server::_new_uuid(); + } + return strpos($uid, self::URN_UUID_PREFIX) === 0 ? substr($uid, 9) : $uid; + } + /** * JSON options for errors thrown as exceptions */ @@ -229,8 +248,8 @@ class JsContact return null; // name is mandatory } return array_filter([ - 'name' => self::localizedString($contact['org_name']), - 'units' => empty($contact['org_unit']) ? null : ['org_unit' => self::localizedString($contact['org_unit'])], + 'name' => $contact['org_name'], + 'units' => empty($contact['org_unit']) ? null : ['org_unit' => $contact['org_unit']], ]); } @@ -247,10 +266,10 @@ class JsContact $contact = []; foreach($orgas as $orga) { - $contact['org_name'] = self::parseLocalizedString($orga['name']); + $contact['org_name'] = self::parseString($orga['name']); $contact['org_unit'] = implode(' ', array_map(static function($unit) { - return self::parseLocalizedString($unit); + return self::parseString($unit); }, $orga['units'])); break; } @@ -270,8 +289,8 @@ class JsContact protected static function titles(array $contact) { return array_filter([ - 'title' => self::localizedString($contact['title']), - 'role' => self::localizedString($contact['role']), + 'title' => $contact['title'], + 'role' => $contact['role'], ]); } @@ -286,21 +305,21 @@ class JsContact $contact = []; if (isset($titles[$id='title']) || isset($contact[$id='jobTitle'])) { - $contact['title'] = self::parseLocalizedString($titles[$id]); + $contact['title'] = self::parseString($titles[$id]); unset($titles[$id]); } if (isset($titles[$id='role'])) { - $contact['role'] = self::parseLocalizedString($titles[$id]); + $contact['role'] = self::parseString($titles[$id]); unset($titles[$id]); } if (!isset($contact['title']) && $titles) { - $contact['title'] = self::parseLocalizedString(array_shift($titles)); + $contact['title'] = self::parseString(array_shift($titles)); } if (!isset($contact['role']) && $titles) { - $contact['role'] = self::parseLocalizedString(array_shift($titles)); + $contact['role'] = self::parseString(array_shift($titles)); } if (count($titles)) { @@ -481,10 +500,10 @@ class JsContact 'street' => self::streetComponents($contact[$prefix.'street'], $contact[$prefix.'street2']), ]); // only add contexts and preference to non-empty address - return !$address ? [] : $address+[ + return !$address ? [] : array_filter($address+[ 'contexts' => [$type => true], 'pref' => $preference, - ]; + ]); } /** @@ -946,16 +965,12 @@ class JsContact * * We're not currently storing/allowing any localization --> they get ignored/thrown away! * - * @param array $value object with attribute "value" + * @param string $value =null * @return string */ - protected static function parseLocalizedString(array $value) + protected static function parseString(string $value=null) { - if (!is_string($value['value'])) - { - throw new \InvalidArgumentException("Invalid localizedString: ".json_encode($value, self::JSON_OPTIONS_ERROR)); - } - return $value['value']; + return $value; } /** @@ -996,10 +1011,10 @@ class JsContact throw new Api\Exception\NotFound(); } $data = array_filter([ - 'uid' => $group['list_uid'], + 'uid' => self::uid($group['list_uid']), 'name' => $group['list_name'], 'card' => self::getJsCard([ - 'uid' => $group['list_uid'], + 'uid' => self::uid($group['list_uid']), 'n_fn' => $group['list_name'], // --> fullName 'modified' => $group['list_modified'], // no other way to send modification date ], false), @@ -1007,7 +1022,7 @@ class JsContact ]); foreach($group['members'] as $uid) { - $data['members'][$uid] = true; + $data['members'][self::uid($uid)] = true; } if ($encode) { @@ -1016,6 +1031,116 @@ class JsContact return $data; } + /** + * Parse JsCard + * + * @param string $json + * @return array + */ + public static function parseJsCardGroup(string $json) + { + try + { + $data = json_decode($json, true, 10, JSON_THROW_ON_ERROR); + + if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist + + // make sure missing mandatory members give an error + $data += ['uid' => null, 'members' => null]; + $group = []; + foreach ($data as $name => $value) + { + switch ($name) + { + case 'uid': + $group['uid'] = self::parseUid($value); + break; + + case 'name': + $group['n_fn'] = $value; + break; + + case 'card': + $card = self::parseJsCard(json_encode($value, self::JSON_OPTIONS_ERROR)); + // prefer name over card-fullName + if (!empty($card['n_fn']) && empty($group['n_fn'])) + { + $group['n_fn'] = $card['n_fn']; + } + break; + + case 'members': + $group['members'] = self::parseMembers($value); + break; + + default: + error_log(__METHOD__ . "() $name=" . json_encode($value, self::JSON_OPTIONS_ERROR) . ' --> ignored'); + break; + } + } + } + catch (\Throwable $e) { + self::handleExceptions($e, 'JsContact CardGroup', $name, $value); + } + return $group; + } + + /** + * Parse members object + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-3.1.2 + * @param array $values uid => true pairs + * @return array of uid's + */ + protected static function parseMembers(array $values) + { + $members = []; + foreach($values as $uid => $value) + { + if (!is_string($uid) || $value !== true) + { + throw new \InvalidArgumentException('Invalid members object: '.json_encode($values, self::JSON_OPTIONS_ERROR)); + } + $members[] = self::parseUid($uid); + } + return $members; + } + + /** + * Map all kind of exceptions while parsing to a JsContactParseException + * + * @param \Throwable $e + * @param string $type + * @param string $name + * @param mixed $value + * @throws JsContactParseException + */ + protected static function handleExceptions(\Throwable $e, $type='JsContact', string $name, $value) + { + try { + throw $e; + } + catch (\JsonException $e) { + throw new JsContactParseException("Error parsing JSON: ".$e->getMessage(), 422, $e); + } + catch (\InvalidArgumentException $e) { + throw new JsContactParseException("Error parsing $type attribute '$name': ". + str_replace('"', "'", $e->getMessage()), 422); + } + catch (\TypeError $e) { + $message = $e->getMessage(); + if (preg_match('/must be of the type ([^ ]+( or [^ ]+)*), ([^ ]+) given/', $message, $matches)) + { + $message = "$matches[1] expected, but got $matches[3]: ". + str_replace('"', "'", json_encode($value, self::JSON_OPTIONS_ERROR)); + } + throw new JsContactParseException("Error parsing $type attribute '$name': $message", 422, $e); + } + catch (\Throwable $e) { + throw new JsContactParseException("Error parsing $type attribute '$name': ". $e->getMessage(), 422, $e); + } + } + /** * @return Api\Contacts */ diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md index 695f21ae86..9100633cf1 100644 --- a/doc/REST-CalDAV-CardDAV/README.md +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -41,7 +41,8 @@ from the data of a allprop PROPFIND, allow browsing CalDAV/CardDAV tree with a r > currently implemented only for contacts! Following RFCs / drafts used/planned for JSON encoding of ressources -* [draft-ietf-jmap-jscontact: JSContact: A JSON Representation of Contact Data](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) +* [draft-ietf-jmap-jscontact: JSContact: A JSON Representation of Contact Data](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) ([*](#implemented-changes-to-jscontact-draft-07-from-next-draft)) +* [draft-ietf-jmap-jscontact-vcard-06: JSContact: Converting from and to vCard](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-vcard/) * [rfc8984: JSCalendar: A JSON Representation of Calendar Data](https://datatracker.ietf.org/doc/html/rfc8984) ### Supported request methods and examples @@ -66,9 +67,9 @@ curl https://example.org/egroupware/groupdav.php//addressbook/ -H "Acc "fullName": { "value": "Default Tester" }, "organizations": { "org": { - "name": { "value": "default.org" }, + "name": "default.org", "units": { - "org_unit": { "value": "department.default.org" } + "org_unit": "department.default.org" } } }, @@ -83,7 +84,7 @@ curl https://example.org/egroupware/groupdav.php//addressbook/ -H "Acc "url": { "resource": "https://www.test.com/", "type": "uri", "contexts": { "work": true } } }, "notes": [ - { "value": "Test test TEST\n\\server\\share\n\\\nother\nblah" } + "Test test TEST\n\\server\\share\n\\\nother\nblah" ], }, "//addressbook/list-36": { @@ -96,7 +97,7 @@ curl https://example.org/egroupware/groupdav.php//addressbook/ -H "Acc "fullName": { "value": "Example distribution list" } }, "members": { - "5638-8623c4830472a8ede9f9f8b30d435ea4": true + "urn:uuid:5638-8623c4830472a8ede9f9f8b30d435ea4": true } } } @@ -194,7 +195,7 @@ curl 'https://example.org/egroupware/groupdav.php/addressbook/?sync-token=https: { "type": "personal", "value": "Default" }, { "type": "surname", "value": "Tester" } ], - "fullName": { "value": "Default Tester" }, + "fullName": "Default Tester", .... } }, @@ -218,7 +219,7 @@ curl 'https://example.org/egroupware/groupdav.php/addressbook/5593' -H "Accept: { "type": "personal", "value": "Default" }, { "type": "surname", "value": "Tester" } ], - "fullName": { "value": "Default Tester" }, + "fullName": "Default Tester", .... } ``` @@ -255,3 +256,7 @@ Location: https://example.org/egroupware/groupdav.php//addressbook/123 * **DELETE** requests delete single resources * one can use ```Accept: application/pretty+json``` to receive pretty-printed JSON eg. for debugging and exploring the API + +#### Implemented changes to [JsContact draft 07](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) from next draft: +* localizedString type / object is removed in favor or regular String type and a [localizations object like in JsCalendar](https://datatracker.ietf.org/doc/html/rfc8984#section-4.6.1) +* [Vendor-specific Property Extensions and Values](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-1.3) use ```:``` like in JsCalendar \ No newline at end of file