* @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('timesheet', $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']; } header('Content-Type: application/json'); try { throw new \Exception('Not Implemented', 501); } catch (\Throwable $e) { return self::handleException($e); } } /** * 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 filter GET parameter: * - filter[]= * - filter[%23 * - filter[search]= with string pattern like for search in the UI * - filter[search][%23 * - filter[search][]= * * @param array $filter * @return array */ protected function filter2col_filter(array $filter) { $cols = []; foreach($filter as $name => $value) { switch($name) { case 'search': $cols = array_merge($cols, $this->bo->search2criteria($value)); break; case 'category': case 'pricelist': $cols[$name === 'pricelist' ? 'pl_id' : 'cat_id'] = $value; break; case 'status': $value = array_map(function ($val) use ($value) { if (!is_numeric($val) || (string)(int)$val !== $val) { $val = array_search($val, $this->bo->status_labels, true); } elseif (isset($this->status_labels[$val])) { $val = (int)$val; } else { $val = false; } if ($val === false) { throw new Api\CalDAV\JsParseException("Invalid status filter value ".json_encode($value)); } return (int)$val; }, (array)$value); $cols['ts_status'] = count($value) <= 1 ? array_pop($value) : $value; break; default: if ($name[0] === '#') { $cols[$name] = $value; } else { $cols['ts_'.$name] = $value; } break; } } return $cols; } /** * 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 = $this->filter2col_filter($options['filters']) + $filters; // + to allow overwriting default owner filter (BO ensures ACL!) } 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_owner' => $entry['owner']] : $entry); } }