From 3bc015a90d35d40c68fdb93786569ae51dddd43c Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Fri, 17 Sep 2021 20:15:36 +0200 Subject: [PATCH] got POST, PUT and DELETE request to add, update and delete contacts working added JSON exception handler with nicer JsCalendar parse errors --- .../inc/class.addressbook_groupdav.inc.php | 25 +- api/src/CalDAV.php | 63 ++++- api/src/Contacts/JsContact.php | 244 +++++++++++------- api/src/Contacts/JsContactParseException.php | 27 ++ 4 files changed, 244 insertions(+), 115 deletions(-) create mode 100644 api/src/Contacts/JsContactParseException.php diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index f3d2db8406..43936b4928 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -599,8 +599,10 @@ class addressbook_groupdav extends Api\CalDAV\Handler // jsContact or vCard 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; + $options['data'] = $contact['list_id'] ? JsContact::getJsCardGroup($contact) : + JsContact::getJsCard($contact); + $options['mimetype'] = ($contact['list_id'] ? JsContact::MIME_TYPE_JSCARDGROUP : + JsContact::MIME_TYPE_JSCARD).';charset=utf-8'; } else { @@ -638,10 +640,12 @@ class addressbook_groupdav extends Api\CalDAV\Handler if (Api\CalDAV::isJSON()) { $contact = JsContact::parseJsCard($options['content']); - // just output it again for now + + /* uncomment to return parsed data for testing header('Content-Type: application/json'); echo json_encode($contact, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); return "200 Ok"; + */ } else { @@ -751,7 +755,8 @@ class addressbook_groupdav extends Api\CalDAV\Handler } if ($this->http_if_match) $contact['etag'] = self::etag2value($this->http_if_match); - $contact['photo_unchanged'] = false; // photo needs saving + // ignore photo for JSON/REST, it's not yet supported + $contact['photo_unchanged'] = Api\CalDAV::isJSON(); //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"); @@ -1051,7 +1056,17 @@ class addressbook_groupdav extends Api\CalDAV\Handler unset($tids[Api\Contacts::DELETED_TYPE]); $non_deleted_tids = array_keys($tids); } - $contact = $this->bo->read(array(self::$path_attr => $id, 'tid' => $non_deleted_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) + { + $keys['id'] = $id; + } + else + { + $keys[self::$path_attr] = $id; + } + $contact = $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')))) diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index 9073b17c4b..74efce702c 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -19,16 +19,16 @@ use EGroupware\Api\CalDAV\Principals; // explicit import non-namespaced classes require_once(__DIR__.'/WebDAV/Server.php'); -use EGroupware\Api\Contacts\JsContact; +use EGroupware\Api\Contacts\JsContactParseException; use HTTP_WebDAV_Server; use calendar_hooks; /** - * EGroupware: GroupDAV access + * EGroupware: CalDAV/CardDAV server * * Using a modified PEAR HTTP/WebDAV/Server class from API! * - * One can use the following url's releative (!) to http://domain.com/egroupware/groupdav.php + * One can use the following URLs relative (!) to https://example.org/egroupware/groupdav.php * * - / base of Cal|Card|GroupDAV tree, only certain clients (KDE, Apple) can autodetect folders from here * - /principals/ principal-collection-set for WebDAV ACL @@ -51,10 +51,25 @@ use calendar_hooks; * - /(resources|locations)//calendar calendar of a resource/location, if user has rights to view * - //(resource|location)- shared calendar from a resource/location * - * Shared addressbooks or calendars are only shown in in users home-set, if he subscribed to it via his CalDAV preferences! + * Shared addressbooks or calendars are only shown in the users home-set, if he subscribed to it via his CalDAV preferences! * * Calling one of the above collections with a GET request / regular browser generates an automatic index - * from the data of a allprop PROPFIND, allow to browse CalDAV/CardDAV/GroupDAV tree with a regular browser. + * from the data of a allprop PROPFIND, allow browsing CalDAV/CardDAV tree with a regular browser. + * + * Using EGroupware CalDAV/CardDAV as REST API: currently only for contacts + * =========================================== + * GET requests to collections with an "Accept: application/json" header return a JSON response similar to a PROPFIND + * following GET parameters are supported to customize the returned properties: + * - props[]= eg. props[]=getetag to return only the ETAG (multiple DAV properties can be specified) + * Default for addressbook collections is to only return address-data (JsContact), other collections return all props. + * - sync-token= to only request change since last sync-token, like rfc6578 sync-collection REPORT + * - nresults=N limit number of responses (only for sync-collection / given sync-token parameter!) + * this will return a "more-results"=true attribute and a new "sync-token" attribute to query for the next chunk + * POST requests to collection with a "Content-Type: application/json" header add new entries in addressbook or calendar collections + * (Location header in response gives URL of new resource) + * GET requests with an "Accept: application/json" header can be used to retrieve single resources / JsContact or JsCalendar schema + * PUT requests with a "Content-Type: application/json" header allow modifying single resources + * DELETE requests delete single resources * * Permanent error_log() calls should use groupdav->log($str) instead, to be send to PHP error_log() * and our request-log (prefixed with "### " after request and response, like exceptions). @@ -1403,7 +1418,8 @@ class CalDAV extends HTTP_WebDAV_Server { // for some reason OS X Addressbook (CFNetwork user-agent) uses now (DAV:add-member given with collection URL+"?add-member") // POST to the collection URL plus a UID like name component (like for regular PUT) to create new entrys - if (isset($_GET['add-member']) || Handler::get_agent() == 'cfnetwork') + if (isset($_GET['add-member']) || Handler::get_agent() == 'cfnetwork' || + substr($options['path'], -1) === '/' && self::isJSON()) { $_GET['add-member'] = ''; // otherwise we give no Location header return $this->PUT($options); @@ -2421,16 +2437,37 @@ class CalDAV extends HTTP_WebDAV_Server $headline = null; _egw_log_exception($e,$headline); - // exception handler sending message back to the client as basic auth message - $error = str_replace(array("\r", "\n"), array('', ' | '), $e->getMessage()); - header('WWW-Authenticate: Basic realm="'.$headline.': '.$error.'"'); - header('HTTP/1.1 401 Unauthorized'); - header('X-WebDAV-Status: 401 Unauthorized', true); - + if (self::isJSON()) + { + header('Content-Type: application/json; charset=utf-8'); + if (is_a($e, JsContactParseException::class)) + { + $status = '422 Unprocessable Entity'; + } + else + { + $status = '500 Internal Server Error'; + } + http_response_code((int)$status); + echo self::json_encode([ + 'error' => $e->getCode() ?: (int)$status, + 'message' => $e->getMessage(), + ]+($e->getPrevious() ? [ + 'original' => get_class($e->getPrevious()).': '.$e->getPrevious()->getMessage(), + ] : []), JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + } + else + { + // exception handler sending message back to the client as basic auth message + $error = str_replace(array("\r", "\n"), array('', ' | '), $e->getMessage()); + header('WWW-Authenticate: Basic realm="' . $headline . ': ' . $error . '"'); + header('HTTP/1.1 401 Unauthorized'); + header('X-WebDAV-Status: 401 Unauthorized', true); + } // if our own logging is active, log the request plus a trace, if enabled in server-config if (self::$request_starttime && isset(self::$instance)) { - self::$instance->_http_status = '401 Unauthorized'; // to correctly log it + self::$instance->_http_status = self::isJSON() ? $status : '401 Unauthorized'; // to correctly log it if ($GLOBALS['egw_info']['server']['exception_show_trace']) { self::$instance->log_request("\n".$e->getTraceAsString()."\n"); diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index ac2be3a756..66599fcb86 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -53,8 +53,15 @@ class JsContact 'organizations' => array_filter(['org' => self::organization($contact)]), 'titles' => self::titles($contact), 'emails' => array_filter([ - 'work' => empty($contact['email']) ? null : ['email' => $contact['email']], - 'private' => empty($contact['email_home']) ? null : ['email' => $contact['email_home']], + 'work' => empty($contact['email']) ? null : [ + 'email' => $contact['email'], + 'contexts' => ['work' => true], + 'pref' => 1, // as it's the more prominent in our UI + ], + 'private' => empty($contact['email_home']) ? null : [ + 'email' => $contact['email_home'], + 'contexts' => ['private' => true], + ], ]), 'phones' => self::phones($contact), 'online' => array_filter([ @@ -62,7 +69,7 @@ class JsContact 'url_home' => !empty($contact['url_home']) ? ['resource' => $contact['url_home'], 'type' => 'uri', 'contexts' => ['private' => true]] : null, ]), 'addresses' => [ - 'work' => self::address($contact, 'work', 1), + 'work' => self::address($contact, 'work', 1), // as it's the more prominent in our UI 'home' => self::address($contact, 'home'), ], 'photos' => self::photos($contact), @@ -75,7 +82,7 @@ class JsContact ]); if ($encode) { - return Api\CalDAV::json_encode($data); + return Api\CalDAV::json_encode($data, self::JSON_OPTIONS_ERROR); } return $data; } @@ -88,97 +95,126 @@ class JsContact */ public static function parseJsCard(string $json) { - $data = json_decode($json, JSON_THROW_ON_ERROR); - - if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist - - $contact = []; - foreach($data as $name => $value) + try { - switch($name) + $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 + + $contact = []; + foreach ($data as $name => $value) { - case 'uid': - if (!is_string($value) || empty($value)) - { - throw new \InvalidArgumentException("Invalid uid value!"); - } - $contact['uid'] = $value; - break; + switch ($name) + { + case 'uid': + if (!is_string($value) || empty($value)) + { + throw new \InvalidArgumentException("Missing or invalid uid value!"); + } + $contact['uid'] = $value; + break; - case 'name': - $contact += self::parseNameComponents($value); - break; + case 'name': + $contact += self::parseNameComponents($value); + break; - case 'fullName': - $contact['n_fn'] = self::parseLocalizedString($value); - break; + case 'fullName': + $contact['n_fn'] = self::parseLocalizedString($value); + break; - case 'organizations': - $contact += self::parseOrganizations($value); - break; + case 'organizations': + $contact += self::parseOrganizations($value); + break; - case 'titles': - $contact += self::parseTitles($value); - break; + case 'titles': + $contact += self::parseTitles($value); + break; - case 'emails': - $contact += self::parseEmails($value); - break; + case 'emails': + $contact += self::parseEmails($value); + break; - case 'phones': - $contact += self::parsePhones($value); - break; + case 'phones': + $contact += self::parsePhones($value); + break; - case 'online': - $contact += self::parseOnline($value); - break; + case 'online': + $contact += self::parseOnline($value); + break; - case 'addresses': - $contact += self::parseAddresses($value); - break; + case 'addresses': + $contact += self::parseAddresses($value); + break; - case 'photos': - $contact += self::parsePhotos($value); - break; + case 'photos': + $contact += self::parsePhotos($value); + break; - case 'anniversaries': - $contact += self::parseAnniversaries($value); - break; + case 'anniversaries': + $contact += self::parseAnniversaries($value); + break; - case 'notes': - $contact['note'] = implode("\n", array_map(static function($note) - { - return self::parseLocalizedString($note); - }, $value)); - break; + case 'notes': + $contact['note'] = implode("\n", array_map(static function ($note) { + return self::parseLocalizedString($note); + }, $value)); + break; - case 'categories': - $contact['cat_id'] = self::parseCategories($value); - break; + case 'categories': + $contact['cat_id'] = self::parseCategories($value); + break; - case 'egroupware.org/customfields': - $contact += self::parseCustomfields($value); - break; + case 'egroupware.org/customfields': + $contact += self::parseCustomfields($value); + break; - case 'egroupware.org/assistant': - $contact['assistent'] = $value; - break; + case 'egroupware.org/assistant': + $contact['assistent'] = $value; + break; - case 'egroupware.org/fileAs': - $contact['fileas'] = $value; - break; + case 'egroupware.org/fileAs': + $contact['fileas'] = $value; + break; - case 'prodId': case 'created': case 'updated': case 'kind': - break; + case 'prodId': + case 'created': + case 'updated': + case 'kind': + break; - default: - error_log(__METHOD__."() $name=".json_encode($value).' --> ignored'); - break; + default: + error_log(__METHOD__ . "() $name=" . json_encode($value, self::JSON_OPTIONS_ERROR) . ' --> ignored'); + break; + } } } + 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); + } return $contact; } + /** + * JSON options for errors thrown as exceptions + */ + const JSON_OPTIONS_ERROR = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE; + /** * Return organisation * @@ -317,6 +353,7 @@ class JsContact * Parse custom fields * * Not defined custom fields are ignored! + * Not send custom fields are set to null! * * @param array $cfs name => object with attribute data and optional type, label, values * @return array @@ -324,17 +361,18 @@ class JsContact protected static function parseCustomfields(array $cfs) { $contact = []; - $definition = Api\Storage\Customfields::get('addressbook'); + $definitions = Api\Storage\Customfields::get('addressbook'); - foreach($cfs as $name => $data) + foreach($definitions as $name => $definition) { - if (!is_array($data) || !array_key_exists('value', $data)) + $data = $cfs[$name]; + if (isset($data[$name])) { - throw new \InvalidArgumentException("Invalid customfield object $name: ".json_encode($data)); - } - if (isset($definition[$name])) - { - switch($definition[$name]['type']) + if (!is_array($data) || !array_key_exists('value', $data)) + { + throw new \InvalidArgumentException("Invalid customfield object $name: ".json_encode($data, self::JSON_OPTIONS_ERROR)); + } + switch($definition['type']) { case 'date-time': $data['value'] = Api\DateTime::to($data['value'], 'object'); @@ -347,12 +385,22 @@ class JsContact break; case 'select': if (is_scalar($data['value'])) $data['value'] = explode(',', $data['value']); - $data['value'] = array_intersect(array_keys($definition[$name]['values']), $data['value']); + $data['value'] = array_intersect(array_keys($definition['values']), $data['value']); $data['value'] = $data['value'] ? implode(',', (array)$data['value']) : null; break; } $contact['#'.$name] = $data['value']; } + // set not return cfs to null + else + { + $contact['#'.$name] = null; + } + } + // report not existing cfs to log + if (($not_existing=array_diff(array_keys($cfs), array_keys($definitions)))) + { + error_log(__METHOD__."() not existing/ignored custom fields: ".implode(', ', $not_existing)); } return $contact; } @@ -454,7 +502,7 @@ class JsContact $contact += ($values=self::parseAddress($address, $id, $last_type)); if (++$n > 2) { - error_log(__METHOD__."() Ignoring $n. address id=$id: ".json_encode($address)); + error_log(__METHOD__."() Ignoring $n. address id=$id: ".json_encode($address, self::JSON_OPTIONS_ERROR)); break; } } @@ -481,7 +529,8 @@ class JsContact */ protected static function parseAddress(array $address, string $id, string &$last_type=null) { - $type = !isset($last_type) && (empty($address['context']['private']) || $id !== 'private') || $last_type === 'home' ? 'work' : 'home'; + $type = !isset($last_type) && (empty($address['contexts']['private']) || $id === 'work') || + $last_type === 'home' ? 'work' : 'home'; $last_type = $type; $prefix = $type === 'work' ? 'adr_one_' : 'adr_two_'; @@ -547,7 +596,7 @@ class JsContact { if (!is_array($component) || !is_string($component['value'])) { - throw new \InvalidArgumentException("Invalid streetComponents!"); + throw new \InvalidArgumentException("Invalid street-component: ".json_encode($component, self::JSON_OPTIONS_ERROR)); } if ($street && $last_type !== 'separator') // if we have no separator, we add a space { @@ -614,7 +663,7 @@ class JsContact { if (!is_array($phone) || !is_string($phone['phone'])) { - throw new \InvalidArgumentException("Invalid phone: " . json_encode($phone)); + throw new \InvalidArgumentException("Invalid phone: " . json_encode($phone, self::JSON_OPTIONS_ERROR)); } // first check for "our" id's if (isset(self::$phone2jscard[$id]) && !isset($contact[$id])) @@ -687,7 +736,7 @@ class JsContact { if (!is_array($value) || !is_string($value['resource'])) { - throw new \InvalidArgumentException("Invalid online resource with id '$id': ".json_encode($value)); + throw new \InvalidArgumentException("Invalid online resource with id '$id': ".json_encode($value, self::JSON_OPTIONS_ERROR)); } // check for "our" id's if (in_array($id, ['url', 'url_home'])) @@ -717,6 +766,7 @@ class JsContact /** * Parse emails object * + * @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" * @return array */ @@ -727,7 +777,7 @@ class JsContact { if (!is_array($value) || !is_string($value['email'])) { - throw new \InvalidArgumentException("Invalid email object: ".json_encode($value)); + throw new \InvalidArgumentException("Invalid email object (requires email attribute): ".json_encode($value, self::JSON_OPTIONS_ERROR)); } if (!isset($contact['email']) && $id !== 'private' && empty($value['context']['private'])) { @@ -802,21 +852,21 @@ class JsContact /** * parse nameComponents * - * @param array $values + * @param array $components * @return array */ - protected static function parseNameComponents(array $values) + protected static function parseNameComponents(array $components) { $contact = array_combine(array_values(self::$nameType2attribute), array_fill(0, count(self::$nameType2attribute), null)); - foreach($values as $value) + foreach($components as $component) { - if (empty($value['type']) || isset($value) && !is_string($value['value'])) + if (empty($component['type']) || isset($component) && !is_string($component['value'])) { - throw new \InvalidArgumentException("Invalid nameComponent!"); + throw new \InvalidArgumentException("Invalid name-component (must have type and value attributes): ".json_encode($component, self::JSON_OPTIONS_ERROR)); } - $contact[self::$nameType2attribute[$value['type']]] = $value['value']; + $contact[self::$nameType2attribute[$component['type']]] = $component['value']; } return $contact; } @@ -854,7 +904,7 @@ class JsContact (!list($year, $month, $day) = explode('-', $anniversary['date'])) || !(1 <= $month && $month <= 12 && 1 <= $day && $day <= 31)) { - throw new \InvalidArgumentException("Invalid anniversary object with id '$id': ".json_encode($anniversary)); + throw new \InvalidArgumentException("Invalid anniversary object with id '$id': ".json_encode($anniversary, self::JSON_OPTIONS_ERROR)); } if (!isset($contact['bday']) && ($id === 'bday' || $anniversary['type'] === 'birth')) { @@ -862,7 +912,7 @@ class JsContact } else { - error_log(__METHOD__."() only one birtday is supported, ignoring aniversary: ".json_encode($anniversary)); + error_log(__METHOD__."() only one birtday is supported, ignoring aniversary: ".json_encode($anniversary, self::JSON_OPTIONS_ERROR)); } } return $contact; @@ -902,7 +952,7 @@ class JsContact { if (!is_string($value['value'])) { - throw new \InvalidArgumentException("Invalid localizedString: ".json_encode($value)); + throw new \InvalidArgumentException("Invalid localizedString: ".json_encode($value, self::JSON_OPTIONS_ERROR)); } return $value['value']; } @@ -960,7 +1010,7 @@ class JsContact $vCard->setAttribute('UID',$list['list_uid']); */ - return Api\CalDAV::json_encode($group); + return Api\CalDAV::json_encode($group, self::JSON_OPTIONS_ERROR); } /** @@ -975,4 +1025,4 @@ class JsContact } return $contacts; } -} \ No newline at end of file +} diff --git a/api/src/Contacts/JsContactParseException.php b/api/src/Contacts/JsContactParseException.php new file mode 100644 index 0000000000..bf5e6acf38 --- /dev/null +++ b/api/src/Contacts/JsContactParseException.php @@ -0,0 +1,27 @@ + + * @package addressbook + * @copyright (c) 2021 by Ralf Becker + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Api\Contacts; + +use Throwable; + +/** + * Error parsing JsContact format + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07 + */ +class JsContactParseException extends \InvalidArgumentException +{ + public function __construct($message = "", $code = 422, Throwable $previous = null) + { + parent::__construct($message, $code ?: 422, $previous); + } +} \ No newline at end of file