From 1280de46d614614e4ff8e9010c2525a7f30f8d27 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Fri, 24 Sep 2021 18:06:13 +0200 Subject: [PATCH] REST API allow sending a JSON patch to update or create a new contact (currently only via POST or PUT, not as PATCH!) allows eg. to create a contact from a simple Wordpress contact-form only supporting POST requests and a flat object, see new example in the documentation --- .../inc/class.addressbook_groupdav.inc.php | 2 +- api/src/Contacts/JsContact.php | 200 +++++++++++++----- doc/REST-CalDAV-CardDAV/README.md | 23 ++ 3 files changed, 174 insertions(+), 51 deletions(-) diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index aecac673ef..56dff82837 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -658,7 +658,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler } } $contact = $type === JsContact::MIME_TYPE_JSCARD ? - JsContact::parseJsCard($options['content']) : JsContact::parseJsCardGroup($options['content']); + JsContact::parseJsCard($options['content'], $oldContact ?: []) : JsContact::parseJsCardGroup($options['content']); if (!empty($id) && strpos($id, self::JS_CARDGROUP_ID_PREFIX) === 0) { diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index da494921cd..6e3038cf0e 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -79,15 +79,27 @@ class JsContact * Parse JsCard * * @param string $json - * @param bool $check_at_type true: check if objects have their proper @type attribute + * @param array $old=[] existing contact + * @param bool $strict true: check if objects have their proper @type attribute * @return array */ - public static function parseJsCard(string $json, bool $check_at_type=true) + public static function parseJsCard(string $json, array $old=[], bool $strict=true) { try { $data = json_decode($json, true, 10, JSON_THROW_ON_ERROR); + // check if we have a patch: keys contain slashes + if (array_filter(array_keys($data), static function ($key) + { + return strpos($key, '/') !== false; + })) + { + // apply patch on JsCard of contact + $data = self::patch($data, $old ? self::getJsCard($old, false) : [], !$old); + $strict = false; + } + if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist $contact = []; @@ -96,11 +108,11 @@ class JsContact switch ($name) { case 'uid': - $contact['uid'] = self::parseUid($value); + $contact['uid'] = self::parseUid($value, $old['uid'], !$strict); break; case 'name': - $contact += self::parseNameComponents($value, $check_at_type); + $contact += self::parseNameComponents($value, $strict); break; case 'fullName': @@ -108,41 +120,41 @@ class JsContact break; case 'organizations': - $contact += self::parseOrganizations($value, $check_at_type); + $contact += self::parseOrganizations($value, $strict); break; case 'titles': - $contact += self::parseTitles($value, $check_at_type); + $contact += self::parseTitles($value, $strict); break; case 'emails': - $contact += self::parseEmails($value, $check_at_type); + $contact += self::parseEmails($value, $strict); break; case 'phones': - $contact += self::parsePhones($value, $check_at_type); + $contact += self::parsePhones($value, $strict); break; case 'online': - $contact += self::parseOnline($value, $check_at_type); + $contact += self::parseOnline($value, $strict); break; case 'addresses': - $contact += self::parseAddresses($value, $check_at_type); + $contact += self::parseAddresses($value, $strict); break; case 'photos': - $contact += self::parsePhotos($value, $check_at_type); + $contact += self::parsePhotos($value, $strict); break; case 'anniversaries': - $contact += self::parseAnniversaries($value); + $contact += self::parseAnniversaries($value, $strict); break; case 'notes': $contact['note'] = implode("\n", array_map(static function ($note) { return self::parseString($note); - }, $value)); + }, (array)$value)); break; case 'categories': @@ -197,11 +209,12 @@ class JsContact * Parse and optionally generate UID * * @param string|null $uid + * @param string|null $old old value, if given it must NOT change * @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) + protected static function parseUid(string $uid=null, string $old=null, bool $generate_when_empty=false) { if (empty($uid) || strlen($uid) < 12) { @@ -211,7 +224,15 @@ class JsContact } $uid = \HTTP_WebDAV_Server::_new_uuid(); } - return strpos($uid, self::URN_UUID_PREFIX) === 0 ? substr($uid, 9) : $uid; + if (strpos($uid, self::URN_UUID_PREFIX) === 0) + { + $uid = substr($uid, strlen(self::URN_UUID_PREFIX)); + } + if (isset($old) && $old !== $uid) + { + throw new \InvalidArgumentException("You must NOT change the UID ('$old'): ".json_encode($uid)); + } + return $uid; } /** @@ -248,15 +269,15 @@ class JsContact * As we store only one organization, the rest get lost, multiple units get concatenated by space. * * @param array $orgas - * @param bool $check_at_type true: check if objects have their proper @type attribute + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseOrganizations(array $orgas, bool $check_at_type=true) + protected static function parseOrganizations(array $orgas, bool $stict=true) { $contact = []; foreach($orgas as $orga) { - if ($check_at_type && $orga[self::AT_TYPE] !== self::TYPE_ORGANIZATION) + if ($stict && $orga[self::AT_TYPE] !== self::TYPE_ORGANIZATION) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($orga, self::JSON_OPTIONS_ERROR)); } @@ -306,15 +327,19 @@ class JsContact * Parse titles, thought we only have "title" and "role" available for storage. * * @param array $titles - * @param bool $check_at_type true: check if objects have their proper @type attribute + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseTitles(array $titles, bool $check_at_type=true) + protected static function parseTitles(array $titles, bool $stict=true) { $contact = []; foreach($titles as $id => $title) { - if ($check_at_type && $title[self::AT_TYPE] !== self::TYPE_TITLE) + if (!$stict && is_string($title)) + { + $title = ['title' => $title]; + } + if ($stict && $title[self::AT_TYPE] !== self::TYPE_TITLE) { throw new \InvalidArgumentException("Missing or invalid @type: " . json_encode($title[self::AT_TYPE])); } @@ -526,21 +551,21 @@ class JsContact * Parse addresses object containing multiple addresses * * @param array $addresses - * @param bool $check_at_type true: check if objects have their proper @type attribute + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseAddresses(array $addresses, bool $check_at_type=true) + protected static function parseAddresses(array $addresses, bool $stict=true) { $n = 0; $last_type = null; $contact = []; foreach($addresses as $id => $address) { - if ($check_at_type && $address[self::AT_TYPE] !== self::TYPE_ADDRESS) + if ($stict && $address[self::AT_TYPE] !== self::TYPE_ADDRESS) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($address)); } - $contact += ($values=self::parseAddress($address, $id, $last_type)); + $contact += ($values=self::parseAddress($address, $id, $last_type, $stict)); if (++$n > 2) { @@ -567,9 +592,10 @@ class JsContact * @param array $address address-object * @param string $id index * @param ?string $last_type "work" or "home" + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseAddress(array $address, string $id, string &$last_type=null) + protected static function parseAddress(array $address, string $id, string &$last_type=null, bool $stict=true) { $type = !isset($last_type) && (empty($address['contexts']['private']) || $id === 'work') || $last_type === 'home' ? 'work' : 'home'; @@ -577,7 +603,7 @@ class JsContact $prefix = $type === 'work' ? 'adr_one_' : 'adr_two_'; $contact = [$prefix.'street' => null, $prefix.'street2' => null]; - list($contact[$prefix.'street'], $contact[$prefix.'street2']) = self::parseStreetComponents($address['street']); + list($contact[$prefix.'street'], $contact[$prefix.'street2']) = self::parseStreetComponents($address['street'], $stict); foreach(self::$jsAddress2attr+self::$jsAddress2workAttr as $js => $attr) { if (isset($address[$js]) && !is_string($address[$js])) @@ -637,12 +663,20 @@ class JsContact * As we have only 2 address-lines, we combine all components, with one space as separator, if none given. * Then we split it into 2 lines. * - * @param array $components - * @param bool $check_at_type true: check if objects have their proper @type attribute + * @param array|string $components string only for relaxed parsing + * @param bool $stict true: check if objects have their proper @type attribute * @return string[] street and street2 values */ - protected static function parseStreetComponents(array $components, bool $check_at_type=true) + protected static function parseStreetComponents($components, bool $stict=true) { + if (!$stict && is_string($components)) + { + $components = [['type' => 'name', 'value' => $components]]; + } + if (!is_array($components)) + { + throw new \InvalidArgumentException("Invalid street-components: ".json_encode($components, self::JSON_OPTIONS_ERROR)); + } $street = []; $last_type = null; foreach($components as $component) @@ -651,7 +685,7 @@ class JsContact { throw new \InvalidArgumentException("Invalid street-component: ".json_encode($component, self::JSON_OPTIONS_ERROR)); } - if ($check_at_type && $component[self::AT_TYPE] !== self::TYPE_STREET_COMPONENT) + if ($stict && $component[self::AT_TYPE] !== self::TYPE_STREET_COMPONENT) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($component, self::JSON_OPTIONS_ERROR)); } @@ -712,21 +746,25 @@ class JsContact * Parse phone objects * * @param array $phones $id => object with attribute "phone" and optional "features" and "context" - * @param bool $check_at_type true: check if objects have their proper @type attribute + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parsePhones(array $phones, bool $check_at_type=true) + protected static function parsePhones(array $phones, bool $stict=true) { $contact = []; // check for good matches foreach($phones as $id => $phone) { + if (!$stict && is_string($phone)) + { + $phone = ['phone' => $phone]; + } if (!is_array($phone) || !is_string($phone['phone'])) { throw new \InvalidArgumentException("Invalid phone: " . json_encode($phone, self::JSON_OPTIONS_ERROR)); } - if ($check_at_type && $phone[self::AT_TYPE] !== self::TYPE_PHONE) + if ($stict && $phone[self::AT_TYPE] !== self::TYPE_PHONE) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($phone, self::JSON_OPTIONS_ERROR)); } @@ -818,19 +856,23 @@ class JsContact * We currently only support 2 URLs, rest get's ignored! * * @param array $values - * @param bool $check_at_type true: check if objects have their proper @type attribute + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseOnline(array $values, bool $check_at_type) + protected static function parseOnline(array $values, bool $stict) { $contact = []; foreach($values as $id => $value) { + if (!$stict && is_string($value)) + { + $value = ['resource' => $value]; + } if (!is_array($value) || !is_string($value['resource'])) { throw new \InvalidArgumentException("Invalid online resource with id '$id': ".json_encode($value, self::JSON_OPTIONS_ERROR)); } - if ($check_at_type && $value[self::AT_TYPE] !== self::TYPE_RESOURCE) + if ($stict && $value[self::AT_TYPE] !== self::TYPE_RESOURCE) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($value, self::JSON_OPTIONS_ERROR)); } @@ -889,15 +931,19 @@ class JsContact * * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.3.1 * @param array $emails id => object with attribute "email" and optional "context" - * @param bool $check_at_type true: check if objects have their proper @type attribute + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseEmails(array $emails, bool $check_at_type=true) + protected static function parseEmails(array $emails, bool $stict=true) { $contact = []; foreach($emails as $id => $value) { - if ($check_at_type && $value[self::AT_TYPE] !== self::TYPE_EMAIL) + if (!$stict && is_string($value)) + { + $value = ['email' => $value]; + } + if ($stict && $value[self::AT_TYPE] !== self::TYPE_EMAIL) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($value, self::JSON_OPTIONS_ERROR)); } @@ -953,11 +999,11 @@ class JsContact * @return array * @ToDo */ - protected static function parsePhotos(array $photos, bool $check_at_type) + protected static function parsePhotos(array $photos, bool $stict) { foreach($photos as $id => $photo) { - if ($check_at_type && $photo[self::AT_TYPE] !== self::TYPE_FILE) + if ($stict && $photo[self::AT_TYPE] !== self::TYPE_FILE) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($photo, self::JSON_OPTIONS_ERROR)); } @@ -1008,18 +1054,23 @@ class JsContact * @param array $components * @return array */ - protected static function parseNameComponents(array $components, bool $check_at_type=true) + protected static function parseNameComponents(array $components, bool $stict=true) { $contact = array_combine(array_values(self::$nameType2attribute), array_fill(0, count(self::$nameType2attribute), null)); - foreach($components as $component) + foreach($components as $type => $component) { + // for relaxed checks, allow $type => $value pairs + if (!$stict && is_string($type) && is_scalar($component)) + { + $component = ['type' => $type, 'value' => $component]; + } if (empty($component['type']) || isset($component) && !is_string($component['value'])) { throw new \InvalidArgumentException("Invalid name-component (must have type and value attributes): ".json_encode($component, self::JSON_OPTIONS_ERROR)); } - if ($check_at_type && $component[self::AT_TYPE] !== self::TYPE_NAME_COMPONENT) + if ($stict && $component[self::AT_TYPE] !== self::TYPE_NAME_COMPONENT) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($component, self::JSON_OPTIONS_ERROR)); } @@ -1052,14 +1103,28 @@ class JsContact * * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.5.1 * @param array $anniversaries id => object with attribute date and optional type - * @param bool $check_at_type true: check if objects have their proper @type attribute + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseAnniversaries(array $anniversaries, bool $check_at_type=true) + protected static function parseAnniversaries(array $anniversaries, bool $stict=true) { $contact = []; foreach($anniversaries as $id => $anniversary) { + if (!$stict && is_string($anniversary)) + { + // allow German date format "dd.mm.yyyy" + if (preg_match('/^(\d+)\.(\d+).(\d+)$/', $anniversary, $matches)) + { + $matches = sprintf('%04d-%02d-%02d', (int)$matches[3], (int)$matches[2], (int)$matches[1]); + } + // allow US date format "mm/dd/yyyy" + elseif (preg_match('#^(\d+)/(\d+)/(\d+)$#', $anniversary, $matches)) + { + $matches = sprintf('%04d-%02d-%02d', (int)$matches[3], (int)$matches[1], (int)$matches[2]); + } + $anniversary = ['type' => $id, 'date' => $anniversary]; + } if (!is_array($anniversary) || !is_string($anniversary['date']) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $anniversary['date']) || (!list($year, $month, $day) = explode('-', $anniversary['date'])) || @@ -1067,7 +1132,7 @@ class JsContact { throw new \InvalidArgumentException("Invalid anniversary object with id '$id': ".json_encode($anniversary, self::JSON_OPTIONS_ERROR)); } - if ($check_at_type && $anniversary[self::AT_TYPE] !== self::TYPE_ANNIVERSARY) + if ($stict && $anniversary[self::AT_TYPE] !== self::TYPE_ANNIVERSARY) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($anniversary, self::JSON_OPTIONS_ERROR)); } @@ -1251,16 +1316,51 @@ class JsContact return $members; } + /** + * Patch JsCard + * + * @param array $patches JSON path + * @param array $jscard to patch + * @param bool $create =false true: create missing components + * @return array patched $jscard + */ + public static function patch(array $patches, array $jscard, bool $create=false) + { + foreach($patches as $path => $value) + { + $parts = explode('/', $path); + $target = &$jscard; + foreach($parts as $n => $part) + { + if (!isset($target[$part]) && $n < count($parts)-1 && !$create) + { + throw new \InvalidArgumentException("Trying to patch not existing attribute with path $path!"); + } + $parent = $target; + $target = &$target[$part]; + } + if (isset($value)) + { + $target = $value; + } + else + { + unset($parent[$part]); + } + } + return $jscard; + } + /** * Map all kind of exceptions while parsing to a JsContactParseException * * @param \Throwable $e * @param string $type - * @param string $name + * @param ?string $name * @param mixed $value * @throws JsContactParseException */ - protected static function handleExceptions(\Throwable $e, $type='JsContact', string $name, $value) + protected static function handleExceptions(\Throwable $e, $type='JsContact', ?string $name, $value) { try { throw $e; diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md index 8ad841ad9c..b173c4942d 100644 --- a/doc/REST-CalDAV-CardDAV/README.md +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -253,6 +253,29 @@ Location: https://example.org/egroupware/groupdav.php//addressbook/123 ``` +
+ Example: POST request to create a new resource using flat attributes (JSON patch syntax) eg. for a simple Wordpress contact-form + +``` +cat </addressbook/' -X POST -d @- -H "Content-Type: application/json" --user +{ + "name/personal": "First", + "name/surname": "Tester", + "organizations/org/name": "Test Organization", + "emails/work": "test.user@test-user.org", + "addresses/work/locality": "Test-Town", + "addresses/work/postcode": "12345", + "addresses/work/street": "Teststr. 123", + "phones/tel_work": "+49 123 4567890", + "online/url": "https://www.example.org/" +} +EOF + +HTTP/1.1 201 Created +Location: https://example.org/egroupware/groupdav.php//addressbook/1234 +``` +
+ * **PUT** requests with a ```Content-Type: application/json``` header allow modifying single resources * **DELETE** requests delete single resources