Contacts REST API:

- implement missing PUT/POST of distribution list / CardGroups
- implement changes from next JsContact draft:
  + string plus extra localizations attribute instead of localizedString object
  + use "<domain>:<name>" for vendor attributes
- add/parse urn:uuid: prefix if UID is a UUID
This commit is contained in:
Ralf Becker 2021-09-20 16:01:22 +02:00
parent 0a75520a01
commit 82c8ed51d2
5 changed files with 243 additions and 76 deletions

View File

@ -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]))
{

View File

@ -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))
{

View File

@ -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);

View File

@ -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
*/

View File

@ -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/<username>/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/<username>/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"
],
},
"/<username>/addressbook/list-36": {
@ -96,7 +97,7 @@ curl https://example.org/egroupware/groupdav.php/<username>/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/<username>/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 ```<domain-name>:<name>``` like in JsCalendar