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/doc/SQL/mysql.initial.sql b/doc/SQL/mysql.initial.sql index 810bff5..8d9dafd 100644 --- a/doc/SQL/mysql.initial.sql +++ b/doc/SQL/mysql.initial.sql @@ -1,38 +1,39 @@ CREATE TABLE IF NOT EXISTS `chwala_locks` ( `uri` varchar(512) BINARY NOT NULL, `owner` varchar(256), `timeout` integer unsigned, `expires` datetime DEFAULT NULL, `token` varchar(256), `scope` tinyint, `depth` tinyint, INDEX `uri_index` (`uri`, `depth`), INDEX `expires_index` (`expires`), INDEX `token_index` (`token`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; CREATE TABLE IF NOT EXISTS `chwala_sessions` ( `id` varchar(40) BINARY NOT NULL, `uri` varchar(1024) BINARY NOT NULL, `owner` varchar(255) BINARY NOT NULL, `owner_name` varchar(255) DEFAULT NULL, `data` mediumtext, + `readonly` tinyint(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), INDEX `uri_index` (`uri`(255)), INDEX `owner` (`owner`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; CREATE TABLE IF NOT EXISTS `chwala_invitations` ( `session_id` varchar(40) BINARY NOT NULL, `user` varchar(255) BINARY NOT NULL, `user_name` varchar(255) DEFAULT NULL, `status` varchar(16) NOT NULL, `changed` datetime DEFAULT NULL, `comment` mediumtext, CONSTRAINT `session_id_fk_chwala_invitations` FOREIGN KEY (`session_id`) REFERENCES `chwala_sessions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE, INDEX `session_id` (`session_id`), UNIQUE INDEX `user_session_id` (`user`, `session_id`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8 COLLATE utf8_general_ci */; -INSERT INTO `system` (`name`, `value`) VALUES ('chwala-version', '2015110400'); +INSERT INTO `system` (`name`, `value`) VALUES ('chwala-version', '2016101700'); diff --git a/doc/SQL/mysql/2016101700.sql b/doc/SQL/mysql/2016101700.sql new file mode 100644 index 0000000..9d5403c --- /dev/null +++ b/doc/SQL/mysql/2016101700.sql @@ -0,0 +1 @@ +ALTER TABLE `chwala_sessions` ADD COLUMN `readonly` tinyint(1) NOT NULL DEFAULT 0; diff --git a/doc/SQL/oracle.initial.sql b/doc/SQL/oracle.initial.sql index 104839b..0451b6a 100644 --- a/doc/SQL/oracle.initial.sql +++ b/doc/SQL/oracle.initial.sql @@ -1,42 +1,43 @@ CREATE TABLE "chwala_locks" ( "uri" varchar(512) NOT NULL, "owner" varchar(256), "timeout" integer, "expires" timestamp DEFAULT NULL, "token" varchar(256), "scope" smallint, "depth" smallint ); CREATE INDEX "uri_index" ON "chwala_locks" ("uri", "depth"); CREATE INDEX "expires_index" ON "chwala_locks" ("expires"); CREATE INDEX "token_index" ON "chwala_locks" ("token"); CREATE TABLE "chwala_sessions" ( "id" varchar(40) NOT NULL, "uri" varchar(1024) NOT NULL, "owner" varchar(255) NOT NULL, "owner_name" varchar(255) DEFAULT NULL, "data" long, + "readonly" smallint DEFAULT 0 NOT NULL, PRIMARY KEY ("id") ); CREATE INDEX "chwala_sessions_uri_idx" ON "chwala_sessions" ("uri"); CREATE INDEX "chwala_sessions_owner_idx" ON "chwala_sessions" ("owner"); CREATE TABLE "chwala_invitations" ( "session_id" varchar(40) NOT NULL REFERENCES "chwala_sessions" ("id") ON DELETE CASCADE ON UPDATE CASCADE, "user" varchar(255) NOT NULL, "user_name" varchar(255) DEFAULT NULL, "status" varchar(16) NOT NULL, "changed" timestamp DEFAULT NULL, "comment" long ); CREATE INDEX "chwala_invitations_session_id_idx" ON "chwala_invitations" ("session_id"); CREATE UNIQUE INDEX "chwala_invitations_user_idx" ON "chwala_invitations" ("user", "session_id"); -INSERT INTO "system" ("name", "value") VALUES ('chwala-version', '2015110400'); +INSERT INTO "system" ("name", "value") VALUES ('chwala-version', '2016101700'); diff --git a/doc/SQL/oracle/2016101700.sql b/doc/SQL/oracle/2016101700.sql new file mode 100644 index 0000000..892f977 --- /dev/null +++ b/doc/SQL/oracle/2016101700.sql @@ -0,0 +1 @@ +ALTER TABLE "chwala_sessions" ADD COLUMN "readonly" smallint DEFAULT 0 NOT NULL; diff --git a/lib/api/common.php b/lib/api/common.php index b60bc66..f09eb35 100644 --- a/lib/api/common.php +++ b/lib/api/common.php @@ -1,182 +1,190 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class file_api_common { protected $api; protected $rc; protected $args = array(); public function __construct($api) { $this->rc = rcube::get_instance(); $this->api = $api; } /** * Request handler */ public function handle() { // GET arguments $this->args = &$_GET; // POST arguments (JSON) if ($_SERVER['REQUEST_METHOD'] == 'POST') { $post = file_get_contents('php://input'); $this->args += (array) json_decode($post, true); unset($post); } // disable script execution time limit, so we can handle big files @set_time_limit(360); } /** * File uploads handler */ protected function upload() { $files = array(); if (is_array($_FILES['file']['tmp_name'])) { foreach ($_FILES['file']['tmp_name'] as $i => $filepath) { if ($err = $_FILES['file']['error'][$i]) { if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) { $maxsize = ini_get('upload_max_filesize'); $maxsize = $this->show_bytes(parse_bytes($maxsize)); throw new Exception("Maximum file size ($maxsize) exceeded", file_api_core::ERROR_CODE); } throw new Exception("File upload failed", file_api_core::ERROR_CODE); } $files[] = array( 'path' => $filepath, 'name' => $_FILES['file']['name'][$i], 'size' => filesize($filepath), 'type' => rcube_mime::file_content_type($filepath, $_FILES['file']['name'][$i], $_FILES['file']['type']), ); } } else if ($_SERVER['REQUEST_METHOD'] == 'POST') { // if filesize exceeds post_max_size then $_FILES array is empty, if ($maxsize = ini_get('post_max_size')) { $maxsize = $this->show_bytes(parse_bytes($maxsize)); throw new Exception("Maximum file size ($maxsize) exceeded", file_api_core::ERROR_CODE); } throw new Exception("File upload failed", file_api_core::ERROR_CODE); } return $files; } /** * Return built-in viewer opbject for specified mimetype * * @return object Viewer object */ protected function find_viewer($mimetype) { - $dir = RCUBE_INSTALL_PATH . 'lib/viewers'; + $dir = RCUBE_INSTALL_PATH . 'lib/viewers'; + $files = array(); + // First get viewers and sort by name to get priority 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->api); - - if ($viewer->supports($mimetype)) { - return $viewer; - } + $files[$matches[1]] = $dir . '/' . $file; } } closedir($handle); } + + ksort($files); + + foreach ($files as $name => $file) { + include_once $file; + $class = 'file_viewer_' . $name; + $viewer = new $class($this->api); + + if ($viewer->supports($mimetype)) { + return $viewer; + } + } } /** * Parse driver metadata information */ protected function parse_metadata($metadata, $default = false) { if ($default) { unset($metadata['form']); $metadata['name'] .= ' (' . $this->api->translate('localstorage') . ')'; } // localize form labels foreach ($metadata['form'] as $key => $val) { $label = $this->api->translate('form.' . $val); if (strpos($label, 'form.') !== 0) { $metadata['form'][$key] = $label; } } return $metadata; } /** * Get folder rights */ protected function folder_rights($folder) { list($driver, $path) = $this->api->get_driver($folder); $rights = $driver->folder_rights($path); $result = array(); $map = array( file_storage::ACL_READ => 'read', file_storage::ACL_WRITE => 'write', ); foreach ($map as $key => $value) { if ($rights & $key) { $result[] = $value; } } return $result; } /** * Update manticore session on file/folder move */ protected function session_uri_update($from, $to, $is_folder = false) { // 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); if (empty($capabilities['MANTICORE'])) { return; } $manticore = new file_manticore($this->api); $manticore->session_uri_update($from, $to, $is_folder); } } diff --git a/lib/api/document.php b/lib/api/document.php index 064524d..7bc9df2 100644 --- a/lib/api/document.php +++ b/lib/api/document.php @@ -1,292 +1,341 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class file_api_document extends file_api_common { /** * Request handler */ public function handle() { $method = $_SERVER['REQUEST_METHOD']; $this->args = $_GET; if ($method == 'POST' && !empty($_SERVER['HTTP_X_HTTP_METHOD'])) { $method = $_SERVER['HTTP_X_HTTP_METHOD']; } // Invitation notifications if ($this->args['method'] == 'invitations') { return $this->invitations(); } // Sessions list if ($this->args['method'] == 'sessions') { return $this->sessions(); } // Session and invitations management if (strpos($this->args['method'], 'document_') === 0) { if ($_SERVER['REQUEST_METHOD'] == 'POST') { $post = file_get_contents('php://input'); $this->args += (array) json_decode($post, true); unset($post); } if (empty($this->args['id'])) { throw new Exception("Missing document ID.", file_api_core::ERROR_CODE); } switch ($this->args['method']) { case 'document_delete': case 'document_invite': case 'document_request': case 'document_decline': case 'document_accept': case 'document_cancel': + case 'document_info': return $this->{$this->args['method']}($this->args['id']); } } // Document content actions for Manticore else if ($method == 'PUT' || $method == 'GET') { if (empty($this->args['id'])) { throw new Exception("Missing document ID.", file_api_core::ERROR_CODE); } $file = $this->get_file_path($this->args['id']); return $this->{'document_' . strtolower($method)}($file); } throw new Exception("Unknown method", file_api_core::ERROR_INVALID); } /** * Get file path from manticore session identifier */ protected function get_file_path($id) { - $manticore = new file_manticore($this->api); + $document = new file_document($this->api); - return $manticore->session_file($id); + $file = $document->session_file($id); + + return $file['file']; } /** * Get invitations list */ protected function invitations() { $timestamp = time(); // Initial tracking request, return just the current timestamp if ($this->args['timestamp'] == -1) { return array('timestamp' => $timestamp); // @TODO: in this mode we should likely return all invitations // that require user action, otherwise we may skip some unintentionally } - $manticore = new file_manticore($this->api); - $filter = array(); + $document = new file_document($this->api); + $filter = array(); if ($this->args['timestamp']) { $filter['timestamp'] = $this->args['timestamp']; } - $list = $manticore->invitations_list($filter); + $list = $document->invitations_list($filter); return array( 'list' => $list, 'timestamp' => $timestamp, ); } /** * Get sessions list */ protected function sessions() { - $manticore = new file_manticore($this->api); + $document = new file_document($this->api); $params = array( 'reverse' => rcube_utils::get_boolean((string) $this->args['reverse']), ); if (!empty($this->args['sort'])) { $params['sort'] = strtolower($this->args['sort']); } - return $manticore->sessions_list($params); + return $document->sessions_list($params); } /** * Close (delete) manticore session */ protected function document_delete($id) { - $manticore = new file_manticore($this->api); + $document = file_document::get_handler($this->api, $id); - if (!$manticore->session_delete($id)) { + if (!$document->session_delete($id)) { throw new Exception("Failed deleting the document session.", file_api_core::ERROR_CODE); } } /** * Invite/add a session participant(s) */ protected function document_invite($id) { - $manticore = new file_manticore($this->api); - $users = $this->args['users']; - $comment = $this->args['comment']; + $document = file_document::get_handler($this->api, $id); + $users = $this->args['users']; + $comment = $this->args['comment']; if (empty($users)) { throw new Exception("Invalid arguments.", file_api_core::ERROR_CODE); } foreach ((array) $users as $user) { if (!empty($user['user'])) { - $manticore->invitation_create($id, $user['user'], file_manticore::STATUS_INVITED, $comment, $user['name']); + $document->invitation_create($id, $user['user'], file_document::STATUS_INVITED, $comment, $user['name']); $result[] = array( 'session_id' => $id, 'user' => $user['user'], 'user_name' => $user['name'], - 'status' => file_manticore::STATUS_INVITED, + 'status' => file_document::STATUS_INVITED, ); } } return array( 'list' => $result, ); } /** * Request an invitation to a session */ protected function document_request($id) { - $manticore = new file_manticore($this->api); - $manticore->invitation_create($id, null, file_manticore::STATUS_REQUESTED, $this->args['comment']); + $document = file_document::get_handler($this->api, $id); + $document->invitation_create($id, null, file_document::STATUS_REQUESTED, $this->args['comment']); } /** * Decline an invitation to a session */ protected function document_decline($id) { - $manticore = new file_manticore($this->api); - $manticore->invitation_update($id, $this->args['user'], file_manticore::STATUS_DECLINED, $this->args['comment']); + $document = file_document::get_handler($this->api, $id); + $document->invitation_update($id, $this->args['user'], file_document::STATUS_DECLINED, $this->args['comment']); } /** * Accept an invitation to a session */ protected function document_accept($id) { - $manticore = new file_manticore($this->api); - $manticore->invitation_update($id, $this->args['user'], file_manticore::STATUS_ACCEPTED, $this->args['comment']); + $document = file_document::get_handler($this->api, $id); + $document->invitation_update($id, $this->args['user'], file_document::STATUS_ACCEPTED, $this->args['comment']); } /** * Remove a session participant(s) - cancel invitations */ protected function document_cancel($id) { - $manticore = new file_manticore($this->api); - $users = $this->args['users']; + $document = file_document::get_handler($this->api, $id); + $users = $this->args['users']; if (empty($users)) { throw new Exception("Invalid arguments.", file_api_core::ERROR_CODE); } foreach ((array) $users as $user) { - $manticore->invitation_delete($id, $user); + $document->invitation_delete($id, $user); $result[] = $user; } return array( 'list' => $result, ); } + /** + * Return document informations + */ + protected function document_info($id) + { + $document = file_document::get_handler($this->api, $id); + $file = $document->session_file($id); + $session = $document->session_info($id); + $rcube = rcube::get_instance(); + + try { + list($driver, $path) = $this->api->get_driver($file['file']); + $result = $driver->file_info($path); + } + catch (Exception $e) { + // invited users may have no permission, + // use file data from the session + $result = array( + 'size' => $file['size'], + 'name' => $file['name'], + 'modified' => $file['modified'], + 'type' => $file['type'], + ); + } + + $result['owner'] = $session['owner']; + $result['owner_name'] = $session['owner_name']; + $result['user'] = $rcube->user->get_username(); + $result['readonly'] = !empty($session['readonly']); + $result['origin'] = $session['origin']; + + if ($result['owner'] == $result['user']) { + $result['user_name'] = $result['owner_name']; + } + else { + $result['user_name'] = $this->api->resolve_user($result['user']) ?: ''; + } + + return $result; + } + /** * Update document file content */ protected function document_put($file) { list($driver, $path) = $this->api->get_driver($file); $length = rcube_utils::request_header('Content-Length'); $tmp_dir = unslashify($this->api->config->get('temp_dir')); $tmp_path = tempnam($tmp_dir, 'chwalaUpload'); // Create stream to copy input into a temp file $input = fopen('php://input', 'r'); $tmp_file = fopen($tmp_path, 'w'); if (!$input || !$tmp_file) { throw new Exception("Failed opening input or temp file stream.", file_api_core::ERROR_CODE); } // Create temp file from the input $copied = stream_copy_to_stream($input, $tmp_file); fclose($input); fclose($tmp_file); if ($copied < $length) { throw new Exception("Failed writing to temp file.", file_api_core::ERROR_CODE); } - $file = array( + $file_data = array( 'path' => $tmp_path, 'type' => rcube_mime::file_content_type($tmp_path, $file), ); - $driver->file_update($path, $file); + $driver->file_update($path, $file_data); // remove the temp file unlink($tmp_path); + + // Update the file metadata in session + $file_data = $driver->file_info($file); + $document = file_document::get_handler($this->api, $this->args['id']); + $document->session_update($this->args['id'], $file_data); } /** * Return document file content */ protected function document_get($file) { list($driver, $path) = $this->api->get_driver($file); try { $params = array('force-type' => 'application/vnd.oasis.opendocument.text'); $driver->file_get($path, $params); } catch (Exception $e) { header("HTTP/1.0 " . file_api_core::ERROR_CODE . " " . $e->getMessage()); } exit; } } diff --git a/lib/api/file_get.php b/lib/api/file_get.php index 8116b54..451d1d6 100644 --- a/lib/api/file_get.php +++ b/lib/api/file_get.php @@ -1,103 +1,103 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class file_api_file_get extends file_api_common { protected $driver; /** * Request handler */ public function handle() { parent::handle(); $this->api->output_type = file_api_core::OUTPUT_HTML; if (!isset($this->args['file']) || $this->args['file'] === '') { header("HTTP/1.0 ".file_api_core::ERROR_CODE." Missing file name"); } $method = $_SERVER['REQUEST_METHOD']; if ($method == 'POST' && !empty($_SERVER['HTTP_X_HTTP_METHOD'])) { $method = $_SERVER['HTTP_X_HTTP_METHOD']; } $params = array( 'force-download' => rcube_utils::get_boolean((string) $this->args['force-download']), 'force-type' => $this->args['force-type'], 'head' => $this->args['head'] ?: $method == 'HEAD', ); list($this->driver, $path) = $this->api->get_driver($this->args['file']); if (!empty($this->args['viewer'])) { $this->file_view($path, $this->args, $params); } try { $this->driver->file_get($path, $params); } catch (Exception $e) { header("HTTP/1.0 " . file_api_core::ERROR_CODE . " " . $e->getMessage()); } exit; } /** * File vieweing request handler */ protected function file_view($file, $args, $params) { $viewer = $args['viewer']; $path = __DIR__ . "/../viewers/$viewer.php"; $class = "file_viewer_$viewer"; if (!file_exists($path)) { return; } // get file info try { $info = $this->driver->file_info($file); } catch (Exception $e) { header("HTTP/1.0 " . file_api_core::ERROR_CODE . " " . $e->getMessage()); exit; } include_once $path; $viewer = new $class($this->api); // check if specified viewer supports file type // otherwise return (fallback to file_get action) if (!$viewer->supports($info['type'])) { return; } - $viewer->output($args['file'], $info['type']); + $viewer->output($args['file'], $info); exit; } } diff --git a/lib/api/file_info.php b/lib/api/file_info.php index 304066c..6627daa 100644 --- a/lib/api/file_info.php +++ b/lib/api/file_info.php @@ -1,143 +1,180 @@ | +--------------------------------------------------------------------------+ | 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']); + if (($manticore || $wopi) && !empty($this->args['session'])) { + if ($info = $this->file_document_file($this->args['session'])) { + $this->args['file'] = $info['file']; + } } 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']); + try { + 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, - ); + $info = $driver->file_info($path); + $info['file'] = $this->args['file']; + } + catch (Exception $e) { + // Invited user may have no access to the file, + // ignore errors if session exists + if (!$this->args['viewer'] || !$this->args['session']) { + throw $e; + } + } } // 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 ((intval($this->args['viewer']) & 4)) { + // @TODO: Chwala client should have a possibility to select + // between wopi and manticore? + if (!$wopi || !$this->file_wopi_handler($info)) { + if ($manticore) { + $this->file_manticore_handler($info); + } } } - - if ((intval($this->args['viewer']) & 4) && $info['viewer']['manticore']) { - $this->file_manticore_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; } } } + /** + * Get file from manticore/wopi session + */ + protected function file_document_file($session_id) + { + $document = file_document::get_handler($this->api, $session_id); + + return $document->session_file($session_id, true); + } + /** * 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 (in_array_nocase($info['type'], $manticore->supported_filetypes(true))) { + $info['viewer']['manticore'] = true; + } + else { + return false; + } + + if ($uri = $manticore->session_start($file, $info, $session)) { $info['viewer']['href'] = $uri; + $info['viewer']['post'] = $manticore->editor_post_params($info); $info['session'] = $manticore->session_info($session, true); } + + return true; } /** - * Get file from manticore session + * Merge WOPI session data into file info */ - protected function file_manticore_file($session_id) + protected function file_wopi_handler(&$info) { - $manticore = new file_manticore($this->api); + $wopi = new file_wopi($this->api); + $file = $this->args['file']; + $session = $this->args['session']; + + if (in_array_nocase($info['type'], $wopi->supported_filetypes(true))) { + $info['viewer']['wopi'] = true; + } + else { + return false; + } + + if ($uri = $wopi->session_start($file, $info, $session)) { + $info['viewer']['href'] = $uri; + $info['viewer']['post'] = $wopi->editor_post_params($info); + $info['session'] = $wopi->session_info($session, true); + } - return $manticore->session_file($session_id, true); + return 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..36fb3b2 100644 --- a/lib/file_api_core.php +++ b/lib/file_api_core.php @@ -1,369 +1,411 @@ | +--------------------------------------------------------------------------+ | 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_office')) { + $caps['WOPI'] = true; + } + if (!$full) { return $caps; } + if ($caps['MANTICORE']) { + $manticore = new file_manticore($this); + $caps['MANTICORE_EDITABLE'] = $manticore->supported_filetypes(true); + } + + if ($caps['WOPI']) { + $wopi = new file_wopi($this); + $caps['WOPI_EDITABLE'] = $wopi->supported_filetypes(true); + } + // 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'; + $rcube = rcube::get_instance(); + $mimetypes = array(); + $mimetypes_c = 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()); + if ($supported = $viewer->supported_mimetypes()) { + $mimetypes = array_merge($mimetypes, $supported); + } } } closedir($handle); } - return $mimetypes; + // Here we return mimetypes supported for editing and creation of files + // @TODO: maybe move this to viewers + if ($rcube->config->get('fileapi_wopi_office')) { + $mimetypes_c['application/vnd.oasis.opendocument.text'] = array('ext' => 'odt'); + $mimetypes_c['application/vnd.oasis.opendocument.presentation'] = array('ext' => 'odp'); + $mimetypes_c['application/vnd.oasis.opendocument.spreadsheet'] = array('ext' => 'ods'); + } + else if ($rcube->config->get('fileapi_manticore')) { + $mimetypes_c['application/vnd.oasis.opendocument.text'] = array('ext' => 'odt'); + } + + $mimetypes_c['text/plain'] = array('ext' => 'txt'); + $mimetypes_c['text/html'] = array('ext' => 'html'); + + foreach (array_keys($mimetypes_c) as $type) { + list ($app, $label) = explode('/', $type); + $label = preg_replace('/[^a-z]/', '', $label); + $mimetypes_c[$type]['label'] = $this->translate('type.' . $label); + } + + return array( + 'view' => $mimetypes, + 'edit' => $mimetypes_c, + ); } /** * 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 82% copy from lib/file_manticore.php copy to lib/file_document.php index bb4af34..57a688a 100644 --- a/lib/file_manticore.php +++ b/lib/file_document.php @@ -1,860 +1,864 @@ | +--------------------------------------------------------------------------+ | 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(); + protected $file_meta_items = array('type', 'name', 'size', 'modified'); 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 + * @param file_api $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); } + /** + * Detect type of file_document class to use for specified session + * + * @param file_api $api Chwala API app instance + * @param string $session_id Document session ID + * + * @return file_document Document object + */ + public static function get_handler($api, $session_id) + { + // we add "w-" prefix to wopi session identifiers, + // so we can distinguish it from manticore sessions + if (strpos($session_id, 'w-') === 0) { + return new file_wopi($api); + } + + return new file_manticore($api); + } + /** * Return viewer URI for specified file/session. This creates * a new collaborative editing session when needed. * * @param string $file File path + * @param array &$file_info File metadata (e.g. type) * @param string &$session_id Optional session ID to join to + * @param string $readonly Create readonly (one-time) session * - * @return string Manticore URI + * @return string An URI for specified file/session * @throws Exception */ - public function session_start($file, &$session_id = null) + public function session_start($file, &$file_info, &$session_id = null, $readonly = false) { 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? + $file_info['type'] = $session['type']; } 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(); + $db = $this->rc->get_dbh(); + $res = $db->query("SELECT `id` FROM `{$this->sessions_table}`" - . " WHERE `owner` = ? AND `uri` = ?", $this->user, $uri); + . " WHERE `owner` = ? AND `uri` = ? AND `readonly` = ?", + $this->user, $uri, intval($readonly)); 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; + $data = array('origin' => $this->get_origin()); + + // store some file data, they will be used + // by invited users that has no access to the storage + foreach ($this->file_meta_items as $item) { + if (isset($file_info[$item])) { + $data[$item] = $file_info[$item]; + } + } + + // bind the session ID with editor type (see file_document::get_handler()) + if ($this instanceof file_wopi) { + $session_id = 'w-' . $session_id; + } // 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); + $res = $this->session_create($session_id, $uri, $owner, $data, $readonly); } 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 + * @return array File info (file, type, size) * @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; + $result = array('file' => $path); + + foreach ($this->file_meta_items as $item) { + if (isset($session[$item])) { + $result[$item] = $session[$item]; + } + } + + return $result; } /** * 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) . "%'"); + . " WHERE `readonly` = 0 AND `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; + return $db->affected_rows($result) > 0; + } + + /** + * Update editing session + * + * @param string $id Session ID + * @param array $data Session metadata + */ + public function session_update($id, $data) + { + $db = $this->rc->get_dbh(); + $result = $db->query("SELECT `data` FROM `{$this->sessions_table}`" + . " WHERE `id` = ?", $id); + + if ($row = $db->fetch_assoc($result)) { + // merge only relevant information + $data = array_intersect_key($data, array_flip($this->file_meta_items)); + if (empty($data)) { + return true; + } - // Send document delete to Manticore - if ($success && !$local) { - $req = $this->get_request(); - $res = $req->document_delete($id); + $sess_data = json_decode($row['data'], true); + $sess_data = array_merge($sess_data, $data); + + $result = $db->query("UPDATE `{$this->sessions_table}`" + . " SET `data` = ? WHERE `id` = ?", + json_encode($sess_data), $id); + + return $db->affected_rows($result) > 0; } - return $success; + return false; } /** * Create editing session */ - protected function session_create($id, $uri, $owner, $data) + protected function session_create($id, $uri, $owner, $data, $readonly = false) { // 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; - } - } + . " (`id`, `uri`, `owner`, `owner_name`, `data`, `readonly`)" + . " VALUES (?, ?, ?, ?, ?, ?)", + $id, $uri, $owner, $owner_name, json_encode($data), intval($readonly)); - return $success; + return $db->affected_rows($result) > 0; } /** * 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 + * Note: Readonly sessions are ignored here. * * @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 (" + $result = $db->query("SELECT * FROM `{$this->sessions_table}` s" + . " WHERE s.`readonly` = 0 AND (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)); + $result = $db->query("SELECT * FROM `{$this->sessions_table}` s" + . " WHERE s.`readonly` = 0 AND (" . 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); } - // 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'); + $fields = array('id', 'uri', 'owner', 'owner_name', 'readonly'); foreach ($fields as $field) { if (isset($record[$field])) { $session[$field] = $record[$field]; } } if ($path) { $session['file'] = $path; } + if (!empty($record['data'])) { + $data = json_decode($record['data'], true); + $fields = array_merge($this->file_meta_items, array('origin')); + + foreach ($fields as $field) { + if (empty($filter) || in_array($field, $filter)) { + $session[$field] = $data[$field]; + } + } + } + // @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; } + + /** + * Get request origin, use Referer header if specified + */ + protected function get_origin() + { + if (!empty($_SERVER['HTTP_REFERER'])) { + $url = parse_url($_SERVER['HTTP_REFERER']); + + return $url['scheme'] . '://' . $url['host'] . ($url['port'] ?: ''); + } + + return $_SERVER['REQUEST_SCHEME'] . '://' . $_SERVER['HTTP_HOST']; + } } diff --git a/lib/file_manticore.php b/lib/file_manticore.php index bb4af34..39c9771 100644 --- a/lib/file_manticore.php +++ b/lib/file_manticore.php @@ -1,860 +1,238 @@ | +--------------------------------------------------------------------------+ | 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 array &$file_info File metadata (e.g. type) * @param string &$session_id Optional session ID to join to + * @param string $readonly Create readonly (one-time) session * * @return string Manticore URI * @throws Exception */ - public function session_start($file, &$session_id = null) + public function session_start($file, &$file_info, &$session_id = null, $readonly = false) { - 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); - } + parent::session_start($file, $file_info, $session_id, $readonly); - // 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) + protected function session_create($id, $uri, $owner, $data, $readonly = false) { - // 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, $readonly); // 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) + * List supported mimetypes + * + * @param bool $editable Return only editable mimetypes * - * @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 + * @return array List of supported mimetypes */ - public function session_uri_update($from, $to, $is_folder = false) + public function supported_filetypes($editable = 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; + return array( + 'application/vnd.oasis.opendocument.text', + ); } /** * 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_viewer.php b/lib/file_viewer.php index 48c49d9..d8ca18f 100644 --- a/lib/file_viewer.php +++ b/lib/file_viewer.php @@ -1,95 +1,95 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Abstract viewer class */ abstract class file_viewer { protected $mimetypes = array(); protected $api; /** * Class constructor * * @param file_api File API object */ public function __construct($api) { $this->api = $api; } /** * Returns list of supported mimetype * * @return array List of mimetypes */ public function supported_mimetypes() { return $this->mimetypes; } /** * Check if mimetype is supported by the viewer * * @param string $mimetype File type * * @return bool */ public function supports($mimetype) { return in_array($mimetype, $this->mimetypes); } /** * Print output and exit * - * @param string $file File name - * @param string $mimetype File type + * @param string $file File name + * @param array $file_info File metadata (e.g. type) */ - public function output($file, $mimetype = null) + public function output($file, $file_info = array()) { } /** * Return output of file content area * * @param string $file File name * @param string $mimetype File type */ public function frame($file, $mimetype = null) { } /** * Return file URL of file content area * * @param string $file File name * @param string $mimetype File type */ public function href($file, $mimetype = null) { } } diff --git a/lib/file_wopi.php b/lib/file_wopi.php new file mode 100644 index 0000000..7382aed --- /dev/null +++ b/lib/file_wopi.php @@ -0,0 +1,307 @@ + | + +--------------------------------------------------------------------------+ + | Author: Aleksander Machniak | + +--------------------------------------------------------------------------+ +*/ + +/** + * Document editing sessions handling (WOPI) + */ +class file_wopi extends file_document +{ + protected $cache; + + // Mimetypes supported by CODE, but not advertised by all possible names + protected $aliases = array( + 'application/vnd.corel-draw' => 'image/x-coreldraw', + ); + + /** + * Return viewer URI for specified file/session. This creates + * a new collaborative editing session when needed. + * + * @param string $file File path + * @param array &$file_info File metadata (e.g. type) + * @param string &$session_id Optional session ID to join to + * @param string $readonly Create readonly (one-time) session + * + * @return string WOPI URI for specified document + * @throws Exception + */ + public function session_start($file, &$file_info, &$session_id = null, $readonly = false) + { + parent::session_start($file, $file_info, $session_id, $readonly); + + 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; + + $this->token = $this->api->session->create($data); + } + + return $this->frame_uri($session_id, $file_info['type']); + } + + /** + * Generate URI of WOPI editing session (WOPIsrc) + */ + protected function frame_uri($id, $mimetype) + { + $capabilities = $this->capabilities(); + + if (empty($capabilities) || empty($mimetype)) { + return; + } + + $metadata = $capabilities[strtolower($mimetype)]; + + 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 to 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( + 'access_token' => $this->token, + 'access_token_ttl' => $ttl ?: 0, + ); + + // @TODO: we should/could also add: + // lang, title, timestamp, closebutton, revisionhistory + return $params; + } + + /** + * List supported mimetypes + * + * @param bool $editable Return only editable mimetypes + * + * @return array List of supported mimetypes + */ + public function supported_filetypes($editable = false) + { + $caps = $this->capabilities(); + + if ($editable) { + $editable = array(); + foreach ($caps as $mimetype => $c) { + if ($c['name'] == 'edit') { + $editable[] = $mimetype; + } + } + + return $editable; + } + + 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 $this->apply_aliases($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 $this->apply_aliases($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; + } + + /** + * Get cached data + */ + protected function get_from_cache($key) + { + if ($cache = $this->get_cache) { + return $cache->get($key); + } + } + + /** + * Store data in cache + */ + 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; + } + + /** + * Support more mimetypes in CODE capabilities + */ + protected function apply_aliases($caps) + { + foreach ($this->aliases as $type => $alias) { + if (isset($caps[$type]) && !isset($caps[$alias])) { + $caps[$alias] = $caps[$type]; + } + } + + return $caps; + } +} diff --git a/lib/locale/en_US.php b/lib/locale/en_US.php index 357333b..f5b6313 100644 --- a/lib/locale/en_US.php +++ b/lib/locale/en_US.php @@ -1,87 +1,93 @@ Kolab Server.'; $LANG['about.warranty'] = 'Professional support is available from Kolab Systems.'; $LANG['about.support'] = 'It comes with absolutely no warranties and is typically run entirely self supported. You can find help & information on the community web site & wiki.'; $LANG['collection.audio'] = 'Audio'; $LANG['collection.video'] = 'Video'; $LANG['collection.image'] = 'Images'; $LANG['collection.document'] = 'Documents'; $LANG['file.copy'] = 'Copy'; $LANG['file.create'] = 'Create File'; $LANG['file.download'] = 'Download'; $LANG['file.edit'] = 'Edit'; $LANG['file.upload'] = 'Upload File'; $LANG['file.name'] = 'Name'; $LANG['file.move'] = 'Move'; $LANG['file.mtime'] = 'Modified'; $LANG['file.size'] = 'Size'; $LANG['file.open'] = 'Open'; $LANG['file.delete'] = 'Delete'; $LANG['file.rename'] = 'Rename'; $LANG['file.search'] = 'Search file'; $LANG['file.type'] = 'Type'; $LANG['file.save'] = 'Save'; $LANG['file.skip'] = 'Skip'; $LANG['file.skipall'] = 'Skip all'; $LANG['file.overwrite'] = 'Overwrite'; $LANG['file.overwriteall'] = 'Overwrite all'; $LANG['file.moveconfirm'] = 'This action is going to overwrite the destination file: $file.'; $LANG['file.progress'] = 'Uploaded $current of $total ($percent%)'; $LANG['folder.createtitle'] = 'Create Folder'; $LANG['folder.delete'] = 'Delete'; $LANG['folder.edit'] = 'Edit'; $LANG['folder.edittitle'] = 'Edit Folder'; $LANG['folder.under'] = 'inside the current folder'; $LANG['folder.driverselect'] = 'bind with the external storage'; $LANG['folder.driverwithpass'] = 'remember password'; $LANG['folder.driverwithpassdesc'] = 'Stored passwords will be encrypted. Enable this if you do not want to be asked for the password on every login or you want this storage to be available via WebDAV.'; $LANG['folder.name'] = 'Name:'; $LANG['folder.authenticate'] = 'Logon to $title'; $LANG['folder.parent'] = 'Subfolder of:'; $LANG['form.submit'] = 'Submit'; $LANG['form.cancel'] = 'Cancel'; $LANG['form.hostname'] = 'Hostname:'; $LANG['form.username'] = 'Username:'; $LANG['form.password'] = 'Password:'; $LANG['form.baseuri'] = 'Base URI:'; $LANG['login.username'] = 'Username'; $LANG['login.password'] = 'Password'; $LANG['login.login'] = 'Log in'; $LANG['reqtime'] = 'Request time: $1 sec.'; $LANG['maxupload'] = 'Maximum file size: $1'; $LANG['internalerror'] = 'Internal system error!'; $LANG['loginerror'] = 'Incorrect username or password!'; $LANG['authenticating'] = 'Authenticating...'; $LANG['loading'] = 'Loading...'; $LANG['saving'] = 'Saving...'; $LANG['deleting'] = 'Deleting...'; $LANG['copying'] = 'Copying...'; $LANG['moving'] = 'Moving...'; $LANG['logout'] = 'Logout'; $LANG['close'] = 'Close'; $LANG['servererror'] = 'Server Error!'; $LANG['session.expired'] = 'Session has expired. Login again, please'; $LANG['localstorage'] = 'local storage'; $LANG['search'] = 'Search'; $LANG['search.loading'] = 'Searching...'; $LANG['search.in_all_folders'] = 'in all folders'; $LANG['search.in_current_folder'] = 'in current folder'; $LANG['size.B'] = 'B'; $LANG['size.KB'] = 'KB'; $LANG['size.MB'] = 'MB'; $LANG['size.GB'] = 'GB'; +$LANG['type.vndoasisopendocumenttext'] = 'OpenDocument Document (.odt)'; +$LANG['type.vndoasisopendocumentpresentation'] = 'OpenDocument Presentation (.odp)'; +$LANG['type.vndoasisopendocumentspreadsheet'] = 'OpenDocument Spreadsheet (.ods)'; +$LANG['type.plain'] = 'Plain Text Document (.txt)'; +$LANG['type.html'] = 'HTML Document (.html)'; + $LANG['upload.size'] = 'Size:'; $LANG['upload.size.error'] = 'Maximum upload size ($size) exceeded!'; $LANG['upload.progress'] = 'Progress:'; $LANG['upload.rate'] = 'Rate:'; $LANG['upload.eta'] = 'ETA:'; diff --git a/lib/templates/empty.odp b/lib/templates/empty.odp new file mode 100644 index 0000000..87544cf Binary files /dev/null and b/lib/templates/empty.odp differ diff --git a/lib/templates/empty.ods b/lib/templates/empty.ods new file mode 100644 index 0000000..b51d0bf Binary files /dev/null and b/lib/templates/empty.ods differ diff --git a/lib/file_viewer.php b/lib/viewers/doc.php similarity index 51% copy from lib/file_viewer.php copy to lib/viewers/doc.php index 48c49d9..0a80e7c 100644 --- a/lib/file_viewer.php +++ b/lib/viewers/doc.php @@ -1,95 +1,133 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** - * Abstract viewer class + * Class integrating Collabora Online documents viewer */ -abstract class file_viewer +class file_viewer_doc extends file_viewer { - protected $mimetypes = array(); - protected $api; - - /** * Class constructor * * @param file_api File API object */ public function __construct($api) { $this->api = $api; } /** * Returns list of supported mimetype * * @return array List of mimetypes */ public function supported_mimetypes() { - return $this->mimetypes; + $rcube = rcube::get_instance(); + + // Get list of supported types from Collabora + if ($rcube->config->get('fileapi_wopi_office')) { + $wopi = new file_wopi($this->api); + if ($types = $wopi->supported_filetypes()) { + return $types; + } + } + + return array(); } /** * Check if mimetype is supported by the viewer * * @param string $mimetype File type * - * @return bool + * @return bool True if mimetype is supported, False otherwise */ public function supports($mimetype) { - return in_array($mimetype, $this->mimetypes); + return in_array($mimetype, $this->supported_mimetypes()); } /** - * Print output and exit + * Return file viewer URL * * @param string $file File name * @param string $mimetype File type */ - public function output($file, $mimetype = null) + public function href($file, $mimetype = null) { + return file_utils::script_uri() . '?method=file_get' + . '&viewer=doc' + . '&file=' . urlencode($file) + . '&token=' . urlencode(session_id()); } /** - * Return output of file content area + * Print output and exit * - * @param string $file File name - * @param string $mimetype File type + * @param string $file File name + * @param array $file_info File metadata (e.g. type) */ - public function frame($file, $mimetype = null) + public function output($file, $file_info = array()) { - } + // Create readonly session and get WOPI request parameters + $wopi = new file_wopi($this->api); + $url = $wopi->session_start($file, $file_info, $session, true); - /** - * Return file URL of file content area - * - * @param string $file File name - * @param string $mimetype File type - */ - public function href($file, $mimetype = null) - { + if (!$url) { + $this->api->output_error("Failed to open file", 404); + } + + $info = array('readonly' => true); + $post = $wopi->editor_post_params($info); + $url = htmlentities($url); + $form = ''; + + foreach ($post as $name => $value) { + $form .= ''; + } + + echo << + + + + + + +
+ $form +
+ + + +EOT; } } diff --git a/lib/viewers/doc/file_editor.js b/lib/viewers/doc/file_editor.js new file mode 100644 index 0000000..fed909b --- /dev/null +++ b/lib/viewers/doc/file_editor.js @@ -0,0 +1,34 @@ + +function file_editor() +{ + this.editable = false; + this.printable = false; + + this.init = function() + { + document.getElementsByTagName('form')[0].submit(); + }; + + // switch editor into read-write mode + this.enable = function() + { + // @TODO + }; + + // switch editor into read-only mode + this.disable = function() + { + // @TODO + }; + + this.getContent = function() + { + // @TODO + }; + + // print file content + this.print = function() + { + // @TODO + }; +} diff --git a/lib/viewers/image.php b/lib/viewers/image.php index 8b288a4..96ced54 100644 --- a/lib/viewers/image.php +++ b/lib/viewers/image.php @@ -1,111 +1,111 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Class implementing image viewer (with format converter) * * NOTE: some formats are supported by browser, don't use viewer when not needed. */ class file_viewer_image extends file_viewer { protected $mimetypes = array( 'image/bmp', 'image/png', 'image/jpeg', 'image/jpg', 'image/pjpeg', 'image/gif', 'image/tiff', 'image/x-tiff', ); /** * Class constructor * * @param file_api File API object */ public function __construct($api) { // @TODO: disable types not supported by some browsers $this->api = $api; } /** * Return file viewer URL * * @param string $file File name * @param string $mimetype File type */ public function href($file, $mimetype = null) { $href = file_utils::script_uri() . '?method=file_get' . '&file=' . urlencode($file) . '&token=' . urlencode(session_id()); // we redirect to self only images with types unsupported // by browser if (in_array($mimetype, $this->mimetypes)) { $href .= '&viewer=image'; } return $href; } /** * Print output and exit * - * @param string $file File name - * @param string $mimetype File type + * @param string $file File name + * @param array $file_info File metadata (e.g. type) */ - public function output($file, $mimetype = null) + public function output($file, $file_info = array()) { /* // conversion not needed - if (preg_match('/^image/p?jpe?g$/i', $mimetype)) { + if (preg_match('/^image/p?jpe?g$/i', $file_info['type'])) { $this->api->api->file_get($file); return; } */ $rcube = rcube::get_instance(); $temp_dir = unslashify($rcube->config->get('temp_dir')); $file_path = tempnam($temp_dir, 'rcmImage'); list($driver, $file) = $this->api->get_driver($file); // write content to temp file $fd = fopen($file_path, 'w'); $driver->file_get($file, array(), $fd); fclose($fd); // convert image to jpeg and send it to the browser $image = new rcube_image($file_path); if ($image->convert(rcube_image::TYPE_JPG, $file_path)) { header("Content-Type: image/jpeg"); header("Content-Length: " . filesize($file_path)); readfile($file_path); } unlink($file_path); } } diff --git a/lib/viewers/odf.php b/lib/viewers/odf.php index 3ff5d2b..c285908 100644 --- a/lib/viewers/odf.php +++ b/lib/viewers/odf.php @@ -1,147 +1,136 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Class integrating ODF documents viewer from http://webodf.org */ class file_viewer_odf extends file_viewer { protected $mimetypes = array( 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet', 'application/vnd.oasis.opendocument.presentation', 'application/vnd.oasis.opendocument.graphics', 'application/vnd.oasis.opendocument.chart', // 'application/vnd.oasis.opendocument.formula', 'application/vnd.oasis.opendocument.image', 'application/vnd.oasis.opendocument.text-master', // 'application/vnd.sun.xml.base', // 'application/vnd.oasis.opendocument.base', // 'application/vnd.oasis.opendocument.database', 'application/vnd.oasis.opendocument.text-template', 'application/vnd.oasis.opendocument.spreadsheet-template', 'application/vnd.oasis.opendocument.presentation-template', 'application/vnd.oasis.opendocument.graphics-template', 'application/vnd.oasis.opendocument.chart-template', // 'application/vnd.oasis.opendocument.formula-template', 'application/vnd.oasis.opendocument.image-template', ); /** * Class constructor * * @param file_api File API object */ public function __construct($api) { $this->api = $api; $browser = $api->get_browser(); // disable viewer in unsupported browsers if ($browser->ie && $browser->ver < 9) { $this->mimetypes = array(); } } /** * Returns list of supported mimetype * * @return array List of mimetypes */ public function supported_mimetypes() { // @TODO: check supported browsers return $this->mimetypes; } /** * Check if mimetype is supported by the viewer * * @param string $mimetype File type * * @return bool */ public function supports($mimetype) { return in_array($mimetype, $this->mimetypes); } - /** - * Return output of file content area - * - * @param string $file File name - * @param string $mimetype File type - */ - public function frame($file, $mimetype = null) - { - // we use iframe method, see output() - } - /** * Return file viewer URL * * @param string $file File name * @param string $mimetype File type */ public function href($file, $mimetype = null) { return file_utils::script_uri() . '?method=file_get' . '&viewer=odf' . '&file=' . urlencode($file) . '&token=' . urlencode(session_id()); } /** * Print output and exit * - * @param string $file File name - * @param string $mimetype File type + * @param string $file File name + * @param array $file_info File metadata (e.g. type) */ - public function output($file, $mimetype = null) + public function output($file, $file_info = array()) { $file_uri = $this->api->file_url($file); echo <<
EOT; } } diff --git a/lib/viewers/text.php b/lib/viewers/text.php index 8708f77..3e9b4b8 100644 --- a/lib/viewers/text.php +++ b/lib/viewers/text.php @@ -1,216 +1,216 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Class integrating text editor http://ajaxorg.github.io/ace */ class file_viewer_text extends file_viewer { /** * Mimetype to tokenizer map * * @var array */ protected $mimetypes = array( 'text/plain' => 'text', 'text/html' => 'html', 'text/javascript' => 'javascript', 'text/ecmascript' => 'javascript', 'text/x-c' => 'c_cpp', 'text/css' => 'css', 'text/x-java-source' => 'java', 'text/x-php' => 'php', 'text/x-sh' => 'sh', 'text/xml' => 'xml', 'application/xml' => 'xml', 'application/x-vbscript' => 'vbscript', 'message/rfc822' => 'text', 'application/x-empty' => 'text', ); /** * File extension to highligter mode mapping * * @var array */ protected $extensions = array( 'php' => '/^(php|phpt|inc)$/', 'html' => '/^html?$/', 'css' => '/^css$/', 'xml' => '/^xml$/', 'javascript' => '/^js$/', 'sh' => '/^sh$/', ); /** * Returns list of supported mimetype * * @return array List of mimetypes */ public function supported_mimetypes() { // we return only mimetypes not starting with text/ $mimetypes = array(); foreach (array_keys($this->mimetypes) as $type) { if (strpos($type, 'text/') !== 0) { $mimetypes[] = $type; } } return $mimetypes; } /** * Check if mimetype is supported by the viewer * * @param string $mimetype File type * * @return bool */ public function supports($mimetype) { return $this->mimetypes[$mimetype] || preg_match('/^text\/(?!(pdf|x-pdf))/', $mimetype); } /** * Print file content */ protected function print_file($file) { $stdout = fopen('php://output', 'w'); stream_filter_register('file_viewer_text', 'file_viewer_content_filter'); stream_filter_append($stdout, 'file_viewer_text'); list($driver, $file) = $this->api->get_driver($file); $driver->file_get($file, array(), $stdout); } /** * Return file viewer URL * * @param string $file File name * @param string $mimetype File type */ public function href($file, $mimetype = null) { return $this->api->file_url($file) . '&viewer=text'; } /** * Print output and exit * - * @param string $file File name - * @param string $mimetype File type + * @param string $file File name + * @param array $file_info File metadata (e.g. type) */ - public function output($file, $mimetype = null) + public function output($file, $file_info = array()) { - $mode = $this->get_mode($mimetype, $file); + $mode = $this->get_mode($file_info['type'], $file); $href = addcslashes($this->api->file_url($file), "'"); echo ' Editor
';
 
         $this->print_file($file);
 
         echo "
"; } protected function get_mode($mimetype, $filename) { $mimetype = strtolower($mimetype); if ($this->mimetypes[$mimetype]) { return $this->mimetypes[$mimetype]; } $filename = explode('.', $filename); $extension = count($filename) > 1 ? array_pop($filename) : null; if ($extension) { foreach ($this->extensions as $mode => $regexp) { if (preg_match($regexp, $extension)) { return $mode; } } } return 'text'; } } /** * PHP stream filter to detect escape html special chars in a file */ class file_viewer_content_filter extends php_user_filter { private $buffer = ''; private $cutoff = 2048; function onCreate() { $this->cutoff = rand(2048, 3027); return true; } function filter($in, $out, &$consumed, $closing) { while ($bucket = stream_bucket_make_writeable($in)) { $bucket->data = htmlspecialchars($bucket->data, ENT_COMPAT | ENT_HTML401 | ENT_IGNORE); $this->buffer .= $bucket->data; // keep buffer small enough if (strlen($this->buffer) > 4096) { $this->buffer = substr($this->buffer, $this->cutoff); } $consumed += $bucket->datalen; // or strlen($bucket->data)? stream_bucket_append($out, $bucket); } return PSFS_PASS_ON; } } diff --git a/public_html/js/files_api.js b/public_html/js/files_api.js index ddbf694..4fbeef1 100644 --- a/public_html/js/files_api.js +++ b/public_html/js/files_api.js @@ -1,1069 +1,1195 @@ /** +--------------------------------------------------------------------------+ | This file is part of the Kolab File API | | | | Copyright (C) 2012-2015, Kolab Systems AG | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ function files_api() { var ref = this; // default config this.sessions = {}; this.translations = {}; this.env = { url: 'api/', directory_separator: '/', resources_dir: 'resources' }; /*********************************************************/ /********* Basic utilities *********/ /*********************************************************/ // set environment variable(s) this.set_env = function(p, value) { if (p != null && typeof p === 'object' && !value) for (var n in p) this.env[n] = p[n]; else this.env[p] = value; }; // add a localized label(s) to the client environment this.tdef = function(p, value) { if (typeof p == 'string') this.translations[p] = value; else if (typeof p == 'object') $.extend(this.translations, p); }; // return a localized string this.t = function(label) { if (this.translations[label]) return this.translations[label]; else return label; }; /********************************************************/ /********* Remote request methods *********/ /********************************************************/ // send a http POST request to the API service this.post = function(action, data, func) { var url = this.env.url + '?method=' + action; if (!func) func = 'response'; this.set_request_time(); return $.ajax({ type: 'POST', url: url, data: JSON.stringify(data), dataType: 'json', contentType: 'application/json; charset=utf-8', success: function(response) { if (typeof func == 'function') func(response); else ref[func](response); }, error: function(o, status, err) { ref.http_error(o, status, err, data); }, cache: false, beforeSend: function(xmlhttp) { xmlhttp.setRequestHeader('X-Session-Token', ref.env.token); } }); }; // send a http GET request to the API service this.get = function(action, data, func) { var url = this.env.url; if (!func) func = 'response'; this.set_request_time(); data.method = action; return $.ajax({ type: 'GET', url: url, data: data, dataType: 'json', success: function(response) { if (typeof func == 'function') func(response); else ref[func](response); }, error: function(o, status, err) { ref.http_error(o, status, err, data); }, cache: false, beforeSend: function(xmlhttp) { xmlhttp.setRequestHeader('X-Session-Token', ref.env.token); } }); }; // send request with auto-selection of POST/GET method this.request = function(action, data, func) { // Use POST for modification actions with probable big request size var method = /_(create|delete|move|copy|update|auth|subscribe|unsubscribe|invite|decline|request|accept|remove)$/.test(action) ? 'post' : 'get'; return this[method](action, data, func); }; // handle HTTP request errors this.http_error = function(request, status, err, data) { var errmsg = request.statusText; this.set_busy(false); request.abort(); if (request.status && errmsg) this.display_message(this.t('servererror') + ' (' + errmsg + ')', 'error'); }; this.response = function(response) { this.update_request_time(); this.set_busy(false); return this.response_parse(response); }; this.response_parse = function(response) { if (!response || response.status != 'OK') { // Logout on invalid-session error if (response && response.code == 403) this.logout(response); else this.display_message(response && response.reason ? response.reason : this.t('servererror'), 'error'); return false; } return true; }; /*********************************************************/ /********* Utilities *********/ /*********************************************************/ // Called on "session expired" session this.logout = function(response) {}; // set state this.set_busy = function(state, message) {}; // displays error message this.display_message = function(label, type) {}; // called when a request timed out this.request_timed_out = function() {}; // called on start of the request this.set_request_time = function() {}; // called on request response this.update_request_time = function() {}; /*********************************************************/ /********* Helpers *********/ /*********************************************************/ // compose a valid url with the given parameters this.url = function(action, query) { var k, param = {}, querystring = typeof query === 'string' ? '&' + query : ''; if (typeof action !== 'string') query = action; else if (!query || typeof query !== 'object') query = {}; // overwrite task name if (action) query.method = action; // remove undefined values for (k in query) { if (query[k] !== undefined && query[k] !== null) param[k] = query[k]; } return '?' + $.param(param) + querystring; }; // fill folder selector with options this.folder_select_element = function(select, params) { var options = [], selected = params && params.selected ? params.selected : this.env.folder; if (params && params.empty) options.push($('