diff --git a/addressbook/inc/class.addressbook_ui.inc.php b/addressbook/inc/class.addressbook_ui.inc.php index 16e487cbf1..fb5fb4e8c4 100644 --- a/addressbook/inc/class.addressbook_ui.inc.php +++ b/addressbook/inc/class.addressbook_ui.inc.php @@ -730,6 +730,8 @@ class addressbook_ui extends addressbook_bo ) ) ); + $actions += EGroupware\Api\Link\Sharing::get_actions('addressbook', $group); + // check if user is an admin or the export is not generally turned off (contact_export_limit is non-numerical, eg. no) $exception = Api\Storage\Merge::is_export_limit_excepted(); if ((isset($GLOBALS['egw_info']['user']['apps']['admin']) || $exception) || !$this->config['contact_export_limit'] || (int)$this->config['contact_export_limit']) diff --git a/api/js/egw_action/egw_action.js b/api/js/egw_action/egw_action.js index b19ee9f53e..4950e6b1a5 100644 --- a/api/js/egw_action/egw_action.js +++ b/api/js/egw_action/egw_action.js @@ -2079,7 +2079,7 @@ egwActionObject.prototype._getLinks = function(_objs, _actionType) ( (actionLinks[k].actionObj.allowOnMultiple === true) || (actionLinks[k].actionObj.allowOnMultiple == "only" && _objs.length > 1) || - (actionLinks[k].actionObj.allowOnMultiple == false && _objs.length === 1) + (actionLinks[k].actionObj.allowOnMultiple == false && _objs.length == 1) ); if (!egwIsMobile()) actionLinks[k].actionObj.hideOnMobile = false; actionLinks[k].visible = actionLinks[k].visible && !actionLinks[k].actionObj.hideOnMobile && diff --git a/api/js/jsapi/app_base.js b/api/js/jsapi/app_base.js index ab25cfcb89..c3c415d497 100644 --- a/api/js/jsapi/app_base.js +++ b/api/js/jsapi/app_base.js @@ -819,7 +819,7 @@ var AppJS = (function(){ "use strict"; return Class.extend( jQuery(this).dialog("close"); } }; - + this.favorite_popup.dialog({ autoOpen: false, modal: true, @@ -1875,5 +1875,75 @@ var AppJS = (function(){ "use strict"; return Class.extend( reject(_err); }); }); - } + }, + + /** + * Check if the share action is enabled for this entry + * + * @param {egwAction} _action + * @param {egwActionObject[]} _entries + * @param {egwActionObject} _target + * @returns {boolean} if action is enabled + */ + is_share_enabled: function is_share_enabled(_action, _entries, _target) + { + return true; + }, + /** + * create a share-link for the given entry + * + * @param {egwAction} _action egw actions + * @param {egwActionObject[]} _senders selected nm row + * @param {egwActionObject} _target Drag source. Not used here. + * @param {Boolean} _files Allow access to files from the share. + * @returns {Boolean} returns false if not successful + */ + share_link: function(_action, _senders, _target, _files){ + var path = _senders[0].id; + if(typeof _files === 'undefined' && _action.parent && _action.parent.getActionById('shareFiles')) + { + _files = _action.parent.getActionById('shareFiles').checked || false; + } + egw.json('EGroupware\\Api\\Sharing::ajax_create', [_action.id, path, _files], + this._share_link_callback, this, true, this).sendRequest(); + return true; + }, + + /** + * Share-link callback + * @param {object} _data + */ + _share_link_callback: function(_data) { + if (_data.msg || _data.share_link) window.egw_refresh(_data.msg, this.appname); + console.log("_data", _data); + + var copy_link_to_clipboard = null; + + var copy_link_to_clipboard = function(evt){ + var $target = jQuery(evt.target); + $target.select(); + try { + var successful = document.execCommand('copy'); + if (successful) + { + egw.message('Share link copied into clipboard'); + return true; + } + } + catch (e) {} + egw.message('Failed to copy the link!'); + }; + jQuery("body").on("click", "[name=share_link]", copy_link_to_clipboard); + et2_createWidget("dialog", { + callback: function( button_id, value) { + jQuery("body").off("click", "[name=share_link]", copy_link_to_clipboard); + return true; + }, + title: _data.title ? _data.title : egw.lang("%1 Share Link", _data.action ==='shareWritableLink'? egw.lang("Writable"): egw.lang("Readonly")), + template: _data.template, + width: 450, + value: {content:{ "share_link": _data.share_link }} + }); + }, + });}).call(this); diff --git a/api/src/Contacts.php b/api/src/Contacts.php index 6523438b94..e606d36fc5 100755 --- a/api/src/Contacts.php +++ b/api/src/Contacts.php @@ -1204,6 +1204,10 @@ class Contacts extends Contacts\Storage { $access = !!array_intersect($memberships,$GLOBALS['egw']->accounts->memberships($contact['account_id'],true)); } + else if ($contact['id'] && $GLOBALS['egw']->acl->check('A'.$contact['id'], $needed, 'addressbook')) + { + $access = true; + } else { $access = ($grants[$owner] & $needed) && diff --git a/api/src/Etemplate.php b/api/src/Etemplate.php index 323a730d2c..768198b309 100644 --- a/api/src/Etemplate.php +++ b/api/src/Etemplate.php @@ -113,7 +113,7 @@ class Etemplate extends Etemplate\Widget\Template { if (!$extras) continue; - foreach(isset($extras[0]) ? $extras : array($extras) as $extra) + foreach(isset($extras[0]) || count($extras) ? $extras : array($extras) as $extra) { if ($extra['data'] && is_array($extra['data'])) { diff --git a/api/src/Link/Sharing.php b/api/src/Link/Sharing.php new file mode 100644 index 0000000000..2db7fe7f8f --- /dev/null +++ b/api/src/Link/Sharing.php @@ -0,0 +1,133 @@ + create new anon session with just specified application rights + * b) there is a session $keep_session === true + * b1) current user is share owner (eg. checking the link) + * --> Show entry, preferrably not destroying current session + * b2) current user not share owner + * --> Need a limited UI to show entry + * + * @param boolean $keep_session =null null: create a new session, true: try mounting it into existing (already verified) session + * @return string with sessionid + */ + public static function create_session($keep_session=null) + { + $share = array(); + $success = static::check_token($keep_session, $share); + if($success) + { + static::setup_entry($share); + return static::login($share); + } + return ''; + } + + protected static function setup_entry(&$share) + { + + } + + /** + * The anonymous user probably doesn't have the needed permissions to access + * the record, so we should set that up to avoid permission errors + */ + protected function after_login() + { + list($app, $id) = explode('::', $this->share['share_path']); + + // allow app (gets overwritten by session::create) + $GLOBALS['egw_info']['flags']['currentapp'] = $app; + $GLOBALS['egw_info']['user']['apps'] = array( + $app => $GLOBALS['egw_info']['apps'][$app] + ); + } + + /** + * Get actions for sharing an entry from the given app + * + * @param string $appname + * @param int $group Current menu group + */ + public static function get_actions($appname, $group = 6) + { + $actions = array( + 'share' => array( + 'caption' => lang('Share'), + 'icon' => 'api/share', + 'group' => $group, + 'allowOnMultiple' => false, + 'children' => array( + 'shareReadonlyLink' => array( + 'caption' => lang('Readonly Share'), + 'group' => 1, + 'icon' => 'view', + 'order' => 11, + 'enabled' => "javaScript:app.$appname.is_share_enabled", + 'onExecute' => "javaScript:app.$appname.share_link" + ), + 'shareWritableLink' => array( + 'caption' => lang('Writable Share'), + 'group' => 1, + 'icon' => 'edit', + 'allowOnMultiple' => false, + 'order' => 11, + 'enabled' => "javaScript:app.$appname.is_share_enabled", + 'onExecute' => "javaScript:app.$appname.share_link" + ), + 'shareFiles' => array( + 'caption' => lang('Share files'), + 'group' => 2, + 'enabled' => "javaScript:app.$appname.is_share_enabled", + 'checkbox' => true + ) + ), + )); + if(!$GLOBALS['egw_info']['apps']['stylite']) + { + array_unshift($actions['share']['children'], array( + 'caption' => lang('EPL Only'), + 'group' => 0 + )); + foreach($actions['share']['children'] as &$child) + { + $child['enabled'] = false; + } + } + return $actions; + } + + /** + * Get a user interface for shared directories + */ + public function get_ui() + { + echo lang('EPL Only'); + } + +} diff --git a/api/src/Sharing.php b/api/src/Sharing.php new file mode 100644 index 0000000000..354f04c5fd --- /dev/null +++ b/api/src/Sharing.php @@ -0,0 +1,733 @@ + + * @copyright (c) 2014-16 by Ralf Becker + * @package api + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Api; + +/** + * VFS sharing + * + * Token generation uses openssl_random_pseudo_bytes, if available, otherwise + * mt_rand based Api\Auth::randomstring is used. + * + * Existing user sessions are kept whenever possible by an additional mount into regular VFS: + * - share owner is current user (no problems with rights, they simply match) + * - share owner has owner-right for share: we create a temp. eACL for current user + * --> in all other cases session will be replaced with one of the anonymous user, + * as we dont support mounting with rights of share owner (VFS uses Vfs::$user!) + * + * @todo handle mounts of an entry directory /apps/$app/$id + * @todo handle mounts inside shared directory (they get currently lost) + * @todo handle absolute symlinks (wont work as we use share as root) + */ +class Sharing +{ + /** + * Length of base64 encoded token (real length is only 3/4 of it) + * + * Dropbox uses just 15 chars (letters/numbers 5-6 bit), php sessions use 32 chars (hex = 4bits), + * so 32 chars of base64 = 6bits should be plenty. + */ + const TOKEN_LENGTH = 32; + + /** + * Name of table used for storing tokens + */ + const TABLE = 'egw_sharing'; + + /** + * Reference to global db object + * + * @var Api\Db + */ + protected static $db; + + /** + * Share we are instanciated for + * + * @var array + */ + protected $share; + + const READONLY = 'share_ro'; + const WRITABLE = 'share_rw'; + + /** + * Modes for sharing files + * + * @var array + */ + static $modes = array( + self::READONLY => array( + 'label' => 'Readonly share', + 'title' => 'Link is generated allowing recipients to view entries', + ), + self::WRITABLE => array( + 'label' => 'Writable share', + 'title' => 'Link is generated allowing recipients to view and modify entries' + ), + ); + + /** + * Protected constructor called via self::create_session + * + * @param string $token + * @param array $share + */ + protected function __construct(array $share) + { + static::$db = $GLOBALS['egw']->db; + $this->share = $share; + } + + /** + * Get token from url + */ + public static function get_token() + { + // WebDAV has no concept of a query string and clients (including cadaver) + // seem to pass '?' unencoded, so we need to extract the path info out + // of the request URI ourselves + // if request URI contains a full url, remove schema and domain + $matches = null; + if (preg_match('|^https?://[^/]+(/.*)$|', $path_info=$_SERVER['REQUEST_URI'], $matches)) + { + $path_info = $matches[1]; + } + $path_info = substr($path_info, strlen($_SERVER['SCRIPT_NAME'])); + list(, $token/*, $path*/) = preg_split('|[/?]|', $path_info, 3); + + return $token; + } + + /** + * Get root of share + * + * @return string + */ + public function get_root() + { + return $this->share['share_root']; + } + + /** + * Create sharing session + * + * Certain cases: + * a) there is not session $keep_session === null + * --> create new anon session with just filemanager rights and share as fstab + * b) there is a session $keep_session === true + * b1) current user is share owner (eg. checking the link) + * --> mount share under token additionally + * b2) current user not share owner + * b2a) need/use filemanager UI (eg. directory) + * --> destroy current session and continue with a) + * b2b) single file or WebDAV + * --> modify EGroupware enviroment for that request only, no change in session + * + * @param boolean $keep_session =null null: create a new session, true: try mounting it into existing (already verified) session + * @return string with sessionid + */ + public static function create_session($keep_session=null) + { + $share = array(); + static::check_token($keep_session, $share); + if($share) + { + $classname = static::get_share_class($share); + $classname::setup_share($keep_session, $share); + return $classname::login($keep_session, $share); + } + return ''; + } + + protected static function check_token($keep_session, &$share) + { + self::$db = $GLOBALS['egw']->db; + + $token = static::get_token(); + + // are we called from header include, because session did not verify + // --> check if it verifys for our token + if ($token && !$keep_session) + { + $_SERVER['PHP_AUTH_USER'] = $token; + if (!isset($_SERVER['PHP_AUTH_PW'])) $_SERVER['PHP_AUTH_PW'] = ''; + + unset($GLOBALS['egw_info']['flags']['autocreate_session_callback']); + if (isset($GLOBALS['egw']->session) && $GLOBALS['egw']->session->verify() + && isset($GLOBALS['egw']->sharing) && $GLOBALS['egw']->sharing->share['share_token'] === $token) + { + return $GLOBALS['egw']->session->sessionid; + } + } + + if (empty($token) || !($share = self::$db->select(self::TABLE, '*', array( + 'share_token' => $token, + '(share_expires IS NULL OR share_expires > '.self::$db->quote(time(), 'date').')', + ), __LINE__, __FILE__)->fetch()) || + !$GLOBALS['egw']->accounts->exists($share['share_owner'])) + { + sleep(1); + + return static::share_fail( + '404 Not Found', + "Requested resource '/".htmlspecialchars($token)."' does NOT exist!\n" + ); + } + + // check password, if required + if ($share['share_passwd'] && (empty($_SERVER['PHP_AUTH_PW']) || + !(Api\Auth::compare_password($_SERVER['PHP_AUTH_PW'], $share['share_passwd'], 'crypt') || + Api\Header\Authenticate::decode_password($_SERVER['PHP_AUTH_PW']) && + Api\Auth::compare_password($_SERVER['PHP_AUTH_PW'], $share['share_passwd'], 'crypt')))) + { + $realm = 'EGroupware share '.$share['share_token']; + header('WWW-Authenticate: Basic realm="'.$realm.'"'); + return static::share_fail( + '401 Unauthorized', + "\n\n401 Unauthorized\n\nAuthorization failed.\n\n\n" + ); + } + + } + + /** + * Sub-class specific things needed to be done to the share before we try + * to login + * + * @param boolean $keep_session + * @param Array $share + */ + protected static function setup_share($keep_session, &$share) {} + /** + * Sub-class specific things needed to be done to the share (or session) + * after we login but before we start actually doing anything + */ + protected function after_login() {} + + + protected static function login($keep_session, &$share) + { + // update accessed timestamp + self::$db->update(self::TABLE, array( + 'share_last_accessed' => $share['share_last_accessed']=time(), + ), array( + 'share_id' => $share['share_id'], + ), __LINE__, __FILE__); + + // store sharing object in egw object and therefore in session + $class = self::get_share_class($share); + $GLOBALS['egw']->sharing = new $class($share); + + // we have a session we want to keep, but share owner is different from current user and we need filemanager UI, or no session + // --> create a new anon session + if ($keep_session === false && $GLOBALS['egw']->sharing->need_session() || is_null($keep_session)) + { + // create session without checking auth: create(..., false, false) + if (!($sessionid = $GLOBALS['egw']->session->create('anonymous@'.$GLOBALS['egw_info']['user']['domain'], + '', 'text', false, false))) + { + sleep(1); + return static::share_fail( + '500 Internal Server Error', + "Failed to create session: ".$GLOBALS['egw']->session->reason."\n" + ); + } + $GLOBALS['egw']->sharing->after_login(); + } + // we have a session we want to keep, but share owner is different from current user and we dont need filemanager UI + // --> we dont need session and close it, to not modifiy it + elseif ($keep_session === false) + { + $GLOBALS['egw']->session->commit_session(); + } + // need to store new fstab and vfs_user in session to allow GET requests / downloads via WebDAV + $GLOBALS['egw_info']['user']['vfs_user'] = Vfs::$user; + $GLOBALS['egw_info']['server']['vfs_fstab'] = Vfs::mount(); + + // update modified egw and egw_info again in session, if neccessary + if ($keep_session || $sessionid) + { + $_SESSION[Session::EGW_INFO_CACHE] = $GLOBALS['egw_info']; + unset($_SESSION[Session::EGW_INFO_CACHE]['flags']); // dont save the flags, they change on each request + + $_SESSION[Session::EGW_OBJECT_CACHE] = serialize($GLOBALS['egw']); + } + + return $sessionid; + } + + /** + * Get the namespaced class for the given share + * + * @param string $share + */ + protected static function get_share_class($share) + { + try + { + if(self::is_entry($share) && class_exists('\EGroupware\Stylite\Link\Sharing')) + { + return '\\EGroupware\\Stylite\\Link\\Sharing'; + } + } + catch(Exception $e){throw $e;} + return '\\'.__NAMESPACE__ . '\\'. (self::is_entry($share) ? 'Link' : 'Vfs'). '\\Sharing'; + } + + /** + * Something failed, stop everything + * + * @param String $status + * @param String $message + */ + public static function share_fail($status, $message) + { + header("HTTP/1.1 $status"); + header("X-WebDAV-Status: $status", true); + echo $message; + + $class = strpos($status, '404') === 0 ? 'EGroupware\Api\Exception\NotFound' : + strpos($status, '401') === 0 ? 'EGroupware\Api\Exception\NoPermission' : + 'EGroupware\Api\Exception'; + throw new $class($message); + } + + /** + * Check if we use filemanager UI + * + * Only for directories, if browser supports it and filemanager is installed + * + * @return boolean + */ + public function use_filemanager() + { + return !(!Vfs::is_dir($this->share['share_root']) || $_SERVER['REQUEST_METHOD'] != 'GET' || + // or unsupported browsers like ie < 10 + Header\UserAgent::type() == 'msie' && Header\UserAgent::version() < 10.0 || + // or if no filemanager installed (WebDAV has own autoindex) + !file_exists(__DIR__.'/../../filemanager/inc/class.filemanager_ui.inc.php')); + } + /** + * Check if we should use Collabora UI + * + * Only for files, if URL says so, and Collabora & Stylite apps are installed + */ + public function use_collabora() + { + return !Vfs::is_dir($this->share['share_root']) && + array_key_exists('edit', $_REQUEST) && + array_key_exists('collabora', $GLOBALS['egw_info']['apps']) && + array_key_exists('stylite', $GLOBALS['egw_info']['apps']); + + } + + public function is_entry($share = false) + { + if(!$share) $share = $this->share; + list($app, $id) = explode('::', $share['share_path']); + return $share && $share['share_path'] && + $app && $id && $app !== 'vfs' ;//&& array_key_exists($app, $GLOBALS['egw_info']['apps']); + } + + public function need_session() + { + return $this->use_filemanager() || $this->is_entry(); + } + + /** + * Get actions for sharing an entry from the given app + * + * @param string $appname + * @param int $group Current menu group + */ + public static function get_actions($appname, $group = 6) + { + $actions = array( + 'share' => array( + 'caption' => lang('Share'), + 'icon' => 'api/share', + 'group' => $group, + 'allowOnMultiple' => false, + 'children' => array( + 'shareReadonlyLink' => array( + 'caption' => lang('Readonly Share'), + 'group' => 1, + 'icon' => 'view', + 'order' => 11, + 'enabled' => "javaScript:app.$appname.is_share_enabled", + 'onExecute' => "javaScript:app.$appname.share_link" + ), + 'shareWritableLink' => array( + 'caption' => lang('Writable Share'), + 'group' => 1, + 'icon' => 'edit', + 'allowOnMultiple' => false, + 'order' => 11, + 'enabled' => "javaScript:app.$appname.is_share_enabled", + 'onExecute' => "javaScript:app.$appname.share_link" + ), + 'shareFiles' => array( + 'caption' => lang('Share files'), + 'group' => 2, + 'enabled' => "javaScript:app.$appname.is_share_enabled", + 'checkbox' => true + ) + ), + )); + if(!$GLOBALS['egw_info']['apps']['stylite']) + { + array_unshift($actions['share']['children'], array( + 'caption' => lang('EPL Only'), + 'group' => 0 + )); + foreach($actions['share']['children'] as &$child) + { + $child['enabled'] = false; + } + } + return $actions; + } + + /** + * Server a request on a share specified in REQUEST_URI + */ + public function ServeRequest() + { + // sharing is for a different share, change to current share + if ($this->share['share_token'] !== self::get_token()) + { + self::create_session($GLOBALS['egw']->session->session_flags === 'N'); + + return $GLOBALS['egw']->sharing->ServeRequest(); + } + + // No extended ACL for readonly shares, disable eacl by setting session cache + if(!($this->share['share_writable'] & 1)) + { + Cache::setSession(Vfs\Sqlfs\StreamWrapper::EACL_APPNAME, 'extended_acl', array( + '/' => 1, + $this->share['share_path'] => 1 + )); + } + if($this->use_collabora()) + { + $ui = new \EGroupware\Collabora\Ui(); + return $ui->editor($this->share['share_path']); + } + // use pure WebDAV for everything but GET requests to directories + else if (!$this->use_filemanager() && !$this->is_entry()) + { + // send a content-disposition header, so browser knows how to name downloaded file + if (!Vfs::is_dir($this->share['share_root'])) + { + Header\Content::disposition(Vfs::basename($this->share['share_path']), false); + } + //$GLOBALS['egw']->session->commit_session(); + $webdav_server = new Vfs\WebDAV(); + $webdav_server->ServeRequest(Vfs::concat($this->share['share_root'], $this->share['share_token'])); + return; + } + return $this->get_ui(); + } + + /** + * Generate a new token + * + * @return string + */ + public static function token() + { + // generate random token (using oppenssl if available otherwise mt_rand based Api\Auth::randomstring) + do { + $token = function_exists('openssl_random_pseudo_bytes') ? + base64_encode(openssl_random_pseudo_bytes(3*self::TOKEN_LENGTH/4)) : + Auth::randomstring(self::TOKEN_LENGTH); + // base64 can contain chars not allowed in our vfs-urls eg. / or # + } while ($token != urlencode($token)); + + return $token; + } + + /** + * Name of the async job for cleaning up shares + */ + const ASYNC_JOB_ID = 'egw_sharing-tmp_cleanup'; + + /** + * Create a new share + * + * @param string $path either path in temp_dir or vfs with optional vfs scheme + * @param string $mode self::LINK: copy file in users tmp-dir or self::READABLE share given vfs file, + * if no vfs behave as self::LINK + * @param string $name filename to use for $mode==self::LINK, default basename of $path + * @param string|array $recipients one or more recipient email addresses + * @param array $extra =array() extra data to store + * @throw Api\Exception\NotFound if $path not found + * @throw Api\Exception\AssertionFailed if user temp. directory does not exist and can not be created + * @return array with share data, eg. value for key 'share_token' + */ + public static function create($path, $mode, $name, $recipients, $extra=array()) + { + if (!isset(static::$db)) static::$db = $GLOBALS['egw']->db; + + if (empty($name)) $name = $path; + + // check if file has been shared before, with identical attributes + if (($share = static::$db->select(static::TABLE, '*', $extra+array( + 'share_path' => $path, + 'share_owner' => Vfs::$user, + 'share_expires' => null, + 'share_passwd' => null, + 'share_writable'=> false, + ), __LINE__, __FILE__)->fetch())) + { + // if yes, just add additional recipients + $share['share_with'] = $share['share_with'] ? explode(',', $share['share_with']) : array(); + $need_save = false; + foreach((array)$recipients as $recipient) + { + if (!in_array($recipient, $share['share_with'])) + { + $share['share_with'][] = $recipient; + $need_save = true; + } + } + $share['share_with'] = implode(',', $share['share_with']); + if ($need_save) + { + static::$db->update(static::TABLE, array( + 'share_with' => $share['share_with'], + ), array( + 'share_id' => $share['share_id'], + ), __LINE__, __FILE__); + } + } + else + { + $i = 0; + while(true) // self::token() can return an existing value + { + try { + static::$db->insert(static::TABLE, $share = array( + 'share_token' => self::token(), + 'share_path' => $path, + 'share_owner' => Vfs::$user, + 'share_with' => implode(',', (array)$recipients), + 'share_created' => time(), + )+$extra, false, __LINE__, __FILE__); + + $share['share_id'] = static::$db->get_last_insert_id(static::TABLE, 'share_id'); + break; + } + catch(Db\Exception $e) { + if ($i++ > 3) throw $e; + unset($e); + } + } + } + + // if not already installed, install periodic cleanup of shares + $async = new Asyncservice(); + if (!($job = $async->read(self::ASYNC_JOB_ID)) || $job[self::ASYNC_JOB_ID]['method'] === 'egw_sharing::tmp_cleanup') + { + if ($job) $async->delete(self::ASYNC_JOB_ID); // update not working old class-name + + $async->set_timer(array('day' => 28), self::ASYNC_JOB_ID, 'EGroupware\\Api\\Vfs\\Sharing::tmp_cleanup',null); + } + + return $share; + } + + /** + * Create a share via AJAX + * + * @param String $action + * @param String $path + * @param boolean $files + */ + public static function ajax_create($action, $path, $files) + { + $class = self::get_share_class(array('share_path' => $path)); + $share = $class::create( + $path, + $action == 'shareWritableLink' ? Sharing::WRITABLE : Sharing::READONLY, + basename($selected), + array(), + array( + 'share_writable' => $action == 'shareWritableLink', + 'include_files' => $files + ) + ); + + $arr = array( + 'action' => $action, + 'share_link' => $class::share2link($share), + 'template' => Etemplate\Widget\Template::rel2url('/filemanager/templates/default/share_dialog.xet') + ); + $response = Json\Response::get(); + $response->data($arr); + } + + /** + * Api\Storage\Base instance for egw_sharing table + * + * @var Api\Storage\Base + */ + protected static $so; + + /** + * Get a so_sql instance initialised for shares + */ + public static function so() + { + if (!isset(self::$so)) + { + self::$so = new Api\Storage\Base('phpgwapi', self::TABLE, null, '', true); + self::$so->set_times('string'); + } + return self::$so; + } + + /** + * Delete specified shares and unlink temp. files + * + * @param int|array $keys + * @return int number of deleted shares + */ + public static function delete($keys) + { + self::$db = $GLOBALS['egw']->db; + + if (is_scalar($keys)) $keys = array('share_id' => $keys); + + // get all temp. files, to be able to delete them + $tmp_paths = array(); + foreach(self::$db->select(self::TABLE, 'share_path', array( + "share_path LIKE '/home/%/.tmp/%'")+$keys, __LINE__, __FILE__, false) as $row) + { + $tmp_paths[] = $row['share_path']; + } + + // delete specified shares + self::$db->delete(self::TABLE, $keys, __LINE__, __FILE__); + $deleted = self::$db->affected_rows(); + + // check if temp. files are used elsewhere + if ($tmp_paths) + { + foreach(self::$db->select(self::TABLE, 'share_path,COUNT(*) AS cnt', array( + 'share_path' => $tmp_paths, + ), __LINE__, __FILE__, false, 'GROUP BY share_path') as $row) + { + if (($key = array_search($row['share_path'], $tmp_paths))) + { + unset($tmp_paths[$key]); + } + } + // if not delete them + foreach($tmp_paths as $path) + { + Vfs::remove($path); + } + } + return $deleted; + } + + /** + * Home long to keep temp. files: 100 day + */ + const TMP_KEEP = 8640000; + /** + * How long to keep automatic created Wopi shares + */ + const WOPI_KEEP = '-3month'; + + /**. + * Periodic (monthly) cleanup of temporary sharing files (download link) + * + * Exlicit expireds shares are delete, as ones created over 100 days ago and last accessed over 100 days ago. + */ + public static function tmp_cleanup() + { + if (!isset(self::$db)) self::$db = $GLOBALS['egw']->db; + Vfs::$is_root = true; + + try { + $cols = array( + 'share_path', + 'MAX(share_expires) AS share_expires', + 'MAX(share_created) AS share_created', + 'MAX(share_last_accessed) AS share_last_accessed', + ); + if (($group_concat = self::$db->group_concat('share_id'))) $cols[] = $group_concat.' AS share_id'; + // remove expired tmp-files unconditionally + $having = 'HAVING MAX(share_expires) < '.self::$db->quote(self::$db->to_timestamp(time())).' OR '. + // remove without expiration date, when created over 100 days ago AND + 'MAX(share_expires) IS NULL AND MAX(share_created) < '.self::$db->quote(self::$db->to_timestamp(time()-self::TMP_KEEP)). ' AND '. + // (last accessed over 100 days ago OR never) + '(MAX(share_last_accessed) IS NULL OR MAX(share_last_accessed) < '.self::$db->quote(self::$db->to_timestamp(time()-self::TMP_KEEP)).')'; + + foreach(self::$db->select(self::TABLE, $cols, array( + "share_path LIKE '/home/%/.tmp/%'", + ), __LINE__, __FILE__, false, 'GROUP BY share_path '.$having) as $row) + { + Vfs::remove($row['share_path']); + + if ($group_concat) + { + $share_ids = $row['share_id'] ? explode(',', $row['share_id']) : array(); + } + else + { + $share_ids = array(); + foreach(self::$db->selec(self::TABLE, 'share_id', array( + 'share_path' => $row['share_path'], + ), __LINE__, __FILE__) as $id) + { + $share_ids[] = $id['share_id']; + } + } + if ($share_ids) + { + self::$db->delete(self::TABLE, array('share_id' => $share_ids), __LINE__, __FILE__); + } + } + + // delete automatic created and expired Collabora shares older then 3 month + if (class_exists('EGroupware\\Collabora\\Wopi')) + { + self::$db->delete(self::TABLE, array( + 'share_expires < '.self::$db->quote(Api\DateTime::to(self::WOPI_KEEP, 'Y-m-d')), + 'share_writable IN ('.Wopi::WOPI_WRITABLE.','.Wopi::WOPI_READONLY.')', + ), __LINE__, __FILE__); + } + } + catch (\Exception $e) { + _egw_log_exception($e); + } + Vfs::$is_root = false; + } + + /** + * Generate link from share or share-token + * + * @param string|array $share share or share-token + * @return string full Url incl. schema and host + */ + public static function share2link($share) + { + if (is_array($share)) $share = $share['share_token']; + + return Framework::getUrl(Framework::link('/share.php')).'/'.$share; + } +} \ No newline at end of file diff --git a/api/src/Vfs/Sharing.php b/api/src/Vfs/Sharing.php index a83b879db3..166e1b525d 100644 --- a/api/src/Vfs/Sharing.php +++ b/api/src/Vfs/Sharing.php @@ -35,34 +35,8 @@ use filemanager_ui; * @todo handle mounts inside shared directory (they get currently lost) * @todo handle absolute symlinks (wont work as we use share as root) */ -class Sharing +class Sharing extends \EGroupware\Api\Sharing { - /** - * Length of base64 encoded token (real length is only 3/4 of it) - * - * Dropbox uses just 15 chars (letters/numbers 5-6 bit), php sessions use 32 chars (hex = 4bits), - * so 32 chars of base64 = 6bits should be plenty. - */ - const TOKEN_LENGTH = 32; - - /** - * Name of table used for storing tokens - */ - const TABLE = 'egw_sharing'; - - /** - * Reference to global db object - * - * @var Api\Db - */ - protected static $db; - - /** - * Share we are instanciated for - * - * @var array - */ - protected $share; /** * Modes ATTACH is NOT a sharing mode, but it is traditional mode in email @@ -96,48 +70,6 @@ class Sharing ), ); - /** - * Protected constructor called via self::create_session - * - * @param string $token - * @param array $share - */ - protected function __construct(array $share) - { - self::$db = $GLOBALS['egw']->db; - $this->share = $share; - } - - /** - * Get token from url - */ - public static function get_token() - { - // WebDAV has no concept of a query string and clients (including cadaver) - // seem to pass '?' unencoded, so we need to extract the path info out - // of the request URI ourselves - // if request URI contains a full url, remove schema and domain - $matches = null; - if (preg_match('|^https?://[^/]+(/.*)$|', $path_info=$_SERVER['REQUEST_URI'], $matches)) - { - $path_info = $matches[1]; - } - $path_info = substr($path_info, strlen($_SERVER['SCRIPT_NAME'])); - list(, $token/*, $path*/) = preg_split('|[/?]|', $path_info, 3); - - return $token; - } - - /** - * Get root of share - * - * @return string - */ - public function get_root() - { - return $this->share['share_root']; - } - /** * Create sharing session * @@ -156,54 +88,8 @@ class Sharing * @param boolean $keep_session =null null: create a new session, true: try mounting it into existing (already verified) session * @return string with sessionid, does NOT return if no session created */ - public static function create_session($keep_session=null) + public static function setup_share($keep_session, &$share) { - self::$db = $GLOBALS['egw']->db; - - $token = static::get_token(); - - // are we called from header include, because session did not verify - // --> check if it verifys for our token - if ($token && !$keep_session) - { - $_SERVER['PHP_AUTH_USER'] = $token; - if (!isset($_SERVER['PHP_AUTH_PW'])) $_SERVER['PHP_AUTH_PW'] = ''; - - unset($GLOBALS['egw_info']['flags']['autocreate_session_callback']); - if (isset($GLOBALS['egw']->session) && $GLOBALS['egw']->session->verify() - && isset($GLOBALS['egw']->sharing) && $GLOBALS['egw']->sharing->share['share_token'] === $token) - { - return $GLOBALS['egw']->session->sessionid; - } - } - - if (empty($token) || !($share = self::$db->select(self::TABLE, '*', array( - 'share_token' => $token, - '(share_expires IS NULL OR share_expires > '.self::$db->quote(time(), 'date').')', - ), __LINE__, __FILE__)->fetch()) || - !$GLOBALS['egw']->accounts->exists($share['share_owner'])) - { - sleep(1); - - return static::share_fail( - '404 Not Found', - "Requested resource '/".htmlspecialchars($token)."' does NOT exist!\n" - ); - } - - // check password, if required - if ($share['share_passwd'] && (empty($_SERVER['PHP_AUTH_PW']) || - !(Api\Auth::compare_password($_SERVER['PHP_AUTH_PW'], $share['share_passwd'], 'crypt') || - Api\Header\Authenticate::decode_password($_SERVER['PHP_AUTH_PW']) && - Api\Auth::compare_password($_SERVER['PHP_AUTH_PW'], $share['share_passwd'], 'crypt')))) - { - $realm = 'EGroupware share '.$share['share_token']; - header('WWW-Authenticate: Basic realm="'.$realm.'"'); - return static::share_fail( - '401 Unauthorized', - "\n\n401 Unauthorized\n\nAuthorization failed.\n\n\n" - ); - } // need to reset fs_tab, as resolve_url does NOT work with just share mounted if (count($GLOBALS['egw_info']['server']['vfs_fstab']) <= 1) @@ -263,150 +149,27 @@ class Sharing Vfs::clearstatcache(); // clear link-cache and load link registry without permission check to access /apps Api\Link::init_static(true); - - // update accessed timestamp - self::$db->update(self::TABLE, array( - 'share_last_accessed' => $share['share_last_accessed']=time(), - ), array( - 'share_id' => $share['share_id'], - ), __LINE__, __FILE__); - - // store sharing object in egw object and therefore in session - $GLOBALS['egw']->sharing = new Sharing($share); - - // we have a session we want to keep, but share owner is different from current user and we need filemanager UI, or no session - // --> create a new anon session - if ($keep_session === false && $GLOBALS['egw']->sharing->use_filemanager() || is_null($keep_session)) - { - // create session without checking auth: create(..., false, false) - if (!($sessionid = $GLOBALS['egw']->session->create('anonymous@'.$GLOBALS['egw_info']['user']['domain'], - '', 'text', false, false))) - { - sleep(1); - return static::share_fail( - '500 Internal Server Error', - "Failed to create session: ".$GLOBALS['egw']->session->reason."\n" - ); - } - // only allow filemanager app (gets overwritten by session::create) - $GLOBALS['egw_info']['user']['apps'] = array( - 'filemanager' => $GLOBALS['egw_info']['apps']['filemanager'] - ); - // check if sharee has Collabora run rights --> give is to share too - $apps = $GLOBALS['egw']->acl->get_user_applications($share['share_owner']); - if (!empty($apps['collabora'])) - { - $GLOBALS['egw_info']['user']['apps']['collabora'] = $GLOBALS['egw_info']['apps']['collabora']; - } - } - // we have a session we want to keep, but share owner is different from current user and we dont need filemanager UI - // --> we dont need session and close it, to not modifiy it - elseif ($keep_session === false) - { - $GLOBALS['egw']->session->commit_session(); - } - // need to store new fstab and vfs_user in session to allow GET requests / downloads via WebDAV - $GLOBALS['egw_info']['user']['vfs_user'] = Vfs::$user; - $GLOBALS['egw_info']['server']['vfs_fstab'] = Vfs::mount(); - - // update modified egw and egw_info again in session, if neccessary - if ($keep_session || $sessionid) - { - $_SESSION[Api\Session::EGW_INFO_CACHE] = $GLOBALS['egw_info']; - unset($_SESSION[Api\Session::EGW_INFO_CACHE]['flags']); // dont save the flags, they change on each request - - $_SESSION[Api\Session::EGW_OBJECT_CACHE] = serialize($GLOBALS['egw']); - } - - return $sessionid; } - /** - * Something failed, stop everything - * - * @param String $status - * @param String $message - */ - public static function share_fail($status, $message) + protected function after_login() { - header("HTTP/1.1 $status"); - header("X-WebDAV-Status: $status", true); - echo $message; - - $class = strpos($status, '404') === 0 ? 'EGroupware\Api\Exception\NotFound' : - strpos($status, '401') === 0 ? 'EGroupware\Api\Exception\NoPermission' : - 'EGroupware\Api\Exception'; - throw new $class($message); - } - - /** - * Check if we use filemanager UI - * - * Only for directories, if browser supports it and filemanager is installed - * - * @return boolean - */ - public function use_filemanager() - { - return !(!Vfs::is_dir($this->share['share_root']) || $_SERVER['REQUEST_METHOD'] != 'GET' || - // or unsupported browsers like ie < 10 - Api\Header\UserAgent::type() == 'msie' && Api\Header\UserAgent::version() < 10.0 || - // or if no filemanager installed (WebDAV has own autoindex) - !file_exists(__DIR__.'/../../../filemanager/inc/class.filemanager_ui.inc.php')); - } - /** - * Check if we should use Collabora UI - * - * Only for files, if URL says so, and Collabora & Stylite apps are installed - */ - public function use_collabora() - { - return !Vfs::is_dir($this->share['share_root']) && - array_key_exists('edit', $_REQUEST) && - array_key_exists('collabora', $GLOBALS['egw_info']['apps']) && - array_key_exists('stylite', $GLOBALS['egw_info']['apps']); - + // only allow filemanager app (gets overwritten by session::create) + $GLOBALS['egw_info']['user']['apps'] = array( + 'filemanager' => $GLOBALS['egw_info']['apps']['filemanager'] + ); + // check if sharee has Collabora run rights --> give is to share too + $apps = $GLOBALS['egw']->acl->get_user_applications($share['share_owner']); + if (!empty($apps['collabora'])) + { + $GLOBALS['egw_info']['user']['apps']['collabora'] = $GLOBALS['egw_info']['apps']['collabora']; + } } /** * Server a request on a share specified in REQUEST_URI */ - public function ServeRequest() + public function get_ui() { - // sharing is for a different share, change to current share - if ($this->share['share_token'] !== self::get_token()) - { - self::create_session($GLOBALS['egw']->session->session_flags === 'N'); - - return $GLOBALS['egw']->sharing->ServeRequest(); - } - - // No extended ACL for readonly shares, disable eacl by setting session cache - if(!($this->share['share_writable'] & 1)) - { - Api\Cache::setSession(Api\Vfs\Sqlfs\StreamWrapper::EACL_APPNAME, 'extended_acl', array( - '/' => 1, - $this->share['share_path'] => 1 - )); - } - if($this->use_collabora()) - { - $ui = new \EGroupware\Collabora\Ui(); - return $ui->editor($this->share['share_path']); - } - // use pure WebDAV for everything but GET requests to directories - else if (!$this->use_filemanager()) - { - // send a content-disposition header, so browser knows how to name downloaded file - if (!Vfs::is_dir($this->share['share_root'])) - { - Api\Header\Content::disposition(Vfs::basename($this->share['share_path']), false); - } - //$GLOBALS['egw']->session->commit_session(); - $webdav_server = new Vfs\WebDAV(); - $webdav_server->ServeRequest(Vfs::concat($this->share['share_root'], $this->share['share_token'])); - return; - } // run full eTemplate2 UI for directories $_GET['path'] = $this->share['share_root']; $GLOBALS['egw_info']['user']['preferences']['filemanager']['nm_view'] = 'tile'; @@ -417,29 +180,6 @@ class Sharing $ui->index(); } - /** - * Generate a new token - * - * @return string - */ - public static function token() - { - // generate random token (using oppenssl if available otherwise mt_rand based Api\Auth::randomstring) - do { - $token = function_exists('openssl_random_pseudo_bytes') ? - base64_encode(openssl_random_pseudo_bytes(3*self::TOKEN_LENGTH/4)) : - Api\Auth::randomstring(self::TOKEN_LENGTH); - // base64 can contain chars not allowed in our vfs-urls eg. / or # - } while ($token != urlencode($token)); - - return $token; - } - - /** - * Name of the async job for cleaning up shares - */ - const ASYNC_JOB_ID = 'egw_sharing-tmp_cleanup'; - /** * Create a new share * @@ -492,35 +232,9 @@ class Sharing throw new Api\Exception\NotFound("'$path' NOT found!"); } // check if file has been shared before, with identical attributes - if (($mode != self::LINK || isset($path2tmp[$path])) && - ($share = self::$db->select(self::TABLE, '*', $extra+array( - 'share_path' => $mode == 'link' ? $path2tmp[$path] : $vfs_path, - 'share_owner' => Vfs::$user, - 'share_expires' => null, - 'share_passwd' => null, - 'share_writable'=> false, - ), __LINE__, __FILE__)->fetch())) + if (($mode != self::LINK )) { - // if yes, just add additional recipients - $share['share_with'] = $share['share_with'] ? explode(',', $share['share_with']) : array(); - $need_save = false; - foreach((array)$recipients as $recipient) - { - if (!in_array($recipient, $share['share_with'])) - { - $share['share_with'][] = $recipient; - $need_save = true; - } - } - $share['share_with'] = implode(',', $share['share_with']); - if ($need_save) - { - self::$db->update(self::TABLE, array( - 'share_with' => $share['share_with'], - ), array( - 'share_id' => $share['share_id'], - ), __LINE__, __FILE__); - } + return parent::create($vfs_path ? $vfs_path : $path, $mode, $name, $recipients, $extra); } else { @@ -558,38 +272,8 @@ class Sharing $vfs_path = $tmp_file; } - $i = 0; - while(true) // self::token() can return an existing value - { - try { - self::$db->insert(self::TABLE, $share = array( - 'share_token' => self::token(), - 'share_path' => $vfs_path, - 'share_owner' => Vfs::$user, - 'share_with' => implode(',', (array)$recipients), - 'share_created' => time(), - )+$extra, false, __LINE__, __FILE__); - - $share['share_id'] = self::$db->get_last_insert_id(self::TABLE, 'share_id'); - break; - } - catch(Api\Db\Exception $e) { - if ($i++ > 3) throw $e; - unset($e); - } - } + return parent::create($vfs_path, $mode, $name, $recipients, $extra); } - - // if not already installed, install periodic cleanup of shares - $async = new Api\Asyncservice(); - if (!($job = $async->read(self::ASYNC_JOB_ID)) || $job[self::ASYNC_JOB_ID]['method'] === 'egw_sharing::tmp_cleanup') - { - if ($job) $async->delete(self::ASYNC_JOB_ID); // update not working old class-name - - $async->set_timer(array('day' => 28), self::ASYNC_JOB_ID, 'EGroupware\\Api\\Vfs\\Sharing::tmp_cleanup',null); - } - - return $share; } /** diff --git a/api/templates/default/images/share.png b/api/templates/default/images/share.png new file mode 100644 index 0000000000..92337a0501 Binary files /dev/null and b/api/templates/default/images/share.png differ diff --git a/api/templates/default/images/share.svg b/api/templates/default/images/share.svg new file mode 100644 index 0000000000..180376e848 --- /dev/null +++ b/api/templates/default/images/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pixelegg/images/share.png b/pixelegg/images/share.png new file mode 100644 index 0000000000..28e8dc98ef Binary files /dev/null and b/pixelegg/images/share.png differ diff --git a/share.php b/share.php index 6368378b19..40c9b65364 100644 --- a/share.php +++ b/share.php @@ -10,24 +10,24 @@ * @version $Id$ */ -require_once(__DIR__.'/api/src/Vfs/Sharing.php'); +require_once(__DIR__.'/api/src/Sharing.php'); -use EGroupware\Api\Vfs\Sharing; +use EGroupware\Api\Sharing; $GLOBALS['egw_info'] = array( 'flags' => array( 'disable_Template_class' => true, 'noheader' => true, 'nonavbar' => 'always', // true would cause eTemplate to reset it to false for non-popups! - 'currentapp' => 'filemanager', - 'autocreate_session_callback' => 'EGroupware\\Api\\Vfs\\Sharing::create_session', + 'currentapp' => 'api', + 'autocreate_session_callback' => 'EGroupware\\Api\\Sharing::create_session', 'no_exception_handler' => 'basic_auth', // we use a basic auth exception handler (sends exception message as basic auth realm) ) ); include('./header.inc.php'); -if (!$GLOBALS['egw']->sharing) +if (!isset($GLOBALS['egw']->sharing)) { Sharing::create_session(true); // true = mount into existing session }