diff --git a/lib/api/file_info.php b/lib/api/file_info.php index 304066c..3bc0201 100644 --- a/lib/api/file_info.php +++ b/lib/api/file_info.php @@ -1,143 +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 + // 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) && $info['viewer']['manticore']) { - $this->file_manticore_handler($info); + 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)) { $info['viewer']['href'] = $uri; $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)) { + $info['viewer']['href'] = $uri; + $info['session'] = $wopi->session_info($session, true); + } + } } diff --git a/lib/file_api.php b/lib/file_api.php index af185c4..3764d23 100644 --- a/lib/file_api.php +++ b/lib/file_api.php @@ -1,436 +1,462 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class file_api extends file_api_core { public $session; public $config; public $browser; public $output_type = file_api_core::OUTPUT_JSON; public function __construct() { $rcube = rcube::get_instance(); $rcube->add_shutdown_function(array($this, 'shutdown')); $this->config = $rcube->config; $this->session_init(); if ($_SESSION['env']) { $this->env = $_SESSION['env']; } $this->locale_init(); } /** * Process the request and dispatch it to the requested service */ public function run() { $this->request = strtolower($_GET['method']); // Check the session, authenticate the user if (!$this->session_validate($this->request == 'authenticate')) { $this->session->destroy(session_id()); $this->session->regenerate_id(false); if ($username = $this->authenticate()) { $_SESSION['user'] = $username; $_SESSION['env'] = $this->env; // remember client API version if (is_numeric($_GET['version'])) { $_SESSION['version'] = $_GET['version']; } if ($this->request == 'authenticate') { $this->output_success(array( 'token' => session_id(), 'capabilities' => $this->capabilities(), )); } } else { throw new Exception("Invalid session", 403); } } // Call service method $result = $this->request_handler($this->request); // Send success response, errors should be handled by driver class // by throwing exceptions or sending output by itself $this->output_success($result); } /** * Session validation check and session start */ private function session_validate($new_session = false) { if (!$new_session) { $sess_id = rcube_utils::request_header('X-Session-Token') ?: $_REQUEST['token']; } if (empty($sess_id)) { $this->session->start(); return false; } session_id($sess_id); $this->session->start(); if (empty($_SESSION['user'])) { return false; } + // Document-only session + if (($doc_id = $_SESSION['document_session']) + && (strpos($this->request, 'document') !== 0 || $doc_id != $_GET['id']) + ) { + throw new Exception("Access denied", 403); + } + return true; } /** * Initializes session */ private function session_init() { $rcube = rcube::get_instance(); $sess_name = $this->config->get('session_name'); $lifetime = $this->config->get('session_lifetime', 0) * 60; if ($lifetime) { ini_set('session.gc_maxlifetime', $lifetime * 2); } ini_set('session.name', $sess_name ? $sess_name : 'file_api_sessid'); ini_set('session.use_cookies', 0); ini_set('session.serialize_handler', 'php'); // Roundcube Framework >= 1.2 if (in_array('factory', get_class_methods('rcube_session'))) { $this->session = rcube_session::factory($this->config); } // Rouncube Framework < 1.2 else { $this->session = new rcube_session($rcube->get_dbh(), $this->config); $this->session->set_secret($this->config->get('des_key') . dirname($_SERVER['SCRIPT_NAME'])); $this->session->set_ip_check($this->config->get('ip_check')); } $this->session->register_gc_handler(array($rcube, 'gc')); // this is needed to correctly close session in shutdown function $rcube->session = $this->session; } /** * Script shutdown handler */ public function shutdown() { // write performance stats to logs/console if ($this->config->get('devel_mode')) { if (function_exists('memory_get_peak_usage')) $mem = memory_get_peak_usage(); else if (function_exists('memory_get_usage')) $mem = memory_get_usage(); $log = trim($this->request . ($mem ? sprintf(' [%.1f MB]', $mem/1024/1024) : '')); if (defined('FILE_API_START')) { rcube::print_timer(FILE_API_START, $log); } else { rcube::console($log); } } } /** * Authentication request handler (HTTP Auth) */ private function authenticate() { if (isset($_POST['username'])) { $username = $_POST['username']; $password = $_POST['password']; } else if (!empty($_SERVER['PHP_AUTH_USER'])) { $username = $_SERVER['PHP_AUTH_USER']; $password = $_SERVER['PHP_AUTH_PW']; } // when used with (f)cgi no PHP_AUTH* variables are available without defining a special rewrite rule else if (!isset($_SERVER['PHP_AUTH_USER'])) { // "Basic didhfiefdhfu4fjfjdsa34drsdfterrde..." if (isset($_SERVER["REMOTE_USER"])) { $basicAuthData = base64_decode(substr($_SERVER["REMOTE_USER"], 6)); } else if (isset($_SERVER["REDIRECT_REMOTE_USER"])) { $basicAuthData = base64_decode(substr($_SERVER["REDIRECT_REMOTE_USER"], 6)); } else if (isset($_SERVER["Authorization"])) { $basicAuthData = base64_decode(substr($_SERVER["Authorization"], 6)); } else if (isset($_SERVER["HTTP_AUTHORIZATION"])) { $basicAuthData = base64_decode(substr($_SERVER["HTTP_AUTHORIZATION"], 6)); } if (isset($basicAuthData) && !empty($basicAuthData)) { list($username, $password) = explode(":", $basicAuthData); } } if (!empty($username)) { $backend = $this->get_backend(); $result = $backend->authenticate($username, $password); if (empty($result)) { /* header('WWW-Authenticate: Basic realm="' . $this->app_name .'"'); header('HTTP/1.1 401 Unauthorized'); exit; */ throw new Exception("Invalid password or username", file_api_core::ERROR_CODE); } } return $username; } /** * Storage/System method handler */ private function request_handler($request) { // handle "global" requests that don't require api driver switch ($request) { case 'ping': return array(); case 'quit': $this->session->destroy(session_id()); return array(); case 'configure': foreach (array_keys($this->env) as $name) { if (isset($_GET[$name])) { $this->env[$name] = $_GET[$name]; } } $_SESSION['env'] = $this->env; return $this->env; case 'upload_progress': return $this->upload_progress(); case 'mimetypes': return $this->supported_mimetypes(); case 'capabilities': return $this->capabilities(); } // handle request if ($request && preg_match('/^[a-z0-9_-]+$/', $request)) { $aliases = array( // request name aliases for backward compatibility 'lock' => 'lock_create', 'unlock' => 'lock_delete', 'folder_rename' => 'folder_move', ); // Redirect all document_* actions into 'document' action if (preg_match('/^(sessions|invitations|document_[a-z]+)$/', $request)) { $request = 'document'; } $request = $aliases[$request] ?: $request; require_once __DIR__ . "/api/common.php"; include_once __DIR__ . "/api/$request.php"; $class_name = "file_api_$request"; if (class_exists($class_name, false)) { $handler = new $class_name($this); return $handler->handle(); } } throw new Exception("Unknown method", file_api_core::ERROR_INVALID); } /** * File upload progress handler */ protected function upload_progress() { if (function_exists('apc_fetch')) { $prefix = ini_get('apc.rfc1867_prefix'); $uploadid = rcube_utils::get_input_value('id', rcube_utils::INPUT_GET); $status = apc_fetch($prefix . $uploadid); if (!empty($status)) { $status['percent'] = round($status['current']/$status['total']*100); if ($status['percent'] < 100) { $diff = max(1, time() - intval($status['start_time'])); // calculate time to end of uploading (in seconds) $status['eta'] = intval($diff * (100 - $status['percent']) / $status['percent']); // average speed (bytes per second) $status['rate'] = intval($status['current'] / $diff); } } $status['id'] = $uploadid; return $status; // id, done, total, current, percent, start_time, eta, rate } throw new Exception("Not supported", file_api_core::ERROR_CODE); } /** * Returns complete File URL * * @param string $file File name (with path) * * @return string File URL */ public function file_url($file) { return file_utils::script_uri(). '?method=file_get' . '&file=' . urlencode($file) . '&token=' . urlencode(session_id()); } /** * Returns web browser object * * @return rcube_browser Web browser object */ public function get_browser() { if ($this->browser === null) { $this->browser = new rcube_browser; } return $this->browser; } /** * Send success response * * @param mixed $data Data */ public function output_success($data) { if (!is_array($data)) { $data = array(); } $response = array('status' => 'OK', 'result' => $data); if (!empty($_REQUEST['req_id'])) { $response['req_id'] = $_REQUEST['req_id']; } $this->output_send($response); } /** * Send error response * * @param mixed $response Response data * @param int $code Error code */ public function output_error($response, $code = null) { if (is_string($response)) { $response = array('reason' => $response); } $response['status'] = 'ERROR'; if ($code) { $response['code'] = $code; } if (!empty($_REQUEST['req_id'])) { $response['req_id'] = $_REQUEST['req_id']; + header("X-Chwala-Request-ID: " . $_REQUEST['req_id']); } if (empty($response['code'])) { $response['code'] = file_api_core::ERROR_CODE; } + header("X-Chwala-Error: " . $response['code']); + + // When binary response is expected return real + // HTTP error instaead of JSON response with code 200 + if ($this->is_binary_request()) { + header(sprintf("HTTP/1.0 %d %s", $response['code'], $response ?: "Server error")); + exit; + } + $this->output_send($response); } /** * Send response * * @param mixed $data Data */ protected function output_send($data) { // Send response header("Content-Type: {$this->output_type}; charset=utf-8"); echo json_encode($data); exit; } + /** + * Find out if current request expects binary output + */ + protected function is_binary_request() + { + return preg_match('/^(file_get|document)$/', $this->request) + && $_SERVER['REQUEST_METHOD'] == 'GET'; + } + /** * Returns API version supported by the client */ public function client_version() { return $_SESSION['version']; } /** * Create a human readable string for a number of bytes * * @param int Number of bytes * * @return string Byte string */ public function show_bytes($bytes) { if ($bytes >= 1073741824) { $gb = $bytes/1073741824; $str = sprintf($gb >= 10 ? "%d " : "%.1f ", $gb) . 'GB'; } else if ($bytes >= 1048576) { $mb = $bytes/1048576; $str = sprintf($mb >= 10 ? "%d " : "%.1f ", $mb) . 'MB'; } else if ($bytes >= 1024) { $str = sprintf("%d ", round($bytes/1024)) . 'KB'; } else { $str = sprintf('%d ', $bytes) . 'B'; } return $str; } } diff --git a/lib/file_api_core.php b/lib/file_api_core.php index 6839b7f..bde68fc 100644 --- a/lib/file_api_core.php +++ b/lib/file_api_core.php @@ -1,369 +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 ($manticore = $rcube->config->get('fileapi_manticore')) { + if ($rcube->config->get('fileapi_manticore')) { $caps['MANTICORE'] = true; } + // WOPI support + if ($rcube->config->get('fileapi_wopi')) { + $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_manticore.php b/lib/file_document.php similarity index 87% copy from lib/file_manticore.php copy to lib/file_document.php index bb4af34..f635104 100644 --- a/lib/file_manticore.php +++ b/lib/file_document.php @@ -1,860 +1,761 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Document editing sessions handling */ -class file_manticore +class file_document { protected $api; protected $rc; - protected $request; 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 &$session_id Optional session ID to join to * - * @return string Manticore URI + * @return string An URI for specified file/session * @throws Exception */ public function session_start($file, &$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); } } - - // authenticate to Manticore, we need auth token for frame_uri - $req = $this->get_request(); - - // @TODO: make sure the session exists in Manticore? } 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); } - return $this->frame_uri($session_id); + // 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 - * @param bool $local Remove session only from local database + * @param string $id Session identifier */ - public function session_delete($id, $local = false) + 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); - $success = $db->affected_rows($result) > 0; - - // Send document delete to Manticore - if ($success && !$local) { - $req = $this->get_request(); - $res = $req->document_delete($id); - } - - return $success; + 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) ?: ''; - // Do this before starting the session in Manticore, - // it will immediately call api/document to get the file body $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; - // 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; - } + // @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; } /** * 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); } - // Update Manticore 'access' array - if ($status == self::STATUS_INVITED) { - $req = $this->get_request(); - $res = $req->editor_add($session_id, $user, file_manticore_api::ACCESS_WRITE); - - if (!$res) { - throw new Exception("Failed to create an invitation.", 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) + 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 Manticore 'access' array - $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 = '') { 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 Manticore 'access' array if an owner accepted an invitation request - if ($status == self::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); - } - } } /** * 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; } - /** - * 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']; - } - /** * 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; } } - /** - * 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; - } - /** * 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 bb4af34..e842671 100644 --- a/lib/file_manticore.php +++ b/lib/file_manticore.php @@ -1,860 +1,222 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** - * Document editing sessions handling + * Document editing sessions handling (Manticore) */ -class file_manticore +class file_manticore extends file_document { - protected $api; - protected $rc; protected $request; - 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 &$session_id Optional session ID to join to * * @return string Manticore URI * @throws Exception */ public function session_start($file, &$session_id = null) { - if ($file !== null) { - $uri = $this->path2uri($file, $driver); - } + parent::session_start($file, $session_id); - $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); - } - } - - // authenticate to Manticore, we need auth token for frame_uri - $req = $this->get_request(); - - // @TODO: make sure the session exists in Manticore? + // authenticate to Manticore, we need auth token for frame_uri + if (empty($_SESSION['manticore_token'])) { + $this->get_request(); } - 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); - } + // @TODO: make sure the session exists in Manticore? return $this->frame_uri($session_id); } - /** - * 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 * @param bool $local Remove session only from local database */ public function session_delete($id, $local = false) { - $db = $this->rc->get_dbh(); - $result = $db->query("DELETE FROM `{$this->sessions_table}`" - . " WHERE `id` = ? AND `owner` = ?", - $id, $this->user); - - $success = $db->affected_rows($result) > 0; + $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) { - // get user name - $owner_name = $this->api->resolve_user($owner) ?: ''; - - // Do this before starting the session in Manticore, - // it will immediately call api/document to get the file body - $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; + $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; } - /** - * 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; - } - - /** - * 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); - } + parent::invitation_create($session_id, $user, $status, $comment, $user_name); // Update Manticore 'access' array - if ($status == self::STATUS_INVITED) { + 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); } } - - // 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) + 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); - } + parent::invitation_delete($session_id, $user, $local); // Update Manticore 'access' array - $req = $this->get_request(); - $res = $req->editor_delete($session_id, $user); + 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); + 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 = '') { - 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); - } + parent::invitation_update($session_id, $user, $status, $comment); // Update Manticore 'access' array if an owner accepted an invitation request - if ($status == self::STATUS_ACCEPTED_OWNER) { + 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); } } } - /** - * 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; - } - /** * 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']; } - /** - * 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; - } - } - /** * 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; } - - /** - * 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_wopi.php b/lib/file_wopi.php new file mode 100644 index 0000000..8775d55 --- /dev/null +++ b/lib/file_wopi.php @@ -0,0 +1,96 @@ + | + +--------------------------------------------------------------------------+ + | Author: Aleksander Machniak | + +--------------------------------------------------------------------------+ +*/ + +/** + * Document editing sessions handling (WOPI) + */ +class file_wopi extends file_document +{ + /** + * Return viewer URI for specified file/session. This creates + * a new collaborative editing session when needed. + * + * @param string $file File path + * @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) + { + 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); + } + + return $this->frame_uri($session_id, $token); + } + + /** + * Generate URI of WOPI editing session (WOPIsrc) + */ + protected function frame_uri($id, $token) + { + $office_url = rtrim($this->rc->config->get('fileapi_wopi_office'), ' /'); // Collabora + $service_url = rtrim($this->rc->config->get('fileapi_wopi_service'), ' /'); // kolab-wopi + + // 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 + + // @TODO: passing access_token to the client + // http://wopi.readthedocs.io/en/latest/hostpage.html?highlight=token + + // @TODO: access_token_ttl + + $service_url .= '/wopi/files/' . $id; + + $params = array( + 'file_path' => $service_url, + 'access_token' => $token, + ); + + return $office_url . '?' . http_build_query($params); + } + + 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 + } +}