From 07d242e7050a05d81b068cd253730dd7857813f0 Mon Sep 17 00:00:00 2001 From: ralf Date: Mon, 5 Feb 2024 21:06:18 +0200 Subject: [PATCH] * REST API: new links collection allowing to link application entries with each other or attach files --- api/src/CalDAV.php | 22 ++- api/src/CalDAV/Handler.php | 128 ++++++++++++++++++ api/src/CalDAV/JsBase.php | 41 ++++++ api/src/Link.php | 6 +- .../Links-and-attachments.md | 116 ++++++++++++++++ doc/REST-CalDAV-CardDAV/README.md | 4 + doc/REST-CalDAV-CardDAV/Timesheet.md | 1 + infolog/inc/class.infolog_groupdav.inc.php | 25 ++++ timesheet/src/ApiHandler.php | 2 +- 9 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 doc/REST-CalDAV-CardDAV/Links-and-attachments.md diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index cc2a53064b..6200faad6a 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -1104,6 +1104,11 @@ class CalDAV extends HTTP_WebDAV_Server } if (($handler = $this->app_handler($app))) { + // handle links for all apps supporting links + if (preg_match('#/'.$app.'/'.$id.'/links/?$#', $options['path']) && self::isJSON()) + { + return $handler->getLinks($options, $id); + } return $handler->get($options,$id,$user); } error_log(__METHOD__."(".array2string($options).") 501 Not Implemented"); @@ -1536,6 +1541,12 @@ class CalDAV extends HTTP_WebDAV_Server if (($handler = $this->app_handler($app))) { + // handle links for all apps supporting links + if (preg_match('#/'.$app.'/'.$id.'/links/#', $options['path'])) + { + return $handler->createLink($options, $id); + } + // managed attachments if (isset($_GET['action']) && substr($_GET['action'], 0, 11) === 'attachment-') { @@ -2079,6 +2090,11 @@ class CalDAV extends HTTP_WebDAV_Server } if (($handler = $this->app_handler($app))) { + // handle links for all apps supporting links + if (preg_match('#/'.$app.'/'.$id.'/links/(-?\d+)$#', $options['path'], $matches)) + { + return $handler->deleteLink($options, $id, $matches[1]); + } $status = $handler->delete($options,$id,$user); // set default stati: true --> 204 No Content, false --> should be already handled if (is_bool($status)) $status = $status ? '204 No Content' : '400 Something went wrong'; @@ -2317,7 +2333,7 @@ class CalDAV extends HTTP_WebDAV_Server } // Api\WebDAV\Server encodes %, # and ? again, which leads to storing e.g. '%' as '%25' - $id = strtr(array_pop($parts), array( + $id = strtr(array_shift($parts), array( '%25' => '%', '%23' => '#', '%3F' => '?', @@ -2373,13 +2389,15 @@ class CalDAV extends HTTP_WebDAV_Server * Check if request is a possibly large, binary file upload: * - CalDAV managed attachments or * - Mail REST API attachment upload + * - REST API attachment upload to /$app/$id/links/ * * @return bool */ protected static function isFileUpload() { return (isset($_GET['action']) && in_array($_GET['action'], array('attachment-add', 'attachment-update'))) || - strpos($_SERVER['REQUEST_URI'], '/mail/attachments/'); + strpos($_SERVER['REQUEST_URI'], '/mail/attachments/') || + strpos($_SERVER['REQUEST_URI'], '/links/') && $_SERVER['REQUEST_METHOD'] === 'POST' && $_SERVER['CONTENT_TYPE'] !== 'application/json'; } /** diff --git a/api/src/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index 938b4366cb..623b037072 100644 --- a/api/src/CalDAV/Handler.php +++ b/api/src/CalDAV/Handler.php @@ -776,4 +776,132 @@ abstract class Handler } return $this->base_uri().$path.$token; } + + /** + * Create a link to another app's entry or add an attachment + * + * @param array $options + * @param string $app + * @param $id + * @param int|null $user + * @return string with http status + * @throws \InvalidArgumentException + */ + public function createLink(array $options, string $id) + { + header('Content-Type: application/json'); + if (!Api\Link::get_registry($this->app, 'title')) + { + return '501 Not implemented'; + } + // check edit-access + if (!is_array($status = $this->_common_get_put_delete('PUT', $options, $id))) + { + return $status; + } + // are we linking with another app's entry + if (substr($options['path'], -1) === '/' && $options['content_type'] === 'application/json') + { + $json = $options['content'] ?? stream_get_contents($options['stream']); + $data = json_decode($json, true, 2, JSON_THROW_ON_ERROR); + if (empty($data['app']) || empty($data['id']) || !($link = Api\Link::link($this->app, $id, $data['app'], $data['id'], $data['remark'] ?? null))) + { + return '422 Unprocessable Content'; + } + // if a special relation is given, set it + if (!empty($data['rel'])) + { + $this->setLinkRelation($id, $link, $data); + } + } + else + { + if (!is_resource($options['stream']) && isset($options['content']) && + ($options['stream'] = fopen('php://temp', 'r+'))) + { + fwrite($options['stream'], $options['content']); + fseek($options['stream'], 0); + } + if (!is_resource($options['stream']) || !($link = Api\Link::attach_file($this->app, $id, [ + 'tmp_name' => $options['stream'], + 'type' => $options['content_type'], + 'name' => explode('/links/', $options['path'], 2)[1] ?? throw new \InvalidArgumentException('Missing filename'), + ]))) + { + return '422 Unprocessable Content'; + } + } + header('Location: '.Api\Framework::getUrl(Api\Framework::link('/groupdav.php/'. + $GLOBALS['egw_info']['user']['account_lid'].'/'.$this->app.'/'.$id.'/links/'.$link))); + return '201 Created'; + } + + /** + * Setting the link relation + * + * Does nothing in general, but can be overwritten by apps e.g. InfoLog for "egroupware.org-primary" + * + * @param string|int $id + * @param int $link_id + * @param array $data values for keys "app", "id", "rel", "remark" + * @throws Api\Exception\NotFound if $id is not found or readable + * @throws Api\Exception on other errors like storing + */ + protected function setLinkRelation(string $id, int $link_id, array $data) + { + + } + + /** + * Get Links to and from an app-entry + * + * @param array $options + * @param string $id + * @return string with http status + */ + public function getLinks(array $options, string $id) + { + header('Content-Type: application/json'); + if (!Api\Link::get_registry($this->app, 'title') || !($type = Api\CalDAV::isJSON())) + { + return '501 Not implemented'; + } + // check read-access + if (!is_array($status = $this->_common_get_put_delete('GET', $options, $id))) + { + return $status; + } + echo Api\CalDAV::json_encode(['responses' => Api\CalDAV\JsBase::getLinks($options['path'], $this->app, $id)], $type === 'pretty'); + return '200 Ok'; + } + + /** + * Delete one link or attachment from an entry + * + * @param array $options + * @param string $id + * @param int $link_id + * @return string with http status + */ + public function deleteLink(array $options, string $id, int $link_id) + { + header('Content-Type: application/json'); + if (!Api\Link::get_registry($this->app, 'title')) + { + return '501 Not implemented'; + } + // check edit-access, we use PUT, as we are NOT deleting the entry itself, but a link to it or attachment + if (!is_array($status = $this->_common_get_put_delete('PUT', $options, $id))) + { + return $status; + } + if (!($link = Api\Link::get_link($link_id)) || + !($link_id < 0 && $this->app === $link['app2'] && $id == $link['id2'] || + $link_id > 0 && ($this->app === $link['link_app1'] && $id == $link['link_id1'] || + $this->app === $link['link_app2'] && $id == $link['link_id2']))) + { + return '404 Not Found'; + } + return Api\Link::unlink($link_id) ? '204 No Content' : '400 Something went wrong'; + } } \ No newline at end of file diff --git a/api/src/CalDAV/JsBase.php b/api/src/CalDAV/JsBase.php index 9cba134693..e9f5357488 100644 --- a/api/src/CalDAV/JsBase.php +++ b/api/src/CalDAV/JsBase.php @@ -292,6 +292,47 @@ class JsBase return $cat_ids ? implode(',', $cat_ids) : null; } + /** + * Get links / link-objects + * + * @param string $prefix + * @param string $app + * @param string $id + * @return array + */ + public static function getLinks(string $prefix, string $app, string $id) + { + $links = []; + foreach(Api\Link::get_links($app, $id, '', 'link_lastmod DESC', true) as $link_id => $data) + { + $path = rtrim($prefix, '/').'/'.$link_id; + if ($data['app'] === 'file') + { + $links[$path] = array_filter([ + self::AT_TYPE => 'Link', + 'href' => Api\Framework::getUrl(Api\Framework::link('/webdav.php/apps/'.$app.'/'.$id.'/'.$data['id'])), + 'contentType' => $data['type'], + 'size' => $data['size'], + 'title' => Api\Link::title($data['app'], $data['id']), + 'egroupware.org-remark' => $data['remark'], + ]); + } + else + { + $links[$path] = array_filter([ + self::AT_TYPE => 'Link', + 'href' => Api\Framework::getUrl(Api\Framework::link('/groupdav.php/'.$GLOBALS['egw_info']['user']['account_lid'].'/'.$data['app'].'/'.$data['id'])), + 'contentType' => 'application/json', + 'title' => Api\Link::title($data['app'], $data['id']), + 'egroupware.org-app' => $data['app'], + 'egroupware.org-id' => $data['id'], + 'egroupware.org-remark' => $data['remark'], + ]); + } + } + return $links; + } + /** * Patch JsCard * diff --git a/api/src/Link.php b/api/src/Link.php index 2c4ed3d8e0..5ee5bb8d62 100644 --- a/api/src/Link.php +++ b/api/src/Link.php @@ -668,7 +668,7 @@ class Link extends Link\Storage * @param string $app2 ='' app of second endpoint * @param string $id2 ='' id in $app2 * @param boolean $hold_for_purge Don't really delete the link, just mark it as deleted and wait for final delete - * @return the number of links deleted + * @return int the number of links deleted */ static function unlink($link_id,$app='',$id='',$owner=0,$app2='',$id2='',$hold_for_purge=false) { @@ -685,7 +685,7 @@ class Link extends Link\Storage * @param string $app2 ='' app of second endpoint, or !file (other !app are not yet supported!) * @param string $id2 ='' id in $app2 * @param boolean $hold_for_purge Don't really delete the link, just mark it as deleted and wait for final delete - * @return the number of links deleted + * @return int|boolean the number of links deleted */ static function unlink2($link_id,$app,&$id,$owner=0,$app2='',$id2='',$hold_for_purge=false) { @@ -1142,7 +1142,7 @@ class Link extends Link\Storage * @param string $app app-name * @param string $name name / key in the registry, eg. 'view' * @param boolean|array|string|int $url_id format entries like "add", "edit", "view" for actions "url" incl. an ID - * array to add arbitray parameter eg. ['some_id' => '$id'] + * array to add arbitrary parameter eg. ['some_id' => '$id'] * @return boolean|string false if $app is not registered, otherwise string with the value for $name */ static function get_registry($app, $name, $url_id=false) diff --git a/doc/REST-CalDAV-CardDAV/Links-and-attachments.md b/doc/REST-CalDAV-CardDAV/Links-and-attachments.md new file mode 100644 index 0000000000..17fb3fd2f3 --- /dev/null +++ b/doc/REST-CalDAV-CardDAV/Links-and-attachments.md @@ -0,0 +1,116 @@ +# EGroupware REST API for Links and attachments +* linking application entries to other application entries +* attaching files to application entries +* listing, creating and deleting links and attachments + +Authentication is via Basic Auth with username and a password, or a token valid for: +- either just the given user or all users +- CalDAV/CardDAV Sync (REST API) +- application the link or attachment is created for + +### Following schema is used for JSON encoding of links and attachments + +* @type: `Link` +* href: string URI to linked entry or attachments +* title: string title of link +* contentType: string `application/json` for links, content-type of attachments +* size: size of attachments +* egroupware.org-remark: string +* egroupware.org-app: string application name of the linked entry +* egroupware.org-id: string application ID of the linked entry +* rel: string `egroupware.org-primary` to mark a primary link for InfoLog entries + +### Supported request methods and examples + +* **GET** to application entry collections to return all links and attachments +
+ Example: Getting all links and attachments of a given application entry + +``` +curl https://example.org/egroupware/groupdav.php////links/ -H "Accept: application/pretty+json" --user +HTTP/1.1 200 Ok +Content-Type: application/json + +{ + "responses": { + "////links/": { + "@type": "Link", + "href": "https://example.org/egroupware/groupdav.php/ralf/addressbook/46", + "contentType": "application/json", + "title": "EGroupware GmbH: Becker, Ralf", + "egroupware.org-app": "addressbook", + "egroupware.org-id": "46", + "egroupware.org-remark": "Testing ;)" + }, + "////links/": { + "@type": "Link", + "href": "https://example.org/egroupware/groupdav.php/ralf/infolog/1161", + "contentType": "application/json", + "title": "Test mit primärem Link (#1161)", + "egroupware.org-app": "infolog", + "egroupware.org-id": "1161" + }, + "////links/": { + "@type": "Link", + "href": "https://example.org/egroupware/webdav.php/apps/timesheet/199/image.svg", + "contentType": "image/svg+xml", + "size": 17167, + "title": "image.svg" + } + } +} +``` +
+ +* **POST** request to upload an attachment or link with another application entry + +
+ Example: Adding a PDF as attachment to an application entry + +``` +curl -i 'https://example.org/egroupware/groupdav.php////links/' -H "Content-Type: application/pdf" --data-binary @ --user + +HTTP/1.1 204 Created +Location: https://example.org/egroupware/groupdav.php////links/ +``` +
+ +
+ Example: Creating a link from one application entry to another + +``` +curl -i 'https://example.org/egroupware/groupdav.php////links/' -H "Content-Type: application/json" --data-binary @- --user <<","id":<2nd-app-id>,"remark":"This is a test ;)"} +EOF + +HTTP/1.1 204 Created +Location: https://example.org/egroupware/groupdav.php////links/ +``` +
+ +
+ Example: Creating the primary link for an InfoLog entry + +``` +curl -i 'https://example.org/egroupware/groupdav.php//infolog//links/' -H "Content-Type: application/json" --data-binary @- --user <<","id":<2nd-app-id>,"rel":"egroupware.org-primary"} +EOF + +HTTP/1.1 204 Created +Location: https://example.org/egroupware/groupdav.php//infolog//links/ +``` +
+ +* **DELETE** request to remove a link or attachment + +
+ Example: deleting an attachment or link + +``` +curl -X DELETE 'https://example.org/egroupware/groupdav.php///links/' --user + +HTTP/1.1 201 No Content +``` +
+ +> one can use ```Accept: application/pretty+json``` to receive pretty-printed JSON eg. for debugging and exploring the API \ No newline at end of file diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md index 91905e8f70..3a97e52fab 100644 --- a/doc/REST-CalDAV-CardDAV/README.md +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -49,6 +49,10 @@ from the data of an ```allprop``` PROPFIND, allow browsing CalDAV/CardDAV tree w * view and reply to eml files and * vacation handling - [Timesheet](Timesheet.md) +- [Links and attachments](Links-and-attachments.md) + * linking application entries to other application entries + * attaching files to application entries + * listing, creating and deleting links and attachments > For the REST API you always have to send an "Accept: application/json" header and for POST & PUT requests additionally > a "Content-Type: application/json" header, otherwise you talk to the CalDAV/CardDAV server and don't get the response you expect! diff --git a/doc/REST-CalDAV-CardDAV/Timesheet.md b/doc/REST-CalDAV-CardDAV/Timesheet.md index b880aaa8e4..5242c6f087 100644 --- a/doc/REST-CalDAV-CardDAV/Timesheet.md +++ b/doc/REST-CalDAV-CardDAV/Timesheet.md @@ -13,6 +13,7 @@ Following schema is used for JSON encoding of timesheets * start: UTCDateTime e.g. `2020-02-03T14:35:37Z` * duration: integer in minutes * quantity: double +* project: string * unitprice: double * category: category object with a single(!) category-name e.g. `{"category name": true}` * owner: string with either email or username or integer ID diff --git a/infolog/inc/class.infolog_groupdav.inc.php b/infolog/inc/class.infolog_groupdav.inc.php index 3a4bf8ecd6..62c5260dba 100644 --- a/infolog/inc/class.infolog_groupdav.inc.php +++ b/infolog/inc/class.infolog_groupdav.inc.php @@ -934,4 +934,29 @@ class infolog_groupdav extends Api\CalDAV\Handler ); return $settings; } + + /** + * Setting the link relation to make an application link InfoLogs primary link + * + * @param string|int $id + * @param int $link_id + * @param array $data values for keys "app", "id", "rel", "remark" + * @throws Api\Exception\NotFound if $id is not found or readable + * @throws Api\Exception on other errors like storing + */ + protected function setLinkRelation(string $id, int $link_id, array $data) + { + if (!($info = $this->read($id)) ||) + { + throw new Api\Exception\NotFound(); + } + $info['info_link_id'] = $link_id; + $info['info_from'] = Link::titel($data['app'], $data['id']); + $info['info_custom_from'] = false; + + if (!$this->bo->write($info)) + { + throw new Api\Exception("Error storing InfoLog"); + } + } } \ No newline at end of file diff --git a/timesheet/src/ApiHandler.php b/timesheet/src/ApiHandler.php index 9bfe6195ee..6e0a97c1a3 100644 --- a/timesheet/src/ApiHandler.php +++ b/timesheet/src/ApiHandler.php @@ -711,6 +711,6 @@ class ApiHandler extends Api\CalDAV\Handler */ function check_access($acl, $entry) { - return $this->bo->check_acl($acl, is_array($entry) ? $entry+['ts_onwer' => $entry['owner']] : $entry); + return $this->bo->check_acl($acl, is_array($entry) ? $entry+['ts_owner' => $entry['owner']] : $entry); } } \ No newline at end of file