diff --git a/config/config.inc.php.dist b/config/config.inc.php.dist index 00b23b6..c305a32 100644 --- a/config/config.inc.php.dist +++ b/config/config.inc.php.dist @@ -1,80 +1,95 @@ array( 'driver' => 'seafile', 'host' => 'seacloud.cc', // when username is set to '%u' current user name and password // will be used to authenticate to this storage source 'username' => '%u', ), 'Public-Files' => array( 'driver' => 'webdav', 'baseuri' => 'https://some.host.tld/Files', 'username' => 'admin', 'password' => 'pass', ), ); */ // Manticore service URL. Enables use of WebODF collaborative editor. // Note: this URL should be accessible from Chwala host and Roundcube host as well. $config['fileapi_manticore'] = null; +// WOPI/Office service URL. Enables use of collaborative editor supporting WOPI. +// Note: this URL should be accessible from Chwala host and Roundcube host as well. +$config['fileapi_wopi_office'] = null; + +// Kolab WOPI service URL. Enables use of collaborative editor supporting WOPI. +// Note: this URL should be accessible from Chwala host and Office host as well. +$config['fileapi_wopi_service'] = null; + // Name of the user interface skin. $config['file_api_skin'] = 'default'; // Chwala UI communicates with Chwala API via HTTP protocol // The URL here is a location of Chwala API service. By default // the UI location is used with addition of /api/ suffix. $config['file_api_url'] = ''; +// Type of Chwala cache. Supported values: 'db', 'apc' and 'memcache'. +// Note: This is only for some additional data like WOPI capabilities. +$config['chwala_cache'] = 'db'; + +// lifetime of Chwala cache +// possible units: s, m, h, d, w +$config['chwala_cache_ttl'] = '1d'; // ------------------------------------------------ // SeaFile driver settings // ------------------------------------------------ // Enables SeaFile Web API conversation log $config['fileapi_seafile_debug'] = true; // Enables caching of some SeaFile information e.g. folders list // Note: 'db', 'apc' and 'memcache' are supported $config['fileapi_seafile_cache'] = 'db'; // Expiration time of SeaFile cache entries $config['fileapi_seafile_cache_ttl'] = '7d'; // Default SeaFile Web API host // Note: http:// and https:// (default) prefixes can be used here $config['fileapi_seafile_host'] = 'localhost'; // Enables SSL certificates validation when connecting // with any SeaFile server $config['fileapi_seafile_ssl_verify_host'] = false; $config['fileapi_seafile_ssl_verify_peer'] = false; // ------------------------------------------------ // WebDAV driver settings // ------------------------------------------------ // Default URI location for WebDAV storage $config['fileapi_webdav_baseuri'] = 'https://localhost/iRony'; diff --git a/lib/api/file_info.php b/lib/api/file_info.php index 3bc0201..270cb19 100644 --- a/lib/api/file_info.php +++ b/lib/api/file_info.php @@ -1,172 +1,172 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class file_api_file_info extends file_api_common { /** * Request handler */ public function handle() { parent::handle(); // check Manticore support. Note: we don't use config->get('fileapi_manticore') // here as it may be not properly set if backend driver wasn't initialized yet $capabilities = $this->api->capabilities(false); $manticore = $capabilities['MANTICORE']; $wopi = $capabilities['WOPI']; // support file_info by session ID if (!isset($this->args['file']) || $this->args['file'] === '') { if ($manticore && !empty($this->args['session'])) { $this->args['file'] = $this->file_manticore_file($this->args['session']); } else { throw new Exception("Missing file name", file_api_core::ERROR_CODE); } } if ($this->args['file'] !== null) { list($driver, $path) = $this->api->get_driver($this->args['file']); $info = $driver->file_info($path); $info['file'] = $this->args['file']; } else { $info = array( // @TODO: session exists, invitation exists, assume ODF format // however, this should be done in a different way, // e.g. this info should be stored in sessions database 'type' => 'application/vnd.oasis.opendocument.text', 'writable' => false, ); } // Possible 'viewer' types are defined in files_api.js:file_type_supported() // 1 - Native browser support // 2 - Chwala viewer exists // 4 - Editor exists (manticore/wopi) if (rcube_utils::get_boolean((string) $this->args['viewer'])) { if ($this->args['file'] !== null) { $this->file_viewer_info($info); } // check if file type is supported by webodf editor? if ($manticore) { if (strtolower($info['type']) == 'application/vnd.oasis.opendocument.text') { $info['viewer']['manticore'] = true; } } if ($wopi) { // @TODO: check supported mimetype $info['viewer']['wopi'] = true; } if ((intval($this->args['viewer']) & 4)) { // @TODO: Chwala client should have a possibility to select // between wopi and manticore? if ($info['viewer']['wopi']) { $this->file_wopi_handler($info); } else if ($info['viewer']['manticore']) { $this->file_manticore_handler($info); } } } - $this->file_wopi_handler($info); - // check writable flag if ($this->args['file'] !== null) { $path = explode(file_storage::SEPARATOR, $path); array_pop($path); $path = implode(file_storage::SEPARATOR, $path); $acl = $driver->folder_rights($path); $info['writable'] = ($acl & file_storage::ACL_WRITE) != 0; } return $info; } /** * Merge file viewer data into file info */ protected function file_viewer_info(&$info) { $file = $this->args['file']; $viewer = $this->find_viewer($info['type']); if ($viewer) { $info['viewer'] = array(); if ($frame = $viewer->frame($file, $info['type'])) { $info['viewer']['frame'] = $frame; } else if ($href = $viewer->href($file, $info['type'])) { $info['viewer']['href'] = $href; } } } /** * Merge manticore session data into file info */ protected function file_manticore_handler(&$info) { $manticore = new file_manticore($this->api); $file = $this->args['file']; $session = $this->args['session']; - if ($uri = $manticore->session_start($file, $session)) { + if ($uri = $manticore->session_start($file, $info['type'], $session)) { $info['viewer']['href'] = $uri; + $info['viewer']['post'] = $manticore->editor_post_params($info); $info['session'] = $manticore->session_info($session, true); } } /** * Get file from manticore session */ protected function file_manticore_file($session_id) { $manticore = new file_manticore($this->api); return $manticore->session_file($session_id, true); } /** * Merge WOPI session data into file info */ protected function file_wopi_handler(&$info) { $wopi = new file_wopi($this->api); $file = $this->args['file']; $session = $this->args['session']; - if ($uri = $wopi->session_start($file, $session)) { + if ($uri = $wopi->session_start($file, $info['type'], $session)) { $info['viewer']['href'] = $uri; + $info['viewer']['post'] = $wopi->editor_post_params($info); $info['session'] = $wopi->session_info($session, true); } } } diff --git a/lib/file_api_core.php b/lib/file_api_core.php index bde68fc..bc93479 100644 --- a/lib/file_api_core.php +++ b/lib/file_api_core.php @@ -1,374 +1,374 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class file_api_core extends file_locale { const API_VERSION = 2; const ERROR_CODE = 500; const ERROR_INVALID = 501; const OUTPUT_JSON = 'application/json'; const OUTPUT_HTML = 'text/html'; public $env = array( 'date_format' => 'Y-m-d H:i', 'language' => 'en_US', ); protected $app_name = 'Kolab File API'; protected $drivers = array(); protected $icache = array(); protected $backend; /** * Returns API version */ public function client_version() { return self::API_VERSION; } /** * Initialise authentication/configuration backend class * * @return file_storage Main storage driver */ public function get_backend() { if ($this->backend) { return $this->backend; } $rcube = rcube::get_instance(); $driver = $rcube->config->get('fileapi_backend', 'kolab'); $this->backend = $this->load_driver_object($driver); // configure api $this->backend->configure($this->env); return $this->backend; } /** * Return supported/enabled external storage instances * * @param bool $as_objects Return drivers as objects not config data * * @return array List of storage drivers */ public function get_drivers($as_objects = false) { $rcube = rcube::get_instance(); $enabled = $rcube->config->get('fileapi_drivers'); $preconf = $rcube->config->get('fileapi_sources'); $result = array(); $all = array(); $iRony = defined('KOLAB_DAV_ROOT'); if (!empty($enabled)) { $backend = $this->get_backend(); $drivers = $backend->driver_list(); foreach ($drivers as $item) { // Disable webdav sources/drivers in iRony that point to the // same host to prevent infinite recursion if ($iRony && $item['driver'] == 'webdav') { $self_url = parse_url($_SERVER['SCRIPT_URI']); $item_url = parse_url($item['host']); if ($self_url['host'] == $item_url['host']) { continue; } } $all[] = $item['title']; if ($item['enabled'] && in_array($item['driver'], (array) $enabled)) { $result[] = $as_objects ? $this->get_driver_object($item) : $item; } } } if (empty($result) && !empty($preconf)) { foreach ((array) $preconf as $title => $item) { if (!in_array($title, $all)) { $item['title'] = $title; $item['admin'] = true; $result[] = $as_objects ? $this->get_driver_object($item) : $item; } } } return $result; } /** * Return driver for specified file/folder path * * @param string $path Folder/file path * * @return array Storage driver object, modified path, driver config */ public function get_driver($path) { $drivers = $this->get_drivers(); foreach ($drivers as $item) { $prefix = $item['title'] . file_storage::SEPARATOR; if ($path == $item['title'] || strpos($path, $prefix) === 0) { $selected = $item; break; } } if (empty($selected)) { return array($this->get_backend(), $path); } $path = substr($path, strlen($selected['title']) + 1); return array($this->get_driver_object($selected), $path, $selected); } /** * Initialize driver instance * * @param array $config Driver config * * @return file_storage Storage driver instance */ public function get_driver_object($config) { $key = $config['title']; if (empty($this->drivers[$key])) { $this->drivers[$key] = $driver = $this->load_driver_object($config['driver']); if ($config['username'] == '%u') { $backend = $this->get_backend(); $auth_info = $backend->auth_info(); $config['username'] = $auth_info['username']; $config['password'] = $auth_info['password']; } else if (!empty($config['password']) && empty($config['admin']) && !empty($key)) { $config['password'] = $this->decrypt($config['password']); } // configure api $driver->configure(array_merge($config, $this->env), $key); } return $this->drivers[$key]; } /** * Loads a driver */ public function load_driver_object($name) { $class = $name . '_file_storage'; if (!class_exists($class, false)) { $include_path = __DIR__ . "/drivers/$name" . PATH_SEPARATOR; $include_path .= ini_get('include_path'); set_include_path($include_path); } return new $class; } /** * Returns storage(s) capabilities * * @param bool $full Return all drivers' capabilities * * @return array Capabilities */ public function capabilities($full = true) { $rcube = rcube::get_instance(); $backend = $this->get_backend(); $caps = array(); // check support for upload progress if (($progress_sec = $rcube->config->get('upload_progress')) && ini_get('apc.rfc1867') && function_exists('apc_fetch') ) { $caps[file_storage::CAPS_PROGRESS_NAME] = ini_get('apc.rfc1867_name'); $caps[file_storage::CAPS_PROGRESS_TIME] = $progress_sec; } // get capabilities of main storage module foreach ($backend->capabilities() as $name => $value) { // skip disabled capabilities if ($value !== false) { $caps[$name] = $value; } } // Manticore support if ($rcube->config->get('fileapi_manticore')) { $caps['MANTICORE'] = true; } // WOPI support - if ($rcube->config->get('fileapi_wopi')) { + if ($rcube->config->get('fileapi_wopi_office')) { $caps['WOPI'] = true; } if (!$full) { return $caps; } // get capabilities of other drivers $drivers = $this->get_drivers(true); foreach ($drivers as $driver) { if ($driver != $backend) { $title = $driver->title(); foreach ($driver->capabilities() as $name => $value) { // skip disabled capabilities if ($value !== false) { $caps['MOUNTPOINTS'][$title][$name] = $value; } } } } return $caps; } /** * Get user name from user identifier (email address) using LDAP lookup * * @param string $email User identifier * * @return string User name */ public function resolve_user($email) { $key = "user:$email"; // make sure Kolab backend is initialized so kolab_storage can be found $this->get_backend(); // @todo: Move this into drivers if ($this->icache[$key] === null && class_exists('kolab_storage') && ($ldap = kolab_storage::ldap()) ) { $user = $ldap->get_user_record($email, $_SESSION['imap_host']); $this->icache[$key] = $user ?: false; } if ($this->icache[$key]) { return $this->icache[$key]['displayname'] ?: $this->icache[$key]['name']; } } /** * Return mimetypes list supported by built-in viewers * * @return array List of mimetypes */ protected function supported_mimetypes() { $mimetypes = array(); $dir = __DIR__ . '/viewers'; if ($handle = opendir($dir)) { while (false !== ($file = readdir($handle))) { if (preg_match('/^([a-z0-9_]+)\.php$/i', $file, $matches)) { include_once $dir . '/' . $file; $class = 'file_viewer_' . $matches[1]; $viewer = new $class($this); $mimetypes = array_merge($mimetypes, $viewer->supported_mimetypes()); } } closedir($handle); } return $mimetypes; } /** * Encrypts data with current user password * * @param string $str A string to encrypt * * @return string Encrypted string (and base64-encoded) */ public function encrypt($str) { $rcube = rcube::get_instance(); $key = $this->get_crypto_key(); return $rcube->encrypt($str, $key, true); } /** * Decrypts data encrypted with encrypt() method * * @param string $str Encrypted string (base64-encoded) * * @return string Decrypted string */ public function decrypt($str) { $rcube = rcube::get_instance(); $key = $this->get_crypto_key(); return $rcube->decrypt($str, $key, true); } /** * Set encryption password */ protected function get_crypto_key() { $key = 'chwala_crypto_key'; $rcube = rcube::get_instance(); $backend = $this->get_backend(); $user = $backend->auth_info(); $password = $user['password'] . $user['username']; // encryption password must be 24 characters, no less, no more if (($len = strlen($password)) > 24) { $password = substr($password, 0, 24); } else { $password = $password . substr($rcube->config->get('des_key'), 0, 24 - $len); } $rcube->config->set($key, $password); return $key; } } diff --git a/lib/file_document.php b/lib/file_document.php index f635104..76afd4b 100644 --- a/lib/file_document.php +++ b/lib/file_document.php @@ -1,761 +1,774 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Document editing sessions handling */ class file_document { protected $api; protected $rc; protected $user; protected $sessions_table = 'chwala_sessions'; protected $invitations_table = 'chwala_invitations'; protected $icache = array(); const STATUS_INVITED = 'invited'; const STATUS_REQUESTED = 'requested'; const STATUS_ACCEPTED = 'accepted'; const STATUS_DECLINED = 'declined'; const STATUS_DECLINED_OWNER = 'declined-owner'; // same as 'declined' but done by the session owner const STATUS_ACCEPTED_OWNER = 'accepted-owner'; // same as 'accepted' but done by the session owner /** * Class constructor * * @param file_api Chwala API app instance */ public function __construct($api) { $this->rc = rcube::get_instance(); $this->api = $api; $this->user = $_SESSION['user']; $db = $this->rc->get_dbh(); $this->sessions_table = $db->table_name($this->sessions_table); $this->invitations_table = $db->table_name($this->invitations_table); } /** * Return viewer URI for specified file/session. This creates * a new collaborative editing session when needed. * * @param string $file File path + * @param string $mimetype File type * @param string &$session_id Optional session ID to join to * * @return string An URI for specified file/session * @throws Exception */ - public function session_start($file, &$session_id = null) + public function session_start($file, $mimetype, &$session_id = null) { if ($file !== null) { $uri = $this->path2uri($file, $driver); } $backend = $this->api->get_backend(); if ($session_id) { $session = $this->session_info($session_id); if (empty($session)) { throw new Exception("Document session not found.", file_api_core::ERROR_CODE); } // check session ownership if ($session['owner'] != $this->user) { // check if the user was invited $invitations = $this->invitations_find(array('session_id' => $session_id, 'user' => $this->user)); $states = array(self::STATUS_INVITED, self::STATUS_ACCEPTED, self::STATUS_ACCEPTED_OWNER); if (empty($invitations) || !in_array($invitations[0]['status'], $states)) { throw new Exception("No permission to join the editing session.", file_api_core::ERROR_CODE); } // automatically accept the invitation, if not done yet if ($invitations[0]['status'] == self::STATUS_INVITED) { $this->invitation_update($session_id, $this->user, self::STATUS_ACCEPTED); } } } else if (!empty($uri)) { // To prevent from creating new sessions for the same file+user // (e.g. when user uses F5 to refresh the page), we check first // if such a session exist and continue with it $db = $this->rc->get_dbh(); $res = $db->query("SELECT `id` FROM `{$this->sessions_table}`" . " WHERE `owner` = ? AND `uri` = ?", $this->user, $uri); if ($row = $db->fetch_assoc($res)) { $session_id = $row['id']; $res = true; } else if (!$db->is_error($res)) { $session_id = rcube_utils::bin2ascii(md5(time() . $uri, true)); $data = array(); $owner = $this->user; // we'll store user credentials if the file comes from // an external source that requires authentication if ($backend != $driver) { $auth = $driver->auth_info(); $auth['password'] = $this->rc->encrypt($auth['password']); $data['auth_info'] = $auth; } $res = $this->session_create($session_id, $uri, $owner, $data); } if (!$res) { throw new Exception("Failed creating document editing session", file_api_core::ERROR_CODE); } } else { throw new Exception("Failed creating document editing session (unknown file)", file_api_core::ERROR_CODE); } // Implementations should return real URI return ''; } /** * Get file path (not URI) from session. * * @param string $id Session ID * @param bool $join_mode Throw exception only if session does not exist * * @return string File path * @throws Exception */ public function session_file($id, $join_mode = false) { $session = $this->session_info($id); if (empty($session)) { throw new Exception("Document session not found.", file_api_core::ERROR_CODE); } $path = $this->uri2path($session['uri']); if (empty($path) && (!$join_mode || $session['owner'] == $this->user)) { throw new Exception("Document session not found.", file_api_core::ERROR_CODE); } // check permissions to the session if ($session['owner'] != $this->user) { $invitations = $this->invitations_find(array('session_id' => $id, 'user' => $this->user)); $states = array(self::STATUS_INVITED, self::STATUS_ACCEPTED, self::STATUS_ACCEPTED_OWNER); if (empty($invitations) || !in_array($invitations[0]['status'], $states)) { throw new Exception("No permission to join the editing session.", file_api_core::ERROR_CODE); } } return $path; } /** * Get editing session info * * @param string $id Session identifier * @param bool $with_invitations Return invitations list * * @return array Session data */ public function session_info($id, $with_invitations = false) { $session = $this->icache["session:$id"]; if (!$session) { $db = $this->rc->get_dbh(); $result = $db->query("SELECT * FROM `{$this->sessions_table}`" . " WHERE `id` = ?", $id); if ($row = $db->fetch_assoc($result)) { $session = $this->session_info_parse($row); $this->icache["session:$id"] = $session; } } if ($session) { if ($session['owner'] == $this->user) { $session['is_owner'] = true; } if ($with_invitations && $session['is_owner']) { $session['invitations'] = $this->invitations_find(array('session_id' => $id)); } } return $session; } /** * Find editing sessions for specified path */ public function session_find($path, $invitations = true) { // create an URI for specified path $uri = trim($this->path2uri($path), '/') . '/'; // get existing sessions $sessions = array(); $filter = array('file', 'owner', 'owner_name', 'is_owner'); $db = $this->rc->get_dbh(); $result = $db->query("SELECT * FROM `{$this->sessions_table}`" . " WHERE `uri` LIKE '" . $db->escape($uri) . "%'"); while ($row = $db->fetch_assoc($result)) { if ($path = $this->uri2path($row['uri'])) { $sessions[$row['id']] = $this->session_info_parse($row, $path, $filter); } } // set 'is_invited' flag if ($invitations && !empty($sessions)) { $invitations = $this->invitations_find(array('user' => $this->user)); $states = array(self::STATUS_INVITED, self::STATUS_ACCEPTED, self::STATUS_ACCEPTED_OWNER); foreach ($invitations as $invitation) { if (!empty($sessions[$invitation['session_id']]) && in_array($invitation['status'], $states)) { $sessions[$invitation['session_id']]['is_invited'] = true; } } } return $sessions; } /** * Delete editing session (only owner can do that) * * @param string $id Session identifier */ public function session_delete($id) { $db = $this->rc->get_dbh(); $result = $db->query("DELETE FROM `{$this->sessions_table}`" . " WHERE `id` = ? AND `owner` = ?", $id, $this->user); return $db->affected_rows($result) > 0; } /** * Create editing session */ protected function session_create($id, $uri, $owner, $data) { // get user name $owner_name = $this->api->resolve_user($owner) ?: ''; $db = $this->rc->get_dbh(); $result = $db->query("INSERT INTO `{$this->sessions_table}`" . " (`id`, `uri`, `owner`, `owner_name`, `data`)" . " VALUES (?, ?, ?, ?, ?)", $id, $uri, $owner, $owner_name, json_encode($data)); $success = $db->affected_rows($result) > 0; if ($success) { // @TODO } return $success; } /** * Find sessions, including: * 1. to which the user has access (is a creator or has been invited) * 2. to which the user is considered eligible to request authorization * to participate in the session by already having access to the file * * @param array $param List parameters * * @return array Sessions list */ public function sessions_list($param = array()) { $db = $this->rc->get_dbh(); $sessions = array(); // 1. Get sessions user has access to $result = $db->query("SELECT s.`id`, s.`uri`, s.`owner`, s.`owner_name`" . " FROM `{$this->sessions_table}` s" . " WHERE s.`owner` = ? OR s.`id` IN (" . "SELECT i.`session_id` FROM `{$this->invitations_table}` i" . " WHERE i.`user` = ?" . ")", $this->user, $this->user); if ($db->is_error($result)) { throw new Exception("Internal error.", file_api_core::ERROR_CODE); } while ($row = $db->fetch_assoc($result)) { if ($path = $this->uri2path($row['uri'], true)) { $sessions[$row['id']] = $this->session_info_parse($row, $path); // For performance reasons we don't want to fetch info of every file // on the list. As we support only ODT files here... $sessions[$row['id']]['type'] = 'application/vnd.oasis.opendocument.text'; } } // 2. Get sessions user is eligible // - get list of all folder URIs and find sessions for files in these locations // @FIXME: in corner cases (user has many folders) this may produce a big query, // maybe fetching all sessions and then comparing with list of locations would be faster? $uris = $this->all_folder_locations(); $where = array_map(function($uri) use ($db) { return 's.`uri` LIKE ' . $db->quote(str_replace('%', '_', $uri) . '/%'); }, $uris); $result = $db->query("SELECT s.`id`, s.`uri`, s.`owner`, s.`owner_name`" . " FROM `{$this->sessions_table}` s WHERE " . join(' OR ', $where)); if ($db->is_error($result)) { throw new Exception("Internal error.", file_api_core::ERROR_CODE); } while ($row = $db->fetch_assoc($result)) { if (empty($sessions[$row['id']])) { // remove filename (and anything after it) so we have the folder URI // to check if it's on the folders list we have $uri = substr($row['uri'], 0, strrpos($row['uri'], '/')); if (in_array($uri, $uris) && ($path = $this->uri2path($row['uri'], true))) { $sessions[$row['id']] = $this->session_info_parse($row, $path); // For performance reasons we don't want to fetch info of every file // on the list. As we support only ODT files here... $sessions[$row['id']]['type'] = 'application/vnd.oasis.opendocument.text'; } } } // set 'is_invited' flag if (!empty($sessions)) { $invitations = $this->invitations_find(array('user' => $this->user)); $states = array(self::STATUS_INVITED, self::STATUS_ACCEPTED, self::STATUS_ACCEPTED_OWNER); foreach ($invitations as $invitation) { if (!empty($sessions[$invitation['session_id']]) && in_array($invitation['status'], $states)) { $sessions[$invitation['session_id']]['is_invited'] = true; } } } // Sorting $sort = !empty($params['sort']) ? $params['sort'] : 'name'; $index = array(); if (in_array($sort, array('name', 'file', 'owner'))) { foreach ($sessions as $key => $val) { if ($sort == 'name' || $sort == 'file') { $path = explode(file_storage::SEPARATOR, $val['file']); $index[$key] = $path[count($path) - 1]; continue; } $index[$key] = $val[$sort]; } array_multisort($index, SORT_ASC, SORT_LOCALE_STRING, $sessions); } if ($params['reverse']) { $sessions = array_reverse($sessions, true); } return $sessions; } + /** + * Retern extra editor parameters to post the the viewer iframe + * + * @param array $info File info + * + * @return array POST parameters + */ + public function editor_post_params($info) + { + return array(); + } + /** * Find invitations for current user. This will return all * invitations related to the user including his sessions. * * @param array $filter Search filter (see self::invitations_find()) * * @return array Invitations list */ public function invitations_list($filter = array()) { $filter['user'] = $this->user; // list of invitations to the user or requested by him $result = $this->invitations_find($filter, true); unset($filter['user']); $filter['owner'] = $this->user; // other invitations that belong to the sessions owned by the user if ($other = $this->invitations_find($filter, true)) { $result = array_merge($result, $other); } return $result; } /** * Find invitations for specified filter * * @param array $filter Search filter (see self::invitations_find()) * - session_id: session identifier * - timestamp: "changed > ?" filter * - user: Invitation user identifier * - owner: Session owner identifier * @param bool $extended Return session file names * * @return array Invitations list */ public function invitations_find($filter, $extended = false) { $db = $this->rc->get_dbh(); $query = ''; $select = "i.*"; foreach ($filter as $column => $value) { if ($column == 'timestamp') { $where[] = "i.`changed` > " . $db->fromunixtime($value); } else if ($column == 'owner') { $join[] = "`{$this->sessions_table}` s ON (i.`session_id` = s.`id`)"; $where[] = "s.`owner` = " . $db->quote($value); } else { $where[] = "i.`$column` = " . $db->quote($value); } } if ($extended) { $select .= ", s.`uri`, s.`owner`, s.`owner_name`"; $join[] = "`{$this->sessions_table}` s ON (i.`session_id` = s.`id`)"; } if (!empty($join)) { $query .= ' JOIN ' . implode(' JOIN ', array_unique($join)); } if (!empty($where)) { $query .= ' WHERE ' . implode(' AND ', array_unique($where)); } $result = $db->query("SELECT $select FROM `{$this->invitations_table}` i" . "$query ORDER BY i.`changed`"); if ($db->is_error($result)) { throw new Exception("Internal error.", file_api_core::ERROR_CODE); } $invitations = array(); while ($row = $db->fetch_assoc($result)) { if ($extended) { try { // add unix-timestamp of the `changed` date to the result $dt = new DateTime($row['changed']); $row['timestamp'] = $dt->format('U'); } catch(Exception $e) { } // add filename to the result $filename = parse_url($row['uri'], PHP_URL_PATH); $filename = pathinfo($filename, PATHINFO_BASENAME); $filename = rawurldecode($filename); $row['filename'] = $filename; if ($path = $this->uri2path($row['uri'])) { $row['file'] = $path; } unset($row['uri']); } $invitations[] = $row; } return $invitations; } /** * Create an invitation * * @param string $session_id Document session identifier * @param string $user User identifier (use null for current user) * @param string $status Invitation status (invited, requested) * @param string $comment Invitation description/comment * @param string &$user_name Optional user name * * @throws Exception */ public function invitation_create($session_id, $user, $status = 'invited', $comment = '', &$user_name = '') { if (empty($user)) { $user = $this->user; } if ($status != self::STATUS_INVITED && $status != self::STATUS_REQUESTED) { throw new Exception("Invalid invitation status.", file_api_core::ERROR_CODE); } // get session information $session = $this->session_info($session_id); if (empty($session)) { throw new Exception("Document session not found.", file_api_core::ERROR_CODE); } // check session ownership, only owner can create 'new' invitations if ($status == self::STATUS_INVITED && $session['owner'] != $this->user) { throw new Exception("No permission to create an invitation.", file_api_core::ERROR_CODE); } if ($session['owner'] == $user) { throw new Exception("Not possible to create an invitation for the session creator.", file_api_core::ERROR_CODE); } // get user name if (empty($user_name)) { $user_name = $this->api->resolve_user($user) ?: ''; } // insert invitation $db = $this->rc->get_dbh(); $result = $db->query("INSERT INTO `{$this->invitations_table}`" . " (`session_id`, `user`, `user_name`, `status`, `comment`, `changed`)" . " VALUES (?, ?, ?, ?, ?, " . $db->now() . ")", $session_id, $user, $user_name, $status, $comment ?: ''); if (!$db->affected_rows($result)) { throw new Exception("Failed to create an invitation.", file_api_core::ERROR_CODE); } } /** * Delete an invitation (only session owner can do that) * * @param string $session_id Session identifier * @param string $user User identifier * @param bool $local Remove invitation only from local database * * @throws Exception */ public function invitation_delete($session_id, $user, $local = false) { $db = $this->rc->get_dbh(); $result = $db->query("DELETE FROM `{$this->invitations_table}`" . " WHERE `session_id` = ? AND `user` = ?" . " AND EXISTS (SELECT 1 FROM `{$this->sessions_table}` WHERE `id` = ? AND `owner` = ?)", $session_id, $user, $session_id, $this->user); if (!$db->affected_rows($result)) { throw new Exception("Failed to delete an invitation.", file_api_core::ERROR_CODE); } } /** * Update an invitation status * * @param string $session_id Session identifier * @param string $user User identifier (use null for current user) * @param string $status Invitation status (accepted, declined) * @param string $comment Invitation description/comment * * @throws Exception */ public function invitation_update($session_id, $user, $status, $comment = '') { if (empty($user)) { $user = $this->user; } if ($status != self::STATUS_ACCEPTED && $status != self::STATUS_DECLINED) { throw new Exception("Invalid invitation status.", file_api_core::ERROR_CODE); } // get session information $session = $this->session_info($session_id); if (empty($session)) { throw new Exception("Document session not found.", file_api_core::ERROR_CODE); } // check session ownership if ($user != $this->user && $session['owner'] != $this->user) { throw new Exception("No permission to update an invitation.", file_api_core::ERROR_CODE); } if ($session['owner'] == $this->user) { $status = $status . '-owner'; } $db = $this->rc->get_dbh(); $result = $db->query("UPDATE `{$this->invitations_table}`" . " SET `status` = ?, `comment` = ?, `changed` = " . $db->now() . " WHERE `session_id` = ? AND `user` = ?", $status, $comment ?: '', $session_id, $user); if (!$db->affected_rows($result)) { throw new Exception("Failed to update an invitation status.", file_api_core::ERROR_CODE); } } /** * Update a session URI (e.g. on file/folder move) * * @param string $from Source file/folder path * @param string $to Destination file/folder path * @param bool $is_folder True if the path is a folder */ public function session_uri_update($from, $to, $is_folder = false) { $db = $this->rc->get_dbh(); // Resolve paths $from = $this->path2uri($from); $to = $this->path2uri($to); if ($is_folder) { $set = "`uri` = REPLACE(`uri`, " . $db->quote($from . '/') . ", " . $db->quote($to .'/') . ")"; $where = "`uri` LIKE " . $db->quote(str_replace('%', '_', $from) . '/%'); } else { $set = "`uri` = " . $db->quote($to); $where = "`uri` = " . $db->quote($from); } $db->query("UPDATE `{$this->sessions_table}` SET $set WHERE $where"); } /** * Parse session info data */ protected function session_info_parse($record, $path = null, $filter = array()) { $session = array(); $fields = array('id', 'uri', 'owner', 'owner_name'); foreach ($fields as $field) { if (isset($record[$field])) { $session[$field] = $record[$field]; } } if ($path) { $session['file'] = $path; } // @TODO: is_invited?, last_modified? if ($session['owner'] == $this->user) { $session['is_owner'] = true; } if (!empty($filter)) { $session = array_intersect_key($session, array_flip($filter)); } return $session; } /** * Get file URI from path */ protected function path2uri($path, &$driver = null) { list($driver, $path) = $this->api->get_driver($path); return $driver->path2uri($path); } /** * Get file path from the URI */ protected function uri2path($uri, $use_fallback = false) { $backend = $this->api->get_backend(); try { return $backend->uri2path($uri); } catch (Exception $e) { // do nothing } foreach ($this->api->get_drivers(true) as $driver) { try { $path = $driver->uri2path($uri); $title = $driver->title(); if ($title) { $path = $title . file_storage::SEPARATOR . $path; } return $path; } catch (Exception $e) { // do nothing } } // likely user has no access to the file, but has been invited, // extract filename from the URI if ($use_fallback && $uri) { $path = parse_url($uri, PHP_URL_PATH); $path = explode('/', $path); $path = $path[count($path) - 1]; return $path; } } /** * Get URI of all user folders (with shared locations) */ protected function all_folder_locations() { $locations = array(); foreach (array_merge(array($this->api->get_backend()), $this->api->get_drivers(true)) as $driver) { // Performance optimization: We're interested here in shared folders, // Kolab is the only driver that currently supports them, ignore others if (get_class($driver) != 'kolab_file_storage') { continue; } try { foreach ($driver->folder_list() as $folder) { if ($uri = $driver->path2uri($folder)) { $locations[] = $uri; } } } catch (Exception $e) { // do nothing } } return $locations; } } diff --git a/lib/file_manticore.php b/lib/file_manticore.php index e842671..54cf990 100644 --- a/lib/file_manticore.php +++ b/lib/file_manticore.php @@ -1,222 +1,223 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Document editing sessions handling (Manticore) */ class file_manticore extends file_document { protected $request; /** * Return viewer URI for specified file/session. This creates * a new collaborative editing session when needed. * * @param string $file File path + * @param string $mimetype File type * @param string &$session_id Optional session ID to join to * * @return string Manticore URI * @throws Exception */ - public function session_start($file, &$session_id = null) + public function session_start($file, $mimetype, &$session_id = null) { parent::session_start($file, $session_id); // authenticate to Manticore, we need auth token for frame_uri if (empty($_SESSION['manticore_token'])) { $this->get_request(); } // @TODO: make sure the session exists in Manticore? return $this->frame_uri($session_id); } /** * Delete editing session (only owner can do that) * * @param string $id Session identifier * @param bool $local Remove session only from local database */ public function session_delete($id, $local = false) { $success = parent::session_delete($id, $local); // Send document delete to Manticore if ($success && !$local) { $req = $this->get_request(); $res = $req->document_delete($id); } return $success; } /** * Create editing session */ protected function session_create($id, $uri, $owner, $data) { $success = parent::session_create($id, $uri, $owner, $data); // create the session in Manticore if ($success) { $req = $this->get_request(); $res = $req->document_create(array( 'id' => $id, 'title' => '', // @TODO: maybe set to a file path without extension? 'access' => array( array( 'identity' => $owner, 'permission' => file_manticore_api::ACCESS_WRITE, ), ), )); if (!$res) { $this->session_delete($id, true); return false; } } return $success; } /** * Create an invitation * * @param string $session_id Document session identifier * @param string $user User identifier (use null for current user) * @param string $status Invitation status (invited, requested) * @param string $comment Invitation description/comment * @param string &$user_name Optional user name * * @throws Exception */ public function invitation_create($session_id, $user, $status = 'invited', $comment = '', &$user_name = '') { parent::invitation_create($session_id, $user, $status, $comment, $user_name); // Update Manticore 'access' array if ($status == file_document::STATUS_INVITED) { $req = $this->get_request(); $res = $req->editor_add($session_id, $user, file_manticore_api::ACCESS_WRITE); if (!$res) { $this->invitation_delete($session_id, $user, true); throw new Exception("Failed to create an invitation.", file_api_core::ERROR_CODE); } } } /** * Delete an invitation (only session owner can do that) * * @param string $session_id Session identifier * @param string $user User identifier * @param bool $local Remove invitation only from local database * * @throws Exception */ public function invitation_delete($session_id, $user, $local = false) { parent::invitation_delete($session_id, $user, $local); // Update Manticore 'access' array if (!$local) { $req = $this->get_request(); $res = $req->editor_delete($session_id, $user); if (!$res) { throw new Exception("Failed to remove an invitation.", file_api_core::ERROR_CODE); } } } /** * Update an invitation status * * @param string $session_id Session identifier * @param string $user User identifier (use null for current user) * @param string $status Invitation status (accepted, declined) * @param string $comment Invitation description/comment * * @throws Exception */ public function invitation_update($session_id, $user, $status, $comment = '') { parent::invitation_update($session_id, $user, $status, $comment); // Update Manticore 'access' array if an owner accepted an invitation request if ($status == file_document::STATUS_ACCEPTED_OWNER) { $req = $this->get_request(); $res = $req->editor_add($session_id, $user, file_manticore_api::ACCESS_WRITE); if (!$res) { throw new Exception("Failed to update an invitation status.", file_api_core::ERROR_CODE); } } } /** * Generate URI of Manticore editing session */ protected function frame_uri($id) { $base_url = rtrim($this->rc->config->get('fileapi_manticore'), ' /'); return $base_url . '/document/' . $id . '/' . $_SESSION['manticore_token']; } /** * Initialize Manticore API request handler */ protected function get_request() { if (!$this->request) { $uri = rcube_utils::resolve_url($this->rc->config->get('fileapi_manticore')); $this->request = new file_manticore_api($uri); // Use stored session token, check if it's still valid if ($_SESSION['manticore_token']) { $is_valid = $this->request->set_session_token($_SESSION['manticore_token'], true); if ($is_valid) { return $this->request; } } $backend = $this->api->get_backend(); $auth = $backend->auth_info(); $_SESSION['manticore_token'] = $this->request->login($auth['username'], $auth['password']); if (empty($_SESSION['manticore_token'])) { throw new Exception("Unable to login to Manticore server.", file_api_core::ERROR_CODE); } } return $this->request; } } diff --git a/lib/file_wopi.php b/lib/file_wopi.php index 8775d55..8073ae0 100644 --- a/lib/file_wopi.php +++ b/lib/file_wopi.php @@ -1,96 +1,267 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Document editing sessions handling (WOPI) */ class file_wopi extends file_document { + protected $cache; + /** * Return viewer URI for specified file/session. This creates * a new collaborative editing session when needed. * * @param string $file File path + * @param string $mimetype File type * @param string &$session_id Optional session ID to join to * * @return string WOPI URI for specified document * @throws Exception */ - public function session_start($file, &$session_id = null) + public function session_start($file, $mimetype, &$session_id = null) { parent::session_start($file, $session_id); if ($session_id) { // Create Chwala session for use as WOPI access_token // This session will have access to this one document session only $keys = array('language', 'user_id', 'user', 'username', 'password', 'storage_host', 'storage_port', 'storage_ssl'); $data = array_intersect_key($_SESSION, array_flip($keys)); $data['document_session'] = $session_id; - $token = $this->api->session->create($data); -rcube::console('-----' . $token); -rcube::console($data); + + $this->token = $this->api->session->create($data); } - return $this->frame_uri($session_id, $token); + return $this->frame_uri($session_id, $mimetype); } /** * Generate URI of WOPI editing session (WOPIsrc) */ - protected function frame_uri($id, $token) + protected function frame_uri($id, $mimetype) { - $office_url = rtrim($this->rc->config->get('fileapi_wopi_office'), ' /'); // Collabora - $service_url = rtrim($this->rc->config->get('fileapi_wopi_service'), ' /'); // kolab-wopi + $capabilities = $this->capabilities(); - // https://wopi.readthedocs.io/en/latest/discovery.html#action-urls - // example urlsrc="https://office.example.org/loleaflet/1.8.3/loleaflet.html?" - // example WOPIsrc="https://office.example.org:4000/wopi/files/$id" - - // @TODO: Parsing and replacing placeholder values + if (empty($capabilities) || empty($mimetype)) { + return; + } - // @TODO: passing access_token to the client - // http://wopi.readthedocs.io/en/latest/hostpage.html?highlight=token + $metadata = $capabilities[strtolower($mimetype)]; - // @TODO: access_token_ttl + if (empty($metadata)) { + return; + } + $office_url = rtrim($metadata['urlsrc'], ' /?'); // collabora + $service_url = rtrim($this->rc->config->get('fileapi_wopi_service'), ' /'); // kolab-wopi $service_url .= '/wopi/files/' . $id; + // @TODO: Parsing and replacing placeholder values + // https://wopi.readthedocs.io/en/latest/discovery.html#action-urls + + return $office_url . '?WOPISrc=' . urlencode($service_url); + } + + /** + * Retern extra viewer parameters to post the the viewer iframe + * + * @param array $info File info + * + * @return array POST parameters + */ + public function editor_post_params($info) + { + // Access token TTL (number of milliseconds since January 1, 1970 UTC) + if ($ttl = $this->rc->config->get('session_lifetime', 0) * 60) { + $now = new DateTime('now', new DateTimeZone('UTC')); + $ttl = ($ttl + $now->format('U')) . '000'; + } + $params = array( - 'file_path' => $service_url, - 'access_token' => $token, + 'access_token' => $this->token, + 'access_token_ttl' => $ttl ?: 0, ); - return $office_url . '?' . http_build_query($params); + // @TODO: we should/could also add: + // lang, title, permission, timestamp, closebutton, revisionhistory + + return $params; } + /** + * List supported mimetypes + */ public function supported_filetypes() { - // @TODO: Use WOPI discovery to get the list of supported - // filetypes and urlsrc attrbutes - // this should probably be cached - // https://wopi.readthedocs.io/en/latest/discovery.html + $caps = $this->capabilities(); + + return array_keys($caps); + } + + /** + * Uses WOPI discovery to get Office capabilities + * https://wopi.readthedocs.io/en/latest/discovery.html + */ + protected function capabilities() + { + $cache_key = 'wopi.capabilities'; + if ($result = $this->get_from_cache($cache_key)) { + return $result; + } + + $office_url = rtrim($this->rc->config->get('fileapi_wopi_office'), ' /'); + $office_url .= '/hosting/discovery'; + + try { + $request = $this->http_request(); + $request->setMethod(HTTP_Request2::METHOD_GET); + $request->setBody(''); + $request->setUrl($office_url); + + $response = $request->send(); + $body = $response->getBody(); + $code = $response->getStatus(); + + if (empty($body) || $code != 200) { + throw new Exception("Unexpected WOPI discovery response"); + } + } + catch (Exception $e) { + rcube::raise_error($e, true, true); + } + + // parse XML output + // + // + // + // + // + // ... + + $node = new DOMDocument('1.0', 'UTF-8'); + $node->loadXML($body); + + $result = array(); + + foreach ($node->getElementsByTagName('app') as $app) { + if ($mimetype = $app->getAttribute('name')) { + if ($action = $app->getElementsByTagName('action')->item(0)) { + foreach ($action->attributes as $attr) { + $result[$mimetype][$attr->name] = $attr->value; + } + } + } + } + + if (empty($result)) { + rcube::raise_error("Failed to parse WOPI discovery response: $body", true, true); + } + + $this->save_in_cache($cache_key, $result); + + return $result; + } + + /** + * Initializes HTTP request object + */ + protected function http_request() + { + require_once 'HTTP/Request2.php'; + + $request = new HTTP_Request2(); + + // Configure connection options + $config = $this->rc->config; + $http_config = (array) $config->get('http_request', $config->get('kolab_http_request')); + + // Deprecated config, all options are separated variables + if (empty($http_config)) { + $options = array( + 'ssl_verify_peer', + 'ssl_verify_host', + 'ssl_cafile', + 'ssl_capath', + 'ssl_local_cert', + 'ssl_passphrase', + 'follow_redirects', + ); + + foreach ($options as $optname) { + if (($optvalue = $config->get($optname)) !== null + || ($optvalue = $config->get('kolab_' . $optname)) !== null + ) { + $http_config[$optname] = $optvalue; + } + } + } + + if (!empty($http_config)) { + try { + $request->setConfig($http_config); + } + catch (Exception $e) { + rcube::log_error("HTTP: " . $e->getMessage()); + } + } + + // proxy User-Agent + $request->setHeader('user-agent', $_SERVER['HTTP_USER_AGENT']); + + // some HTTP server configurations require this header + $request->setHeader('accept', "application/json,text/javascript,*/*"); + + return $request; + } + + protected function get_from_cache($key) + { + if ($cache = $this->get_cache) { + return $cache->get($key); + } + } + + protected function save_in_cache($key, $value) + { + if ($cache = $this->get_cache) { + $cache->set($key, $value); + } + } + + /** + * Getter for the shared cache engine object + */ + protected function get_cache() + { + if ($this->cache === null) { + $cache = $this->rc->get_cache_shared('chwala'); + $this->cache = $cache ?: false; + } + + return $this->cache; } }