got POST, PUT and DELETE request to add, update and delete contacts working

added JSON exception handler with nicer JsCalendar parse errors
This commit is contained in:
Ralf Becker 2021-09-17 20:15:36 +02:00
parent 8793f7895b
commit 7617aae9d0
4 changed files with 244 additions and 115 deletions

View File

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

View File

@ -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)/<resource-name>/calendar calendar of a resource/location, if user has rights to view
* - /<current-username>/(resource|location)-<resource-name> 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[]=<DAV-prop-name> 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=<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");

View File

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

View File

@ -0,0 +1,27 @@
<?php
/**
* EGroupware API - JsContact
*
* @link https://www.egroupware.org
* @author Ralf Becker <rb@egroupware.org>
* @package addressbook
* @copyright (c) 2021 by Ralf Becker <rb@egroupware.org>
* @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);
}
}