diff --git a/lib/api/document.php b/lib/api/document.php index 3b89943..16078e2 100644 --- a/lib/api/document.php +++ b/lib/api/document.php @@ -1,188 +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']; } // Sessions and invitations management if (strpos($this->args['method'], 'document_') === 0) { - if ($method == 'POST') { + 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': return $this->document_delete($this->args['id']); case 'document_invite': return $this->document_invite($this->args['id']); // case 'document_request': // case 'document_decline': // case 'document_accept': // case 'document_remove': } } // 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); 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); + if (!empty($user['user'])) { + $manticore->invitation_create($id, $user['user'], file_manticore::STATUS_INVITED); + + $result[] = array( + 'session_id' => $id, + 'user' => $user['user'], +// 'name' => $user['name'], + 'status' => file_manticore::STATUS_INVITED, + ); } - - $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/file_manticore.php b/lib/file_manticore.php index f608f5d..966512d 100644 --- a/lib/file_manticore.php +++ b/lib/file_manticore.php @@ -1,555 +1,559 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Document editing sessions handling */ class file_manticore { protected $api; protected $rc; protected $request; 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 not found.", file_api_core::ERROR_CODE); } // check session ownership if ($session['owner'] != $_SESSION['user']) { // 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: make sure the session exists in Manticore? } else { + // @TODO: to prevent from creating a new sessions for the same file+user + // (e.g. when user uses F5 to refresh the page), we should check + // if such a session exist + $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 not found.", file_api_core::ERROR_CODE); } $path = $this->uri2path($session['uri']); if (empty($path)) { 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, $with_invitations = false) { $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); 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 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->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_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->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->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' => 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); + $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 2a6abd5..7d0d951 100644 --- a/lib/file_manticore_api.php +++ b/lib/file_manticore_api.php @@ -1,394 +1,405 @@ | +--------------------------------------------------------------------------+ | 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? + // @FIXME: 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) + public function editor_add($session_id, $identity, $permission) { $res = $this->get("api/documents/$session_id/access"); -rcube::console($req); if ($res->get_error_code() != 200) { return false; } + $access = $res->get(); + + // sanity check, this should never be empty + if (empty($access)) { + return false; + } + // @todo add editor to the 'access' array - + foreach ($access as $entry) { + if ($entry['identity'] == $identity) { + return true; + } + } + + $access[] = array('identity' => $identity, 'permission' => $permission); - $res = $this->put("api/documents/$session_id/access", $params); + $res = $this->put("api/documents/$session_id/access", $access); -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); } }