From 2aedd7f5ef80e2afbf98adfa46f19a436cfd1bf0 Mon Sep 17 00:00:00 2001 From: ralf Date: Wed, 29 Nov 2023 15:47:27 +0200 Subject: [PATCH] WIP REST Api for Timesheet app --- api/src/CalDAV.php | 10 +- api/src/CalDAV/Handler.php | 2 +- api/src/CalDAV/JsBase.php | 387 ++++++ api/src/CalDAV/JsCalendar.php | 266 +---- ...arseException.php => JsParseException.php} | 2 +- api/src/Contacts/JsContact.php | 238 +--- api/src/Contacts/JsContactParseException.php | 27 - timesheet/src/ApiHandler.php | 1052 +++++++++++++++++ timesheet/src/JsTimesheet.php | 184 +++ 9 files changed, 1638 insertions(+), 530 deletions(-) create mode 100644 api/src/CalDAV/JsBase.php rename api/src/CalDAV/{JsCalendarParseException.php => JsParseException.php} (90%) delete mode 100644 api/src/Contacts/JsContactParseException.php create mode 100644 timesheet/src/ApiHandler.php create mode 100644 timesheet/src/JsTimesheet.php diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index 8296b52901..d230725795 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -19,7 +19,7 @@ use EGroupware\Api\CalDAV\Principals; // explicit import non-namespaced classes require_once(__DIR__.'/WebDAV/Server.php'); -use EGroupware\Api\Contacts\JsContactParseException; +use EGroupware\Api\CalDAV\JsParseException; use HTTP_WebDAV_Server; use calendar_hooks; @@ -1156,7 +1156,9 @@ class CalDAV extends HTTP_WebDAV_Server 'address-data' => self::mkprop(self::CARDDAV, 'address-data', '') ] : ($is_calendar ? [ 'calendar-data' => self::mkprop(self::CALDAV, 'calendar-data', ''), - ] : 'all'), + ] : [ + 'data' => self::mkprop(self::CALDAV, 'data', '') + ]), 'other' => [], ); @@ -1262,7 +1264,7 @@ class CalDAV extends HTTP_WebDAV_Server // check if this is a property-object elseif (count($prop) === 3 && isset($prop['name']) && isset($prop['ns']) && isset($prop['val'])) { - $value = in_array($prop['name'], ['address-data', 'calendar-data']) ? $prop['val'] : self::jsonProps($prop['val']); + $value = in_array($prop['name'], ['address-data', 'calendar-data', 'data']) ? $prop['val'] : self::jsonProps($prop['val']); } else { @@ -2557,7 +2559,7 @@ class CalDAV extends HTTP_WebDAV_Server if (self::isJSON()) { header('Content-Type: application/json; charset=utf-8'); - if (is_a($e, JsContactParseException::class)) + if (is_a($e, JsParseException::class)) { $status = '422 Unprocessable Entity'; } diff --git a/api/src/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index f7a2230546..e6faa7b216 100644 --- a/api/src/CalDAV/Handler.php +++ b/api/src/CalDAV/Handler.php @@ -561,7 +561,7 @@ abstract class Handler */ function get_path($entry) { - return (is_array($entry) ? $entry[self::$path_attr] : $entry).self::$path_extension; + return (is_array($entry) ? $entry[static::$path_attr] : $entry).static::$path_extension; } /** diff --git a/api/src/CalDAV/JsBase.php b/api/src/CalDAV/JsBase.php new file mode 100644 index 0000000000..9a3d7d1cb0 --- /dev/null +++ b/api/src/CalDAV/JsBase.php @@ -0,0 +1,387 @@ + + * @package calendar + * @copyright (c) 2023 by Ralf Becker + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Api\CalDAV; + +use EGroupware\Api; + +/** + * Shared base-class of all REST API / JMAP classes + * + * @link https://datatracker.ietf.org/doc/html/rfc8984 + * @link https://jmap.io/spec-calendars.html + */ +class JsBase +{ + const APP = null; + + const MIME_TYPE_JSON = "application/json"; + + 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 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, string $old=null, bool $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(); + } + 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; + } + + /** + * Return a date-time value in UTC + * + * @link https://datatracker.ietf.org/doc/html/rfc8984#section-1.4.4 + * @param null|string|\DateTime $date + * @param bool $user false: timestamp in server-time, true: timestamp in user-time, does NOT matter for DateTime objects + * @return string|null + */ + protected static function UTCDateTime($date, bool $user=false) + { + static $utc=null; + if (!isset($utc)) $utc = new \DateTimeZone('UTC'); + + if (!isset($date)) + { + return null; + } + $date = $user ? Api\DateTime::user2server($date, 'object') : Api\DateTime::to($date, 'object'); + $date->setTimezone($utc); + + // we need to use "Z", not "+00:00" + return substr($date->format(Api\DateTime::RFC3339), 0, -6).'Z'; + } + + /** + * Output an account as email, if available, username or as last resort the numerical account_id + * + * @param int|null $account_id + * @return string|int|null + * @throws \Exception + */ + protected static function account(int $account_id=null) + { + if (!$account_id) + { + return null; + } + return Api\Accounts::id2name($account_id, 'account_email') ?: Api\Accounts::id2name($account_id) ?: $account_id; + } + + /** + * Parse an account specified as email, account_lid or account_id + * + * @param string|int $value + * @param ?bool $user + * @return int + * @throws \Exception + */ + protected static function parseAccount(string $value, bool $user=true) + { + if (is_numeric($value) && ($exists = Api\Accounts::getInstance()->exists(value)) && + (!isset($user) || $exists === ($user ? 1 : 2))) + { + $account_id = (int)$value; + } + else + { + $account_id = Api\Accounts::getInstance()->name2id($value, + strpos($value, '@') !== false ? 'account_email' : 'account_lid', + isset($user) ? ($user ? 'u' : 'g') : null); + } + if (!$account_id) + { + throw new JsParseException("Invalid or non-existing account '$value'"); + } + return $account_id; + } + + /** + * JSON options for errors thrown as exceptions + */ + const JSON_OPTIONS_ERROR = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE; + + const AT_TYPE = '@type'; + + /** + * Return EGroupware custom fields + * + * @param array $contact + * @return array + */ + protected static function customfields(array $contact) + { + $fields = []; + foreach(Api\Storage\Customfields::get(static::APP) as $name => $data) + { + $value = $contact['#'.$name]; + if (isset($value)) + { + switch($data['type']) + { + case 'date-time': + $value = Api\DateTime::to($value, Api\DateTime::RFC3339); + break; + case 'float': + $value = (double)$value; + break; + case 'int': + $value = (int)$value; + break; + case 'select': + $value = explode(',', $value); + break; + } + $fields[$name] = array_filter([ + 'value' => $value, + 'type' => $data['type'], + 'label' => $data['label'], + 'values' => $data['values'], + ]); + } + } + return $fields; + } + + /** + * 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 + */ + protected static function parseCustomfields(array $cfs) + { + $contact = []; + $definitions = Api\Storage\Customfields::get(self::APP); + + foreach($definitions as $name => $definition) + { + $data = $cfs[$name]; + if (isset($data)) + { + if (is_scalar($data) || is_array($data) && !isset($data['value'])) + { + $data = ['value' => $data]; + } + 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'); + break; + case 'float': + $data['value'] = (double)$data['value']; + break; + case 'int': + $data['value'] = round($data['value']); + break; + case 'select': + if (is_scalar($data['value'])) $data['value'] = explode(',', $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; + } + + /** + * Return object of category-name(s) => true + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.5.4 + * @param ?string $cat_ids comma-sep. cat_id's + * @return true[] + */ + protected static function categories(?string $cat_ids) + { + $cat_ids = array_filter($cat_ids ? explode(',', $cat_ids): []); + + return array_combine(array_map(static function ($cat_id) + { + return Api\Categories::id2name($cat_id); + }, $cat_ids), array_fill(0, count($cat_ids), true)); + } + + /** + * Parse categories object + * + * @param array $categories category-name => true pairs + * @return ?string comma-separated cat_id's + * @todo make that generic, so JsContact & JSCalendar have not to overwrite it + */ + protected static function parseCategories(array $categories, bool $multiple=true) + { + static $bo=null; + $cat_ids = []; + if ($categories) + { + if (count($categories) > 1 && !$multiple) + { + throw new JsParseException("Only a single category is supported!"); + } + if (!isset($bo)) $bo = new Api\Categories($GLOBALS['egw_info']['user']['account_id'], static::APP); + foreach($categories as $name => $true) + { + if (!($cat_id = $bo->name2id($name))) + { + $cat_id = $bo->add(array('name' => $name, 'descr' => $name, 'access' => 'private')); + } + $cat_ids[] = $cat_id; + } + } + return $cat_ids ? implode(',', $cat_ids) : null; + } + + /** + * 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; + } + + /** + * Parse an integer + * + * @param int $value + * @return int + * @throws \TypeError + */ + public static function parseInt(int $value) + { + return $value; + } + + /** + * Parse an float value + * + * @param float $value + * @return float + * @throws \TypeError + */ + public static function parseFloat(float $value) + { + return $value; + } + + /** + * Map all kind of exceptions while parsing to a JsCalendarParseException + * + * @param \Throwable $e + * @param string $type + * @param ?string $name + * @param mixed $value + * @throws JsParseException + */ + protected static function handleExceptions(\Throwable $e, $type='JsCalendar', ?string $name, $value) + { + try { + throw $e; + } + catch (\JsonException $e) { + throw new JsParseException("Error parsing JSON: ".$e->getMessage(), 422, $e); + } + catch (\InvalidArgumentException $e) { + throw new JsParseException("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 JsParseException("Error parsing $type attribute '$name': $message", 422, $e); + } + catch (\Throwable $e) { + throw new JsParseException("Error parsing $type attribute '$name': ". $e->getMessage(), 422, $e); + } + } +} \ No newline at end of file diff --git a/api/src/CalDAV/JsCalendar.php b/api/src/CalDAV/JsCalendar.php index d829476d99..06d2d31a48 100644 --- a/api/src/CalDAV/JsCalendar.php +++ b/api/src/CalDAV/JsCalendar.php @@ -19,12 +19,13 @@ use EGroupware\Api; * @link https://datatracker.ietf.org/doc/html/rfc8984 * @link https://jmap.io/spec-calendars.html */ -class JsCalendar +class JsCalendar extends JsBase { + const APP = 'calendar'; + const MIME_TYPE = "application/jscalendar+json"; const MIME_TYPE_JSEVENT = "application/jscalendar+json;type=event"; const MIME_TYPE_JSTASK = "application/jscalendar+json;type=task"; - const MIME_TYPE_JSON = "application/json"; const TYPE_EVENT = 'Event'; @@ -200,174 +201,6 @@ class JsCalendar return $event; } - 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 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, string $old=null, bool $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(); - } - 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; - } - - /** - * JSON options for errors thrown as exceptions - */ - const JSON_OPTIONS_ERROR = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE; - - const AT_TYPE = '@type'; - - /** - * Return EGroupware custom fields - * - * @param array $contact - * @return array - */ - protected static function customfields(array $contact) - { - $fields = []; - foreach(Api\Storage\Customfields::get('calendar') as $name => $data) - { - $value = $contact['#'.$name]; - if (isset($value)) - { - switch($data['type']) - { - case 'date-time': - $value = Api\DateTime::to($value, Api\DateTime::RFC3339); - break; - case 'float': - $value = (double)$value; - break; - case 'int': - $value = (int)$value; - break; - case 'select': - $value = explode(',', $value); - break; - } - $fields[$name] = array_filter([ - 'value' => $value, - 'type' => $data['type'], - 'label' => $data['label'], - 'values' => $data['values'], - ]); - } - } - return $fields; - } - - /** - * 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 - */ - protected static function parseCustomfields(array $cfs) - { - $contact = []; - $definitions = Api\Storage\Customfields::get('calendar'); - - foreach($definitions as $name => $definition) - { - $data = $cfs[$name]; - if (isset($data)) - { - if (is_scalar($data)) - { - $data = ['value' => $data]; - } - 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'); - break; - case 'float': - $data['value'] = (double)$data['value']; - break; - case 'int': - $data['value'] = round($data['value']); - break; - case 'select': - if (is_scalar($data['value'])) $data['value'] = explode(',', $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; - } - - /** - * Return object of category-name(s) => true - * - * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.5.4 - * @param ?string $cat_ids comma-sep. cat_id's - * @return true[] - */ - protected static function categories(?string $cat_ids) - { - $cat_ids = array_filter($cat_ids ? explode(',', $cat_ids): []); - - return array_combine(array_map(static function ($cat_id) - { - return Api\Categories::id2name($cat_id); - }, $cat_ids), array_fill(0, count($cat_ids), true)); - } - /** * Parse categories object * @@ -421,29 +254,6 @@ class JsCalendar return $value; } - /** - * Return a date-time value in UTC - * - * @link https://datatracker.ietf.org/doc/html/rfc8984#section-1.4.4 - * @param null|string|\DateTime $date - * @return string|null - */ - protected static function UTCDateTime($date) - { - static $utc=null; - if (!isset($utc)) $utc = new \DateTimeZone('UTC'); - - if (!isset($date)) - { - return null; - } - $date = Api\DateTime::to($date, 'object'); - $date->setTimezone($utc); - - // we need to use "Z", not "+00:00" - return substr($date->format(Api\DateTime::RFC3339), 0, -6).'Z'; - } - const DATETIME_FORMAT = 'Y-m-d\TH:i:s'; /** @@ -874,76 +684,6 @@ class JsCalendar return $alerts; } - /** - * Patch JsEvent - * - * @param array $patches JSON path - * @param array $jsevent to patch - * @param bool $create =false true: create missing components - * @return array patched $jsevent - */ - public static function patch(array $patches, array $jsevent, bool $create=false) - { - foreach($patches as $path => $value) - { - $parts = explode('/', $path); - $target = &$jsevent; - 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 $jsevent; - } - - /** - * Map all kind of exceptions while parsing to a JsCalendarParseException - * - * @param \Throwable $e - * @param string $type - * @param ?string $name - * @param mixed $value - * @throws JsCalendarParseException - */ - protected static function handleExceptions(\Throwable $e, $type='JsCalendar', ?string $name, $value) - { - try { - throw $e; - } - catch (\JsonException $e) { - throw new JsCalendarParseException("Error parsing JSON: ".$e->getMessage(), 422, $e); - } - catch (\InvalidArgumentException $e) { - throw new JsCalendarParseException("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 JsCalendarParseException("Error parsing $type attribute '$name': $message", 422, $e); - } - catch (\Throwable $e) { - throw new JsCalendarParseException("Error parsing $type attribute '$name': ". $e->getMessage(), 422, $e); - } - } - /** * @return \calendar_boupdate */ diff --git a/api/src/CalDAV/JsCalendarParseException.php b/api/src/CalDAV/JsParseException.php similarity index 90% rename from api/src/CalDAV/JsCalendarParseException.php rename to api/src/CalDAV/JsParseException.php index 3cabfa8866..f72428cf7b 100644 --- a/api/src/CalDAV/JsCalendarParseException.php +++ b/api/src/CalDAV/JsParseException.php @@ -18,7 +18,7 @@ use Throwable; * * @link * @link https://datatracker.ietf.org/doc/html/rfc8984 */ -class JsCalendarParseException extends \InvalidArgumentException +class JsParseException extends \InvalidArgumentException { public function __construct($message = "", $code = 422, Throwable $previous = null) { diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index e6493dc5a7..5b2329fab4 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -19,12 +19,13 @@ use EGroupware\Api; * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07 (newer, here implemented format) * @link https://datatracker.ietf.org/doc/html/rfc7095 jCard (older vCard compatible contact data as JSON, NOT implemented here!) */ -class JsContact +class JsContact extends Api\CalDAV\JsBase { + const APP = 'addressbook'; + const MIME_TYPE = "application/jscontact+json"; const MIME_TYPE_JSCARD = "application/jscontact+json;type=card"; const MIME_TYPE_JSCARDGROUP = "application/jscontact+json;type=cardgroup"; - const MIME_TYPE_JSON = "application/json"; /** * Get jsCard for given contact @@ -216,50 +217,6 @@ class JsContact 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 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, string $old=null, bool $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(); - } - 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; - } - /** * JSON options for errors thrown as exceptions */ @@ -390,123 +347,6 @@ class JsContact return $contact; } - /** - * Return EGroupware custom fields - * - * @param array $contact - * @return array - */ - protected static function customfields(array $contact) - { - $fields = []; - foreach(Api\Storage\Customfields::get('addressbook') as $name => $data) - { - $value = $contact['#'.$name]; - if (isset($value)) - { - switch($data['type']) - { - case 'date-time': - $value = Api\DateTime::to($value, Api\DateTime::RFC3339); - break; - case 'float': - $value = (double)$value; - break; - case 'int': - $value = (int)$value; - break; - case 'select': - $value = explode(',', $value); - break; - } - $fields[$name] = array_filter([ - 'value' => $value, - 'type' => $data['type'], - 'label' => $data['label'], - 'values' => $data['values'], - ]); - } - } - return $fields; - } - - /** - * 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 - */ - protected static function parseCustomfields(array $cfs) - { - $contact = []; - $definitions = Api\Storage\Customfields::get('addressbook'); - - foreach($definitions as $name => $definition) - { - $data = $cfs[$name]; - if (isset($data)) - { - if (is_scalar($data)) - { - $data = ['value' => $data]; - } - 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'); - break; - case 'float': - $data['value'] = (double)$data['value']; - break; - case 'int': - $data['value'] = round($data['value']); - break; - case 'select': - if (is_scalar($data['value'])) $data['value'] = explode(',', $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; - } - - /** - * Return object of category-name(s) => true - * - * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.5.4 - * @param ?string $cat_ids comma-sep. cat_id's - * @return true[] - */ - protected static function categories(?string $cat_ids) - { - $cat_ids = array_filter($cat_ids ? explode(',', $cat_ids): []); - - return array_combine(array_map(static function ($cat_id) - { - return Api\Categories::id2name($cat_id); - }, $cat_ids), array_fill(0, count($cat_ids), true)); - } - /** * Parse categories object * @@ -1359,76 +1199,6 @@ 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 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 */ @@ -1441,4 +1211,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 deleted file mode 100644 index bf5e6acf38..0000000000 --- a/api/src/Contacts/JsContactParseException.php +++ /dev/null @@ -1,27 +0,0 @@ - - * @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 diff --git a/timesheet/src/ApiHandler.php b/timesheet/src/ApiHandler.php new file mode 100644 index 0000000000..aa38442c2d --- /dev/null +++ b/timesheet/src/ApiHandler.php @@ -0,0 +1,1052 @@ + + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Timesheet; + +use EGroupware\Api; + +/** + * REST API for Timesheet + */ +class ApiHandler extends Api\CalDAV\Handler +{ + /** + * @var \timesheet_bo + */ + protected \timesheet_bo $bo; + + /** + * Extension to append to url/path + * + * @var string + */ + static $path_extension = ''; + + /** + * Constructor + * + * @param string $app 'calendar', 'addressbook' or 'infolog' + * @param Api\CalDAV $caldav calling class + */ + function __construct($app, Api\CalDAV $caldav) + { + parent::__construct($app, $caldav); + self::$path_extension = ''; + + $this->bo = new \timesheet_bo(); + } + + /** + * Options for json_encode of responses + */ + const JSON_RESPONSE_OPTIONS = JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR; + + /** + * Handle post request for mail (send or compose mail and upload attachments) + * + * @param array &$options + * @param int $id + * @param int $user =null account_id of owner, default null + * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found') + */ + function post(&$options,$id,$user=null) + { + if ($this->debug) error_log(__METHOD__."($id, $user)".print_r($options,true)); + $path = $options['path']; + if (empty($user)) + { + $user = $GLOBALS['egw_info']['user']['account_id']; + } + else + { + $prefix = '/'.Api\Accounts::id2name($user); + if (str_starts_with($path, $prefix)) $path = substr($path, strlen($prefix)); + if ($user != $GLOBALS['egw_info']['user']['account_id']) + { + throw new \Exception("/mail is NOT available for users other than the one you authenticated!", 403); + } + } + header('Content-Type: application/json'); + + try { + if (str_starts_with($path, '/mail/attachments/')) + { + return self::storeAttachment($path, $options['stream'] ?? $options['content']); + } + elseif (preg_match('#^/mail(/(\d+))?/vacation/?$#', $path, $matches)) + { + return self::updateVacation($user, $options['content'], $matches[2]); + } + elseif (preg_match('#^/mail(/(\d+))?/view/?$#', $path, $matches)) + { + return self::viewEml($user, $options['stream'] ?? $options['content'], $matches[2]); + } + elseif (preg_match('#^/mail(/(\d+))?(/compose)?#', $path, $matches)) + { + $ident_id = $matches[2] ?? self::defaultIdentity($user); + $do_compose = (bool)($matches[3] ?? false); + if (!($data = json_decode($options['content'], true))) + { + throw new \Exception('Error decoding JSON: '.json_last_error_msg(), 422); + } + // ToDo: check required attributes + + $preset = array_filter(array_intersect_key($data, array_flip(['to', 'cc', 'bcc', 'replyto', 'subject', 'priority']))+[ + 'body' => $data['bodyHtml'] ?? null ?: $data['body'] ?? '', + 'mimeType' => !empty($data['bodyHtml']) ? 'html' : 'plain', + 'identity' => $ident_id, + ]+self::prepareAttachments($data['attachments'] ?? [], $data['attachmentType'] ?? 'attach', + $data['shareExpiration'], $data['sharePassword'], $do_compose)); + + // for compose we need to construct a URL and push it to the client (or give an error if the client is not online) + if ($do_compose) + { + if (!Api\Json\Push::isOnline($user)) + { + $account_lid = Api\Accounts::id2name($user); + throw new \Exception("User '$account_lid' (#$user) is NOT online", 404); + } + $push = new Api\Json\Push($user); + $push->call('egw.open', '', 'mail', 'add', ['preset' => $preset], '_blank', 'mail'); + echo json_encode([ + 'status' => 200, + 'message' => 'Request to open compose window sent', + //'data' => $preset, + ], self::JSON_RESPONSE_OPTIONS); + return true; + } + $acc_id = Api\Mail\Account::read_identity($ident_id)['acc_id']; + $mail_account = Api\Mail\Account::read($acc_id); + // check if the mail-account requires a user-context / password and then just send the mail with an smtp-only account NOT saving to Sent folder + if (empty($mail_account->acc_imap_password) || $mail_account->acc_smtp_auth_session && empty($mail_account->acc_smtp_password)) + { + $acc_id = Api\Mail\Account::get_default(true, true, true, false); + $compose = new \mail_compose($acc_id); + $compose->mailPreferences['sendOptions'] = 'send_only'; + $warning = 'Mail NOT saved to Sent folder, as no user password'; + } + else + { + $compose = new \mail_compose($acc_id); + } + $preset = array_filter([ + 'mailaccount' => $acc_id, + 'mailidentity' => $ident_id, + 'identity' => null, + 'add_signature' => true, // add signature in send, independent what preference says + ]+$preset); + if ($compose->send($preset, $acc_id)) + { + echo json_encode(array_filter([ + 'status' => 200, + 'warning' => $warning ?? null, + 'message' => 'Mail successful sent', + //'data' => $preset, + ]), self::JSON_RESPONSE_OPTIONS); + return true; + } + throw new \Exception($compose->error_info); + } + + throw new \Exception('Not Found', 404); + } + catch (\Throwable $e) { + return self::handleException($e); + } + } + + /** + * Get vacation array from server + * + * @param Api\Mail\Imap $imap + * @param ?int $user + * @return array + */ + protected static function getVacation(Api\Mail\Imap $imap, int $user=null) + { + if ($GLOBALS['egw']->session->token_auth) + { + return $imap->getVacationUser($user ?: $GLOBALS['egw_info']['user']['account_id']); + } + $sieve = new Api\Mail\Sieve($imap); + return $sieve->getVacation()+['script' => $sieve->script]; + } + + /** + * Update vacation message/handling with JSON data given in $content + * + * @param int $user + * @param array $content + * @param int|null $identity + * @return bool + * @throws Api\Exception\AssertionFailed + * @throws Api\Exception\NotFound + */ + protected static function updateVacation(int $user, string $content, int $identity=null) + { + $account = self::getMailAccount($user, $identity); + $vacation = $account->imapServer()->getVacationUser($user); + if (!($update = json_decode($content, true, 3, JSON_THROW_ON_ERROR))) + { + throw new \Exeception('Invalid request: no content', 400); + } + // Sieve class stores them as timestamps + foreach(['start', 'end'] as $name) + { + if (isset($update[$name])) + { + $vacation[$name.'_date'] = (new Api\DateTime($update[$name]))->format('ts'); + if (empty($update['status'])) $update['status'] = 'by_date'; + } + elseif (array_key_exists($name, $update)) + { + $vacation[$name.'_date'] = null; + if (empty($update['status'])) $update['status'] = 'off'; + } + unset($update[$name]); + } + // Sieve class stores them as comma-separated string + if (array_key_exists('forwards', $update)) + { + $vacation['forwards'] = implode(',', self::parseAddressList($update['forwards'] ?? [], 'forwards')); + unset($update['forwards']); + } + if (array_key_exists('addresses', $update)) + { + $update['addresses'] = self::parseAddressList($update['addresses'] ?? [], 'addresses'); + } + static $modi = ['notice+store', 'notice', 'store']; + if (isset($update['modus']) && !in_array($update['modus'], $modi)) + { + throw new \Exception("Invalid value '$update[modus]' for attribute modus, allowed values are: '".implode("', '", $modi)."'", 400); + } + if (($invalid=array_diff(array_keys($update), ['start','end','status','modus','text','addresses','forwards','days']))) + { + throw new \Exception("Invalid attribute: ".implode(', ', $invalid), 400); + } + $vacation_rule = null; + $vacation = array_merge([ // some defaults + 'status' => 'on', + 'addresses' => [Api\Accounts::id2name($user, 'account_email')], + 'days' => 3, + ], $vacation, $update); + // for token-auth we have to use the admin connection + if ($GLOBALS['egw']->session->token_auth) + { + if (!$account->imapServer()->setVacationUser($user, $vacation)) + { + throw new \Exception($account->imapServer()->error ?: 'Error updating sieve-script'); + } + } + else + { + $sieve = new Api\Mail\Sieve($account->imapServer()); + $sieve->setVacation($vacation, null, $vacation_rule, true); + } + echo json_encode(array_filter([ + 'status' => 200, + 'message' => 'Vacation handling updated', + 'vacation_rule' => $vacation_rule, + 'vacation' => self::returnVacation(self::getVacation($account->imapServer(), $user)), + ]), self::JSON_RESPONSE_OPTIONS); + return true; + } + + /** + * Parse array of email addresses + * + * @param string[] $_addresses + * @param string $name attribute name for exception + * @return string[] + * @throws \Exception if there is an invalid email address + */ + protected static function parseAddressList(array $_addresses, $name=null) + { + $parsed = iterator_to_array(Api\Mail::parseAddressList($_addresses)); + + if (count($parsed) !== count($_addresses) || + array_filter($parsed, static function ($addr) + { + return !$addr->valid; + })) + { + throw new \Exception("Error parsing email-addresses in attribute $name: ".json_encode($_addresses)); + } + return array_map(static function($addr) + { + return $addr->mailbox.'@'.$addr->host; + }, $parsed); + } + + /** + * Store uploaded attachment and return token + * + * @param string $path + * @param string|stream $content + * @return string HTTP status + * @throws \Exception on error + */ + protected static function storeAttachment(string $path, $content) + { + $attachment_path = tempnam($GLOBALS['egw_info']['server']['temp_dir'], 'attach--'. + (str_replace('/', '-', substr($path, 18)) ?: 'no-name').'--'); + if (is_resource($content) ? + stream_copy_to_stream($content, $fp=fopen($attachment_path, 'w')) : + file_put_contents($attachment_path, $content)) + { + if (isset($fp)) fclose($fp); + $location = '/mail/attachments/'.substr(basename($attachment_path), 8); + // allow to suppress location header with an "X-No-Location: true" header + if (($location_header = empty($_SERVER['HTTP_X_NO_LOCATION']))) + { + header('Location: '.Api\Framework::getUrl(Api\Framework::link('/groupdav.php'.$location))); + } + $ret = $location_header ? '201 Created' : '200 Ok'; + echo json_encode([ + 'status' => (int)$ret, + 'message' => 'Attachment stored', + 'location' => $location, + ], self::JSON_RESPONSE_OPTIONS); + return $ret; + } + throw new \Exception('Error storing attachment'); + } + + /** + * View posted eml file + * + * @param int $user + * @param string|stream $content + * @param ?int $acc_id mail account to import in Drafts folder + * @return string HTTP status + * @throws \Exception on error + */ + protected static function viewEml(int $user, $content, int $acc_id=null) + { + if (empty($acc_id)) + { + $acc_id = self::defaultIdentity($user); + } + + // check and bail, if user is not online + if (!Api\Json\Push::isOnline($user)) + { + $account_lid = Api\Accounts::id2name($user); + throw new \Exception("User '$account_lid' (#$user) is NOT online", 404); + } + + // save posted eml to a temp-dir + $eml = tempnam($GLOBALS['egw_info']['server']['temp_dir'], 'view-eml-'); + if (!(is_resource($content) ? + stream_copy_to_stream($content, $fp = fopen($eml, 'w')) : + file_put_contents($eml, $content))) + { + throw new \Exception('Error storing attachment'); + } + if (isset($fp)) fclose($fp); + + // import mail into drafts folder + $mail = Api\Mail::getInstance(false, $acc_id); + $folder = $mail->getDraftFolder(); + $mailer = new Api\Mailer(); + $mail->parseFileIntoMailObject($mailer, $eml); + $mail->openConnection(); + $message_uid = $mail->appendMessage($folder, $mailer->getRaw(), null, '\\Seen'); + + // tell browser to view eml from drafts folder + $push = new Api\Json\Push($user); + $push->call('egw.open', \mail_ui::generateRowID($acc_id, $folder, $message_uid, true), + 'mail', 'view', ['mode' => 'display'], '_blank', 'mail'); + + // respond with success message + echo json_encode([ + 'status' => 200, + 'message' => 'Request to open view window sent', + ], self::JSON_RESPONSE_OPTIONS); + + return true; + } + + /** + * Get default identity of user + * + * @param int $user + * @return int ident_id + * @throws Api\Exception\WrongParameter + * @throws \Exception (404) if user has no IMAP account + */ + protected static function defaultIdentity(int $user) + { + foreach(Api\Mail\Account::search($user,false) as $acc_id => $account) + { + // do NOT add SMTP only accounts as identities + if (!$account->is_imap(false)) continue; + + foreach($account->identities($acc_id) as $ident_id => $identity) + { + return $ident_id; + } + } + throw new \Exception("No IMAP account found for user #$user", 404); + } + + /** + * Convert an attachment name into an upload array for mail_compose::compose + * + * @param string[] $attachments either "/mail/attachments/" / file in temp_dir or VFS path + * @param ?string $attachmentType "attach" (default), "link", "share_ro", "share_rw" + * @param ?string $expiration "YYYY-mm-dd" or e.g. "+2days" + * @param ?string $password optional password for the share + * @param bool $compose true: for compose window, false: to send + * @return array with values for keys "file", "name", "filemode", "expiration" and "password" + * @throws Exception if file not found or unreadable + */ + protected static function prepareAttachments(array $attachments, string $attachmentType=null, string $expiration=null, string $password=null, bool $compose=true) + { + $ret = []; + foreach($attachments as $attachment) + { + if (preg_match('#^/mail/attachments/(([^/]+)--[^/.-]{6,})$#', $attachment, $matches)) + { + if (!file_exists($path=$GLOBALS['egw_info']['server']['temp_dir'].'/attach--'.$matches[1])) + { + throw new \Exception("Attachment $attachment NOT found", 400); + } + if ($compose) + { + $ret['file'][] = $path; + $ret['name'][] = $matches[2]; + } + else + { + $ret['attachments'][] = [ + 'name' => $matches[2], + 'type' => Api\Vfs::mime_content_type($path), + 'file' => $path, + 'size' => filesize($path), + ]; + } + } + else + { + if (!Api\Vfs::is_readable($attachment)) + { + throw new \Exception("Attachment $attachment NOT found", 400); + } + if ($compose) + { + $ret['file'][] = Api\Vfs::PREFIX.$attachment; + $ret['name'][] = Api\Vfs::basename($attachment); + } + else + { + $ret['attachments'][] = [ + 'name' => Api\Vfs::basename($attachment), + 'type' => Api\Vfs::mime_content_type($attachment), + 'file' => Api\Vfs::PREFIX.$attachment, + 'size' => filesize(Api\Vfs::PREFIX.$attachment), + ]; + } + } + } + if ($ret) + { + $ret['filemode'] = $attachmentType ?? 'attach'; + if (!in_array($ret['filemode'], $valid=['attach', 'link', 'share_ro', 'share_rw'])) + { + throw new \Exception("Invalid value '$ret[filemode]' for attachmentType, must be one of: '".implode("', '", $valid)."'", 422); + } + // EPL share password and expiration + $ret['password'] = $password ?: null; + if (!empty($expiration)) + { + $ret['expiration'] = (new Api\DateTime($expiration))->format('Y-m-d'); + } + } + return $ret; + } + + /** + * Handle propfind in the timesheet folder / get request on the collection itself + * + * @param string $path + * @param array &$options + * @param array &$files + * @param int $user account_id + * @param string $id ='' + * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found') + */ + function propfind($path,&$options,&$files,$user,$id='') + { + $filter = [ + 'ts_owner' => $user ?: null, + ]; + + // process REPORT filters or multiget href's + $nresults = null; + if (($id || $options['root']['name'] != 'propfind') && !$this->_report_filters($options,$filter,$id, $nresults)) + { + return false; + } + if ($id) $path = dirname($path).'/'; // carddav_name get's added anyway in the callback + + if ($this->debug) error_log(__METHOD__."($path,".array2string($options).",,$user,$id) filter=".array2string($filter)); + + // rfc 6578 sync-collection report: filter for sync-token is already set in _report_filters + if ($options['root']['name'] == 'sync-collection') + { + // callback to query sync-token, after propfind_callbacks / iterator is run and + // stored max. modification-time in $this->sync_collection_token + $files['sync-token'] = array($this, 'get_sync_collection_token'); + $files['sync-token-params'] = array($path, $user); + + $this->sync_collection_token = null; + + $filter['order'] = 'ts_modified ASC'; // return oldest modifications first + $filter['sync-collection'] = true; + } + + if (isset($nresults)) + { + $files['files'] = $this->propfind_generator($path, $filter, $files['files'], (int)$nresults); + + // hack to support limit with sync-collection report: contacts are returned in modified ASC order (oldest first) + // if limit is smaller than full result, return modified-1 as sync-token, so client requests next chunk incl. modified + // (which might contain further entries with identical modification time) + if ($options['root']['name'] == 'sync-collection' && $this->bo->total > $nresults) + { + --$this->sync_collection_token; + $files['sync-token-params'][] = true; // tell get_sync_collection_token that we have more entries + } + } + else + { + // return iterator, calling ourselves to return result in chunks + $files['files'] = $this->propfind_generator($path,$filter, $files['files']); + } + return true; + } + + /** + * Chunk-size for DB queries of profind_generator + */ + const CHUNK_SIZE = 500; + + /** + * Generator for propfind with ability to skip reporting not found ids + * + * @param string $path + * @param array& $filter + * @param array $extra extra resources like the collection itself + * @param int|null $nresults option limit of number of results to report + * @param boolean $report_not_found_multiget_ids=true + * @return Generator + */ + function propfind_generator($path, array &$filter, array $extra=[], $nresults=null, $report_not_found_multiget_ids=true) + { + //error_log(__METHOD__."('$path', ".array2string($filter).", ".array2string($start).", $report_not_found_multiget_ids)"); + $starttime = microtime(true); + $filter_in = $filter; + + // yield extra resources like the root itself + $yielded = 0; + foreach($extra as $resource) + { + if (++$yielded && isset($nresults) && $yielded > $nresults) + { + return; + } + yield $resource; + } + + if (isset($filter['order'])) + { + $order = $filter['order']; + unset($filter['order']); + } + else + { + $order = 'egw_timesheet.ts_id'; + } + // detect sync-collection report + $sync_collection_report = $filter['sync-collection']; + unset($filter['sync-collection']); + + // stop output buffering switched on to log the response, if we should return more than 200 entries + if (!empty($this->requested_multiget_ids) && ob_get_level() && count($this->requested_multiget_ids) > 200) + { + $this->caldav->log("### ".count($this->requested_multiget_ids)." resources requested in multiget REPORT --> turning logging off to allow streaming of the response"); + ob_end_flush(); + } + + $search = $filter['search'] ?? []; + unset($filter['search']); + for($chunk=0; ($timesheets =& $this->bo->search($search, '*', $order, '', '', False, 'AND', + [$chunk*self::CHUNK_SIZE, self::CHUNK_SIZE], $filter)); ++$chunk) + { + // read custom-fields + if ($this->bo->customfields) + { + $id2keys = array(); + foreach($timesheets as $key => &$timesheet) + { + $id2keys[$timesheet['ts_id']] = $key; + } + if (($cfs = $this->bo->read_customfields(array_keys($id2keys)))) + { + foreach($cfs as $id => $data) + { + $timesheets[$id2keys[$id]] += $data; + } + } + } + foreach($timesheets as &$timesheet) + { + $content = JsTimesheet::JsTimesheet($timesheet, false); + $timesheet = Api\Db::strip_array_keys($timesheet, 'ts_'); + + // remove contact from requested multiget ids, to be able to report not found urls + if (!empty($this->requested_multiget_ids) && ($k = array_search($timesheet[self::$path_attr], $this->requested_multiget_ids)) !== false) + { + unset($this->requested_multiget_ids[$k]); + } + // sync-collection report: deleted entry need to be reported without properties + if ($timesheet['ts_status'] == \timesheet_bo::DELETED_STATUS) + { + if (++$yielded && isset($nresults) && $yielded > $nresults) + { + return; + } + yield ['path' => $path.urldecode($this->get_path($timesheet))]; + continue; + } + $props = array( + 'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', 'application/json'), + 'getlastmodified' => Api\DateTime::user2server($timesheet['modified']), + 'displayname' => $timesheet['title'], + ); + if (true) + { + $props['getcontentlength'] = bytes(is_array($content) ? json_encode($content) : $content); + $props['data'] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'data', $content); + } + if (++$yielded && isset($nresults) && $yielded > $nresults) + { + return; + } + yield $this->add_resource($path, $timesheet, $props); + } + // sync-collection report --> return modified of last contact as sync-token + if ($sync_collection_report) + { + $this->sync_collection_token = $timesheet['modified']; + } + } + + // report not found multiget urls + if ($report_not_found_multiget_ids && !empty($this->requested_multiget_ids)) + { + foreach($this->requested_multiget_ids as $id) + { + if (++$yielded && isset($nresults) && $yielded > $nresults) + { + return; + } + yield ['path' => $path.$id.self::$path_extension]; + } + } + + if ($this->debug) + { + error_log(__METHOD__."($path, filter=".json_encode($filter).', extra='.json_encode($extra). + ", nresults=$nresults, report_not_found=$report_not_found_multiget_ids) took ". + (microtime(true) - $starttime)." to return $yielded resources"); + } + } + + /** + * Process the filters from the CalDAV REPORT request + * + * @param array $options + * @param array &$filters + * @param string $id + * @param int &$nresult on return limit for number or results or unchanged/null + * @return boolean true if filter could be processed + */ + function _report_filters($options, &$filters, $id, &$nresults) + { + // in case of JSON/REST API pass filters to report + if (Api\CalDAV::isJSON() && !empty($options['filters']) && is_array($options['filters'])) + { + $filters += $options['filters']; // using += to no allow overwriting existing filters + } + elseif (!empty($options['filters'])) + { + /* Example of a complex filter used by Mac Addressbook + + + becker + ralf + + + becker + ralf + + + becker + ralf + + + */ + $filter_test = isset($options['filters']['attrs']) && isset($options['filters']['attrs']['test']) ? + $options['filters']['attrs']['test'] : 'anyof'; + $prop_filters = array(); + + $matches = $prop_test = $column = null; + foreach($options['filters'] as $n => $filter) + { + if (!is_int($n)) continue; // eg. attributes of filter xml element + + switch((string)$filter['name']) + { + case 'param-filter': + $this->caldav->log(__METHOD__."(...) param-filter='{$filter['attrs']['name']}' not (yet) implemented!"); + break; + case 'prop-filter': // can be multiple prop-filter, see example + if ($matches) $prop_filters[] = implode($prop_test=='allof'?' AND ':' OR ',$matches); + $matches = array(); + $prop_filter = strtoupper($filter['attrs']['name']); + $prop_test = isset($filter['attrs']['test']) ? $filter['attrs']['test'] : 'anyof'; + if ($this->debug > 1) error_log(__METHOD__."(...) prop-filter='$prop_filter', test='$prop_test'"); + break; + case 'is-not-defined': + $matches[] = '('.$column."='' OR ".$column.' IS NULL)'; + break; + case 'text-match': // prop-filter can have multiple text-match, see example + if (!isset($this->filter_prop2cal[$prop_filter])) // eg. not existing NICKNAME in EGroupware + { + if ($this->debug || $prop_filter != 'NICKNAME') error_log(__METHOD__."(...) text-match: $prop_filter {$filter['attrs']['match-type']} '{$filter['data']}' unknown property '$prop_filter' --> ignored"); + $column = false; // to ignore following data too + } + else + { + switch($filter['attrs']['collation']) // todo: which other collations allowed, we are always unicode + { + case 'i;unicode-casemap': + default: + $comp = ' '.$GLOBALS['egw']->db->capabilities[Api\Db::CAPABILITY_CASE_INSENSITIV_LIKE].' '; + break; + } + $column = $this->filter_prop2cal[strtoupper($prop_filter)]; + if (strpos($column, '_') === false) $column = 'contact_'.$column; + if (!isset($filters['order'])) $filters['order'] = $column; + $match_type = $filter['attrs']['match-type']; + $negate_condition = isset($filter['attrs']['negate-condition']) && $filter['attrs']['negate-condition'] == 'yes'; + } + break; + case '': // data of text-match element + if (isset($filter['data']) && isset($column)) + { + if ($column) // false for properties not known to EGroupware + { + $value = str_replace(array('%', '_'), array('\\%', '\\_'), $filter['data']); + switch($match_type) + { + case 'equals': + $sql_filter = $column . $comp . $GLOBALS['egw']->db->quote($value); + break; + default: + case 'contains': + $sql_filter = $column . $comp . $GLOBALS['egw']->db->quote('%'.$value.'%'); + break; + case 'starts-with': + $sql_filter = $column . $comp . $GLOBALS['egw']->db->quote($value.'%'); + break; + case 'ends-with': + $sql_filter = $column . $comp . $GLOBALS['egw']->db->quote('%'.$value); + break; + } + $matches[] = ($negate_condition ? 'NOT ' : '').$sql_filter; + + if ($this->debug > 1) error_log(__METHOD__."(...) text-match: $prop_filter $match_type' '{$filter['data']}'"); + } + unset($column); + break; + } + // fall through + default: + $this->caldav->log(__METHOD__."(".array2string($options).",,$id) unknown filter=".array2string($filter).' --> ignored'); + break; + } + } + if ($matches) $prop_filters[] = implode($prop_test=='allof'?' AND ':' OR ',$matches); + if ($prop_filters) + { + $filters[] = $filter = '(('.implode($filter_test=='allof'?') AND (':') OR (', $prop_filters).'))'; + if ($this->debug) error_log(__METHOD__."(path=$options[path], ...) sql-filter: $filter"); + } + } + // parse limit from $options['other'] + /* Example limit + + 10 + + */ + foreach((array)$options['other'] as $option) + { + switch($option['name']) + { + case 'nresults': + $nresults = (int)$option['data']; + //error_log(__METHOD__."(...) options[other]=".array2string($options['other'])." --> nresults=$nresults"); + break; + case 'limit': + break; + case 'href': + break; // from addressbook-multiget, handled below + // rfc 6578 sync-report + case 'sync-token': + if (!empty($option['data'])) + { + $parts = explode('/', $option['data']); + $sync_token = array_pop($parts); + $filters[] = 'contact_modified>'.(int)$sync_token; + $filters['tid'] = null; // to return deleted entries too + } + break; + case 'sync-level': + if ($option['data'] != '1') + { + $this->caldav->log(__METHOD__."(...) only sync-level {$option['data']} requested, but only 1 supported! options[other]=".array2string($options['other'])); + } + break; + default: + $this->caldav->log(__METHOD__."(...) unknown xml tag '{$option['name']}': options[other]=".array2string($options['other'])); + break; + } + } + // multiget --> fetch the url's + $this->requested_multiget_ids = null; + if ($options['root']['name'] == 'addressbook-multiget') + { + $this->requested_multiget_ids = []; + foreach($options['other'] as $option) + { + if ($option['name'] == 'href') + { + $parts = explode('/',$option['data']); + if (($id = urldecode(array_pop($parts)))) + { + $this->requested_multiget_ids[] = self::$path_extension ? basename($id,self::$path_extension) : $id; + } + } + } + if ($this->requested_multiget_ids) $filters[self::$path_attr] = $this->requested_multiget_ids; + if ($this->debug) error_log(__METHOD__."(...) addressbook-multiget: ids=".implode(',', $this->requested_multiget_ids)); + } + elseif ($id) + { + $filters[self::$path_attr] = self::$path_extension ? basename($id,self::$path_extension) : $id; + } + //error_log(__METHOD__."() options[other]=".array2string($options['other'])." --> filters=".array2string($filters)); + return true; + } + + /** + * Handle get request for an applications entry + * + * @param array &$options + * @param int $id + * @param int $user =null account_id + * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found') + */ + function get(&$options,$id,$user=null) + { + header('Content-Type: application/json'); + + if (!is_array($timesheet = $this->_common_get_put_delete('GET',$options,$id))) + { + return $timesheet; + } + + try + { + // jsContact or vCard + if (($type=Api\CalDAV::isJSON())) + { + $options['data'] = JsTimesheet::JsTimesheet($timesheet, $type); + $options['mimetype'] = 'application/json'; + + header('Content-Encoding: identity'); + header('ETag: "'.$this->get_etag($timesheet).'"'); + return true; + } + } + catch (\Throwable $e) { + return self::handleException($e); + } + return '501 Not Implemented'; + } + + /** + * Handle exception by returning an appropriate HTTP status and JSON content with an error message + * + * @param \Throwable $e + * @return string + */ + protected function handleException(\Throwable $e) : string + { + _egw_log_exception($e); + header('Content-Type: application/json'); + echo json_encode([ + 'error' => $code = $e->getCode() ?: 500, + 'message' => $e->getMessage(), + 'details' => $e->details ?? null, + 'script' => $e->script ?? null, + ]+(empty($GLOBALS['egw_info']['server']['exception_show_trace']) ? [] : [ + 'trace' => array_map(static function($trace) + { + $trace['file'] = str_replace(EGW_SERVER_ROOT.'/', '', $trace['file']); + return $trace; + }, $e->getTrace()) + ]), self::JSON_RESPONSE_OPTIONS); + return (400 <= $code && $code < 600 ? $code : 500).' '.$e->getMessage(); + } + + /** + * Handle put request for a contact + * + * @param array &$options + * @param int $id + * @param int $user =null account_id of owner, default null + * @param string $prefix =null user prefix from path (eg. /ralf from /ralf/addressbook) + * @param string $method='PUT' also called for POST and PATCH + * @param string $content_type=null + * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found') + */ + function put(&$options, $id, $user=null, $prefix=null, string $method='PUT', string $content_type=null) + { + $old = $this->_common_get_put_delete($method,$options,$id); + if (!is_null($old) && !is_array($old)) + { + if ($this->debug) error_log(__METHOD__."(,'$id', $user, '$prefix') returning ".array2string($old)); + return $old; + } + + $type = null; + $timesheet = JsTimesheet::parseJsTimesheet($options['content'], $old ?: [], $content_type, $method); + + /* uncomment to return parsed data for testing + header('Content-Type: application/json'); + echo json_encode($timesheet, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + return "200 Ok"; + */ + + if (is_array($old)) + { + $id = $old['id']; + $retval = true; + } + else + { + // new entry + $id = -1; + $retval = '201 Created'; + } + + if (is_array($old)) + { + $timesheet['ts_id'] = $old['id']; + // don't allow the client to overwrite certain values + $timesheet['ts_owner'] = $old['owner']; + $timesheet['ts_created'] = $old['created']; + } + else + { + // only set owner, if user is explicitly specified in URL (check via prefix, NOT for /addressbook/) or sync-all-in-one!) + if ($prefix && $user) + { + $timesheet['ts_owner'] = $user; + } + else + { + $timesheet['ts_owner'] = $GLOBALS['egw_info']['user']['account_id']; + } + } + if ($this->http_if_match) $timesheet['etag'] = self::etag2value($this->http_if_match); + + if (!($save_ok = $this->bo->save($timesheet))) + { + if ($this->debug) error_log(__METHOD__."(,$id) save(".array2string($timesheet).") failed, Ok=$save_ok"); + if ($save_ok === 0) + { + // honor Prefer: return=representation for 412 too (no need for client to explicitly reload) + $this->check_return_representation($options, $id, $user); + return '412 Precondition Failed'; + } + return '403 Forbidden'; // happens when writing new entries in AB's without ADD rights + } + + // send evtl. necessary response headers: Location, etag, ... + $this->put_response_headers($timesheet, $options['path'], $retval); + + if ($this->debug > 1) error_log(__METHOD__."(,'$id', $user, '$prefix') returning ".array2string($retval)); + return $retval; + } + + /** + * Handle delete request for an applications entry + * + * @param array &$options + * @param int $id + * @param int $user account_id of collection owner + * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found') + */ + function delete(&$options,$id,$user) + { + if (!is_array($timesheet = $this->_common_get_put_delete('DELETE',$options,$id))) + { + return $timesheet; + } + if (($ok = $this->bo->delete($timesheet['id'],self::etag2value($this->http_if_match))) === 0) + { + return '412 Precondition Failed'; + } + return $ok; + } + + /** + * Read an entry + * + * @param string|int $id + * @param string $path =null implementation can use it, used in call from _common_get_put_delete + * @return array|boolean array with entry, false if no read rights, null if $id does not exist + */ + function read($id /*,$path=null*/) + { + if (($ret = $this->bo->read($id))) + { + $ret = Api\Db::strip_array_keys($ret, 'ts_'); + } + return $ret; + } + + /** + * Check if user has the necessary rights on an entry + * + * @param int $acl Api\Acl::READ, Api\Acl::EDIT or Api\Acl::DELETE + * @param array|int $entry entry-array or id + * @return boolean null if entry does not exist, false if no access, true if access permitted + */ + function check_access($acl, $entry) + { + return $this->bo->check_acl($acl, is_array($entry) ? $entry+['ts_onwer' => $entry['owner']] : $entry); + } +} \ No newline at end of file diff --git a/timesheet/src/JsTimesheet.php b/timesheet/src/JsTimesheet.php new file mode 100644 index 0000000000..05d68980e9 --- /dev/null +++ b/timesheet/src/JsTimesheet.php @@ -0,0 +1,184 @@ + + * @package calendar + * @copyright (c) 2023 by Ralf Becker + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Timesheet; + +use EGroupware\Api; + +/** + * Rendering events as JSON using new JsCalendar format + * + * @link https://datatracker.ietf.org/doc/html/rfc8984 + * @link https://jmap.io/spec-calendars.html + */ +class JsTimesheet extends Api\CalDAV\JsBase +{ + const APP = 'timesheet'; + + const TYPE_TIMESHEET = 'timesheet'; + + /** + * Get JsEvent for given event + * + * @param int|array $event + * @param bool|"pretty" $encode true: JSON encode, "pretty": JSON encode with pretty-print, false: return raw data e.g. from listing + * @param ?array $exceptions=null + * @return string|array + * @throws Api\Exception\NotFound + */ + public static function JsTimesheet(array $timesheet, $encode=true, array $exceptions=[]) + { + static $bo = null; + if (!isset($bo)) $bo = new \timesheet_bo(); + + if (isset($timesheet['ts_id'])) + { + $timesheet = Api\Db::strip_array_keys($timesheet, 'ts_'); + } + + $data = array_filter([ + self::AT_TYPE => self::TYPE_TIMESHEET, + //'uid' => self::uid($timesheet['uid']), + 'id' => (int)$timesheet['id'], + 'title' => $timesheet['title'], + 'description' => $timesheet['description'], + 'start' => self::UTCDateTime($timesheet['start'], true), + 'duration' => (int)$timesheet['duration'], + 'quantity' => (double)$timesheet['quantity'], + 'unitprice' => (double)$timesheet['unitprice'], + 'category' => self::categories($timesheet['cat_id']), + 'owner' => self::account($timesheet['owner']), + 'created' => self::UTCDateTime($timesheet['created'], true), + 'modified' => self::UTCDateTime($timesheet['modified'], true), + 'modifier' => self::account($timesheet['modifier']), + 'pricelist' => (int)$timesheet['pl_id'] ?: null, + 'status' => $bo->status_labels[$timesheet['status']] ?? null, + 'egroupware.org:customfields' => self::customfields($timesheet), + ]); + + if ($encode) + { + return Api\CalDAV::json_encode($data, $encode === "pretty"); + } + return $data; + } + + /** + * Parse JsTimesheet + * + * @param string $json + * @param array $old=[] existing contact for patch + * @param ?string $content_type=null application/json no strict parsing and automatic patch detection, if method not 'PATCH' or 'PUT' + * @param string $method='PUT' 'PUT', 'POST' or 'PATCH' + * @return array with "ts_" prefix + */ + public static function parseJsTimesheet(string $json, array $old=[], string $content_type=null, $method='PUT') + { + try + { + $data = json_decode($json, true, 10, JSON_THROW_ON_ERROR); + + // check if we use patch: method is PATCH or method is POST AND keys contain slashes + if ($method === 'PATCH') + { + // apply patch on JsCard of contact + $data = self::patch($data, $old ? self::getJsCalendar($old, false) : [], !$old); + } + + if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist + + // check required fields + if (!$old || !$method === 'PATCH') + { + static $required = ['title', 'start', 'duration']; + if (($missing = array_diff_key(array_filter(array_intersect_key($data), array_flip($required)), array_flip($required)))) + { + throw new Api\CalDAV\JsParseException("Required field(s) ".implode(', ', $missing)." missing"); + } + } + + $timesheet = $method === 'PATCH' ? $old : []; + foreach ($data as $name => $value) + { + switch ($name) + { + case 'title': + case 'description': + $timesheet['ts_'.$name] = $value; + break; + + case 'start': + $timesheet['ts_start'] = Api\DateTime::server2user($value, 'ts'); + break; + + case 'duration': + $timesheet['ts_duration'] = self::parseInt($value); + break; + + case 'quantity': + case 'unitprice': + $timesheet['ts_'.$name] = self::parseFloat($value); + break; + + case 'owner': + $timesheet['ts_owner'] = self::parseAccount($value); + break; + + case 'category': + $timesheet['cat_id'] = self::parseCategories($value, false); + break; + + case 'status': + $timesheet['ts_status'] = self::parseStatus($value); + break; + + case 'egroupware.org:customfields': + $timesheet += self::parseCustomfields($value); + break; + + case 'prodId': + case 'created': + case 'modified': + case 'modifier': + break; + + default: + error_log(__METHOD__ . "() $name=" . json_encode($value, self::JSON_OPTIONS_ERROR) . ' --> ignored'); + break; + } + } + } + catch (\Throwable $e) { + self::handleExceptions($e, 'JsTimesheet', $name, $value); + } + + return $timesheet; + } + + /** + * Parse a status label into it's numerical ID + * + * @param string $value + * @return int|null + * @throws Api\CalDAV\JsParseException + */ + protected static function parseStatus(string $value) + { + static $bo=null; + if (!isset($bo)) $bo = new \timesheet_bo(); + + if (($status_id = array_search($value, $bo->status_labels)) === false) + { + throw new Api\CalDAV\JsParseException("Invalid status value '$value', allowed '".implode("', '", $bo->status_labels)."'"); + } + return $status_id; + } +} \ No newline at end of file