diff --git a/doc/SQL/mysql.initial.sql b/doc/SQL/mysql.initial.sql index 0f8364f..6e90f24 100644 --- a/doc/SQL/mysql.initial.sql +++ b/doc/SQL/mysql.initial.sql @@ -1,24 +1,35 @@ 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, `data` mediumtext, 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, + `status` varchar(16) NOT NULL, + `changed` datetime DEFAULT NULL, + 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'); diff --git a/lib/api/document.php b/lib/api/document.php index aef2a3b..3b89943 100644 --- a/lib/api/document.php +++ b/lib/api/document.php @@ -1,143 +1,188 @@ | +--------------------------------------------------------------------------+ | 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']; } - // Document content actions for Manticore - if ($method == 'PUT' || $method == 'GET') { + // Sessions and invitations management + if (strpos($this->args['method'], 'document_') === 0) { + if ($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': + return $this->document_delete($this->args['id']); - $file = $this->get_file_path($this->args['id']); + case 'document_invite': + return $this->document_invite($this->args['id']); - return $this->{'document_' . strtolower($method)}($file); +// case 'document_request': +// case 'document_decline': +// case 'document_accept': +// case 'document_remove': + } } - // Sessions and invitations management - else if ($method == 'POST' && $_GET['method'] == 'document_delete') { - $post = file_get_contents('php://input'); - $this->args += (array) json_decode($post, true); - unset($post); - + // 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); } - - return $this->document_delete($this->args['id']); + + $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); return $manticore->session_file($id); } /** * Close (delete) manticore session */ protected function document_delete($id) { $manticore = new file_manticore($this->api); if (!$manticore->session_delete($id)) { throw new Exception("Failed deleting the document session.", file_api_core::ERROR_CODE); } } + /** + * Invite/add a session participant + */ + protected function document_invite($id) + { + $manticore = new file_manticore($this->api); + $users = $this->args['users']; + + 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)) { + throw new Exception("Failed adding a session participant.", file_api_core::ERROR_CODE); + } + + $result = array( + 'session_id' => $id, + 'user' => $user['user'], +// 'name' => $user['name'], + 'status' => file_manticore::STATUS_INVITED, + ); + } + + return array( + 'list' => $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( 'path' => $tmp_path, 'type' => rcube_mime::file_content_type($tmp_path, $file), ); $driver->file_update($path, $file); // remove the temp file unlink($tmp_path); } /** * 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_info.php b/lib/api/file_info.php index 5f7ba62..b5d3053 100644 --- a/lib/api/file_info.php +++ b/lib/api/file_info.php @@ -1,98 +1,98 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class file_api_file_info extends file_api_common { /** * Request handler */ public function handle() { parent::handle(); if (!isset($this->args['file']) || $this->args['file'] === '') { throw new Exception("Missing file name", file_api_core::ERROR_CODE); } list($driver, $path) = $this->api->get_driver($this->args['file']); $info = $driver->file_info($path); // Possible 'viewer' types are defined in files_api.js:file_type_supported() // 1 - Native browser support // 2 - Chwala viewer exists // 4 - Manticore (WebODF collaborative editor) if (rcube_utils::get_boolean((string) $this->args['viewer'])) { $this->file_viewer_info($info); // check if file type is supported by webodf editor? if ($this->rc->config->get('fileapi_manticore')) { if (strtolower($info['type']) == 'application/vnd.oasis.opendocument.text') { $info['viewer']['manticore'] = true; } } if ((intval($this->args['viewer']) & 4) && $info['viewer']['manticore']) { $this->file_manticore_handler($info); } } return $info; } /** * Merge file viewer data into file info */ protected function file_viewer_info(&$info) { $file = $this->args['file']; $viewer = $this->find_viewer($info['type']); if ($viewer) { $info['viewer'] = array(); if ($frame = $viewer->frame($file, $info['type'])) { $info['viewer']['frame'] = $frame; } else if ($href = $viewer->href($file, $info['type'])) { $info['viewer']['href'] = $href; } } } /** * Merge manticore session data into file info */ protected function file_manticore_handler(&$info) { $manticore = new file_manticore($this->api); $file = $this->args['file']; $session = $this->args['session']; if ($uri = $manticore->session_start($file, $session)) { $info['viewer']['href'] = $uri; - $info['session'] = $manticore->session_info($session); + $info['session'] = $manticore->session_info($session, true); } } } diff --git a/lib/file_api.php b/lib/file_api.php index e40f377..e5706ac 100644 --- a/lib/file_api.php +++ b/lib/file_api.php @@ -1,439 +1,442 @@ | +--------------------------------------------------------------------------+ | 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->session->destroy(session_id()); $this->session->regenerate_id(false); if ($username = $this->authenticate()) { $_SESSION['user'] = $username; $_SESSION['time'] = time(); $_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() { $sess_id = rcube_utils::request_header('X-Session-Token') ?: $_REQUEST['token']; if (empty($sess_id)) { session_start(); return false; } session_id($sess_id); session_start(); if (empty($_SESSION['user'])) { return false; } $timeout = $this->config->get('session_lifetime', 0) * 60; if ($timeout && $_SESSION['time'] && $_SESSION['time'] < time() - $timeout) { return false; } // update session time $_SESSION['time'] = time(); 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', - // document actions - 'document_delete' => 'document', ); + // Redirect all document_* actions into 'document' action + if (strpos($request, 'document_') === 0) { + $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']; } if (empty($response['code'])) { $response['code'] = file_api_core::ERROR_CODE; } $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; } /** * 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_manticore.php b/lib/file_manticore.php index 502caad..f608f5d 100644 --- a/lib/file_manticore.php +++ b/lib/file_manticore.php @@ -1,348 +1,555 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Document editing sessions handling */ class file_manticore { protected $api; protected $rc; protected $request; - protected $table = 'chwala_sessions'; + protected $sessions_table = 'chwala_sessions'; + protected $invitations_table = 'chwala_invitations'; + + const STATUS_INVITED = 'invited'; + const STATUS_REQUESTED = 'requested'; + const STATUS_ACCEPTED = 'accepted'; + const STATUS_DECLINED = 'declined'; /** * Class constructor * * @param file_api Chwala API app instance */ public function __construct($api) { $this->rc = rcube::get_instance(); $this->api = $api; + + $db = $this->rc->get_dbh(); + $this->sessions_table = $db->table_name($this->sessions_table); + $this->invitations_table = $db->table_name($this->invitations_table); } /** * Return viewer URI for specified file/session. This creates * a new collaborative editing session when needed. * * @param string $file File path * @param string &$session_id Optional session ID to join to * * @return string Manticore URI * @throws Exception */ public function session_start($file, &$session_id = null) { list($driver, $path) = $this->api->get_driver($file); $backend = $this->api->get_backend(); $uri = $driver->path2uri($path); if ($session_id) { $session = $this->session_info($session_id); if (empty($session)) { - throw new Exception("Document session ID not found.", file_api_core::ERROR_CODE); + throw new Exception("Document session not found.", file_api_core::ERROR_CODE); } - // check session membership + // check session ownership if ($session['owner'] != $_SESSION['user']) { - throw new Exception("No permission to join the editing session.", file_api_core::ERROR_CODE); + // check if the user was invited + $invitations = $this->invitations_list($session_id); + $states = array(self::STATUS_DECLINED, self::STATUS_REQUESTED); + + 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, $_SESSION['user'], self::STATUS_ACCEPTED); + } } - // @TODO: check if session exists in Manticore? - // @TOOD: joining sessions of other users + // @TODO: make sure the session exists in Manticore? } else { $session_id = rcube_utils::bin2ascii(md5(time() . $uri, true)); $data = array(); $owner = $_SESSION['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); } } return $this->frame_uri($session_id); } /** * Get file path (not URI) from session. * * @param string $id Session ID * * @return string File path * @throws Exception */ public function session_file($id) { $session = $this->session_info($id); if (empty($session)) { - throw new Exception("Document session ID not found.", file_api_core::ERROR_CODE); + throw new Exception("Document session not found.", file_api_core::ERROR_CODE); } $path = $this->uri2path($session['uri']); if (empty($path)) { - throw new Exception("Document session ID not found.", file_api_core::ERROR_CODE); + throw new Exception("Document session not found.", file_api_core::ERROR_CODE); } // @TODO: check permissions to the session return $path; } /** * Get editing session info + * + * @param string $id Session identifier + * @param bool $with_invitations Return invitations list */ - public function session_info($id) + public function session_info($id, $with_invitations = false) { $db = $this->rc->get_dbh(); - $result = $db->query("SELECT * FROM `{$this->table}`" + $result = $db->query("SELECT * FROM `{$this->sessions_table}`" . " WHERE `id` = ?", $id); if ($row = $db->fetch_assoc($result)) { - return $this->session_info_parse($row); + $session = $this->session_info_parse($row); + + 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) + public function session_find($path, $invitations = true) { // create an URI for specified path list($driver, $path) = $this->api->get_driver($path); $uri = trim($driver->path2uri($path), '/') . '/'; // get existing sessions $sessions = array(); $filter = array('file', 'owner', 'is_owner'); $db = $this->rc->get_dbh(); - $result = $db->query("SELECT * FROM `{$this->table}`" + $result = $db->query("SELECT * FROM `{$this->sessions_table}`" . " WHERE `uri` LIKE '" . $db->escape($uri) . "%'"); - if ($row = $db->fetch_assoc($result)) { + 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_list(); + $states = array(self::STATUS_INVITED, self::STATUS_ACCEPTED); + + 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->table}`" + $result = $db->query("DELETE FROM `{$this->sessions_table}`" . " WHERE `id` = ? AND `owner` = ?", $id, $_SESSION['user']); $success = $db->affected_rows($result) > 0; // Send document delete to Manticore if ($success && !$local) { $req = $this->get_request(); $res = $req->document_delete($id); } return $success; } /** * Create editing session */ protected function session_create($id, $uri, $owner, $data) { // 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->table}`" + $result = $db->query("INSERT INTO `{$this->sessions_table}`" . " (`id`, `uri`, `owner`, `data`) VALUES (?, ?, ?, ?)", $id, $uri, $owner, 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' => 'write', + 'permission' => file_manticore_api::ACCESS_WRITE, ), ), )); if (!$res) { $this->session_delete($id, true); return false; } } return $success; } + /** + * Find invitations for current user + * + * + * + * @return array Invitations list + */ + public function invitations_list($session_id = null) + { + $invitations = array(); + $db = $this->rc->get_dbh(); + $result = $db->query("SELECT * FROM `{$this->invitations_table}`" + . " WHERE `user`= ?" + . ($session_id ? " AND `session_id` = " . $db->quote($session_id) : "") + . " ORDER BY `changed`", $_SESSION['user']); + + while ($row = $db->fetch_assoc($result)) { + $invitations[] = $row; + } + + return $invitations; + } + + /** + * Find invitations for specified session_id + */ + public function invitations_find($filter) + { + $invitations = array(); + $db = $this->rc->get_dbh(); + + foreach ($filter as $column => $value) { + $filter[$column] = "`$column` = " . $db->quote($value); + } + + $where = implode(' AND ', $filter); + $result = $db->query("SELECT * FROM `{$this->invitations_table}`" + . " WHERE $where ORDER BY `changed`"); + + while ($row = $db->fetch_assoc($result)) { + $invitations[] = $row; + } + + return $invitations; + } + + /** + * Create an invitation + * + * @param string $session_id Document session identifier + * @param string $user User identifier + * @param string $status Invitation status (invited, requested) + * + * @throws Exception + */ + public function invitation_create($session_id, $user, $status = 'invited') + { + 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'] != $_SESSION['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); + } + } + + // insert invitation + $db = $this->rc->get_dbh(); + $result = $db->query("INSERT INTO `{$this->invitations_table}`" + . " (`session_id`, `user`, `status`, `changed`)" + . " VALUES (?, ?, ?, " . $db->now() . ")", + $session_id, $user, $status); + + 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 + * + * @throws Exception + */ + public function invitation_delete($session_id, $user) + { + $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, $_SESSION['user']); + + if (!$db->affected_rows($result)) { + throw new Exception("Failed to delete an invitation.", file_api_core::ERROR_CODE); + } + + // Update Manticore 'access' array + // @todo + } + + /** + * Update an invitation status + * + * @param string $session_id Session identifier + * @param string $user User identifier + * @param string $status Invitation status (accepted, declined) + * + * @throws Exception + */ + public function invitation_update($session_id, $user, $status) + { + 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 != $_SESSION['user'] && $session['owner'] != $_SESSION['user']) { + throw new Exception("No permission to update an invitation.", file_api_core::ERROR_CODE); + } + + $db = $this->rc->get_dbh(); + $result = $db->query("UPDATE `{$this->invitations_table}`" + . " SET `status` = ?, `changed` = " . $db->now() + . " WHERE `session_id` = ? AND `user` = ?", + $status, $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 && $_SESSION['user'] == $session['owner']) { + // @todo + } + } + /** * Parse session info data */ protected function session_info_parse($record, $path = null, $filter = array()) { /* if (is_string($data) && !empty($data)) { $data = json_decode($data, true); } */ $session = array(); $fields = array('id', 'uri', 'owner'); 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'] == $_SESSION['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 path from the URI */ protected function uri2path($uri) { $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 } } } /** * Return Manticore user/session info */ public function user_info() { $req = $this->get_request(); $res = $req->get('api/users/me'); return $res->get(); } /** * Initialize Manticore API request handler */ protected function get_request() { if (!$this->request) { $uri = rcube_utils::resolve_url($this->rc->config->get('fileapi_manticore')); $this->request = new file_manticore_api($uri); // Use stored session token, check if it's still valid if ($_SESSION['manticore_token']) { $is_valid = $this->request->set_session_token($_SESSION['manticore_token'], true); if ($is_valid) { return $this->request; } } $backend = $this->api->get_backend(); $auth = $backend->auth_info(); $_SESSION['manticore_token'] = $this->request->login($auth['username'], $auth['password']); if (empty($_SESSION['manticore_token'])) { throw new Exception("Unable to login to Manticore server.", file_api_core::ERROR_CODE); } } return $this->request; } } diff --git a/lib/file_manticore_api.php b/lib/file_manticore_api.php index 991dce4..2a6abd5 100644 --- a/lib/file_manticore_api.php +++ b/lib/file_manticore_api.php @@ -1,342 +1,394 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Helper class to connect to the Manticore API */ class file_manticore_api { /** * @var HTTP_Request2 */ private $request; /** * @var string */ private $base_url; /** * @var bool */ private $debug = false; const ERROR_INTERNAL = 100; const ERROR_CONNECTION = 500; const ACCEPT_HEADER = "application/json,text/javascript,*/*"; + const ACCESS_WRITE = 'write'; + const ACCESS_READ = 'read'; + const ACCESS_DENY = 'deny'; + /** * Class constructor. * * @param string $base_url Base URL of the Kolab API */ public function __construct($base_url) { require_once 'HTTP/Request2.php'; $config = rcube::get_instance()->config; $this->debug = rcube_utils::get_boolean($config->get('fileapi_manticore_debug')); $this->base_url = rtrim($base_url, '/') . '/'; $this->request = new HTTP_Request2(); self::configure($this->request); } /** * Configure HTTP_Request2 object * * @param HTTP_Request2 $request Request object */ public static function configure($request) { // Configure connection options $config = rcube::get_instance()->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', self::ACCEPT_HEADER); $request->setHeader('Content-Type', 'application/json; charset=UTF-8'); } /** * Return API's base URL * * @return string Base URL */ public function base_url() { return $this->base_url; } /** * Return HTTP_Request2 object * * @return HTTP_Request2 Request object */ public function request() { return $this->request; } /** * Logs specified user into the API * * @param string $username User name * @param string $password User password * * @return string Session token (on success) */ public function login($username, $password) { $query = array( 'email' => $username, 'password' => $password, ); // remove current token if any $this->request->setHeader('Authorization'); // authenticate the user $response = $this->post('auth/local', $query); if ($token = $response->get('token')) { $this->set_session_token($token); } return $token; } /** * Sets request session token. * * @param string $token Session token. * @param bool $validate Enables token validatity check * * @return bool Token validity status */ public function set_session_token($token, $validate = false) { $this->request->setHeader('Authorization', "Bearer $token"); if ($validate) { $result = $this->get('api/user/me'); return $result->get_error_code() == 200; } return true; } /** * Delete document editing session * * @param array $id Session identifier * * @return bool True on success, False on failure */ public function document_delete($id) { $res = $this->delete('api/documents/' . $id); return $res->get_error_code() == 204; } /** * Create document editing session * * @param array $params Session parameters * * @return bool True on success, False on failure */ public function document_create($params) { $res = $this->post('api/documents', $params); // @TODO: 422? return $res->get_error_code() == 201 || $res->get_error_code() == 422; } + /** + * Add document editor (update 'access' array) + * + * @param array $session_id Session identifier + * @param array $identity User identifier + * + * @return bool True on success, False on failure + */ + public function editor_add($session_id, $idenity, $permission) + { + $res = $this->get("api/documents/$session_id/access"); + +rcube::console($req); + if ($res->get_error_code() != 200) { + return false; + } + + // @todo add editor to the 'access' array + + + $res = $this->put("api/documents/$session_id/access", $params); + +rcube::console($req); + return $res->get_error_code() == 200; + } + /** * API's GET request. * * @param string $action Action name * @param array $get Request arguments * * @return file_ui_api_result Response */ public function get($action, $get = array()) { $url = $this->build_url($action, $get); if ($this->debug) { rcube::write_log('manticore', "GET: $url " . json_encode($get)); } $this->request->setMethod(HTTP_Request2::METHOD_GET); $this->request->setBody(''); return $this->get_response($url); } /** * API's POST request. * * @param string $action Action name * @param array $post POST arguments * * @return kolab_client_api_result Response */ public function post($action, $post = array()) { $url = $this->build_url($action); if ($this->debug) { rcube::write_log('manticore', "POST: $url " . json_encode($post)); } $this->request->setMethod(HTTP_Request2::METHOD_POST); $this->request->setBody(json_encode($post)); return $this->get_response($url); } + /** + * API's PUT request. + * + * @param string $action Action name + * @param array $post POST arguments + * + * @return kolab_client_api_result Response + */ + public function put($action, $post = array()) + { + $url = $this->build_url($action); + + if ($this->debug) { + rcube::write_log('manticore', "PUT: $url " . json_encode($post)); + } + + $this->request->setMethod(HTTP_Request2::METHOD_PUT); + $this->request->setBody(json_encode($post)); + + return $this->get_response($url); + } + /** * API's DELETE request. * * @param string $action Action name * @param array $get Request arguments * * @return file_ui_api_result Response */ public function delete($action, $get = array()) { $url = $this->build_url($action, $get); if ($this->debug) { rcube::write_log('manticore', "DELETE: $url " . json_encode($get)); } $this->request->setMethod(HTTP_Request2::METHOD_DELETE); $this->request->setBody(''); return $this->get_response($url); } /** * @param string $action Action GET parameter * @param array $args GET parameters (hash array: name => value) * * @return Net_URL2 URL object */ private function build_url($action, $args = array()) { $url = new Net_URL2($this->base_url . $action); $url->setQueryVariables((array) $args); return $url; } /** * HTTP Response handler. * * @param Net_URL2 $url URL object * * @return kolab_client_api_result Response object */ private function get_response($url) { try { $this->request->setUrl($url); $response = $this->request->send(); } catch (Exception $e) { return new file_ui_api_result(null, self::ERROR_CONNECTION, $e->getMessage()); } try { $body = $response->getBody(); } catch (Exception $e) { return new file_ui_api_result(null, self::ERROR_INTERNAL, $e->getMessage()); } $code = $response->getStatus(); if ($this->debug) { rcube::write_log('manticore', "Response [$code]: $body"); } if ($code < 300) { $result = $body ? json_decode($body, true) : array(); } else { if ($code != 401) { rcube::raise_error("Error $code on $url", true, false); } $error = $body; } return new file_ui_api_result($result, $code, $error); } } diff --git a/public_html/js/files_api.js b/public_html/js/files_api.js index 913dfcf..8c9501a 100644 --- a/public_html/js/files_api.js +++ b/public_html/js/files_api.js @@ -1,904 +1,927 @@ /* +--------------------------------------------------------------------------+ | This file is part of the Kolab File API | | | | Copyright (C) 2012-2013, 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; }; // print a message into browser console this.log = function(msg) { if (window.console && console.log) console.log(msg); }; /********************************************************/ /********* 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) { ref[func](response); }, error: function(o, status, err) { ref.http_error(o, status, err); }, 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) { ref[func](response); }, error: function(o, status, err) { ref.http_error(o, status, err); }, 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)/.test(action) ? 'post' : 'get'; + 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) { 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($('