1<?php 2/** 3 * EGroupware - Collabora Wopi protocol 4 * 5 * @link http://www.egroupware.org 6 * @author Nathan Gray 7 * @package collabora 8 * @copyright (c) 2017 Nathan Gray 9 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License 10 */ 11 12namespace EGroupware\Collabora; 13 14require_once(__DIR__.'/../../api/src/Vfs/Sharing.php'); 15 16use EGroupware\Api; 17use EGroupware\Api\Vfs; 18use EGroupware\Api\Vfs\Sharing; 19use EGroupware\Api\Vfs\Sqlfs\StreamWrapper as Sql_Stream; 20 21 22/** 23 * Description of Wopi 24 * 25 */ 26class Wopi extends Sharing 27{ 28 // Debug flag 29 const DEBUG = false; 30 31 /** 32 * Lifetime of WOPI shares: 1 day 33 */ 34 const TOKEN_TTL = 86400; 35 /** 36 * writable (normal) WOPI share, to be able to supress it from list of shares 37 */ 38 const WOPI_WRITABLE = 3; 39 /** 40 * readonly WOPI share, to be able to supress it from list of shares 41 */ 42 const WOPI_READONLY = 4; 43 /** 44 * Writable WOPI share, used for sharing a single file with others but 45 * restricts file system access - no save as 46 */ 47 const WOPI_SHARED = 5; 48 49 public $public_functions = array( 50 'index' => TRUE 51 ); 52 53 // Access credentials if we need to get to a password 54 static $credentials = null; 55 56 /** 57 * Entry point for the WOPI API 58 * 59 * Here we check the required parameters, and pass off the the appropriate 60 * endpoint handler. 61 * 62 * @see https://wopirest.readthedocs.io/en/latest/index.html 63 */ 64 public static function index() 65 { 66 // Check access token, start session 67 static::create_session(true); 68 69 // Determine the endpoint, get the ID 70 $matches = array(); 71 preg_match('#/wopi/([[:alpha:]]+)/(-?[[:digit:]]+)?/?(contents)?#', $_SERVER['REQUEST_URI'], $matches); 72 list(, $endpoint, $id) = $matches; 73 74 // need to create a new session, if the file_id changes, eg. after a PUT_RELATIVE 75 if (($last_id = Api\Cache::getSession(__CLASS__, 'file_id')) && $last_id != $id) 76 { 77 static::create_session(null); 78 } 79 80 $endpoint_class = __NAMESPACE__ . '\Wopi\\'. filter_var( 81 ucfirst($endpoint), 82 FILTER_SANITIZE_SPECIAL_CHARS, 83 FILTER_FLAG_STRIP_LOW + FILTER_FLAG_STRIP_HIGH 84 ); 85 $data = array(); 86 if($endpoint_class && class_exists($endpoint_class)) 87 { 88 $instance = new $endpoint_class(); 89 $data = $instance->process($id); 90 Api\Cache::setSession(__CLASS__, 'file_id', $id); 91 } 92 else 93 { 94 // Unknown endpoint - not found 95 http_response_code(404); 96 exit; 97 } 98 99 if(!headers_sent() && $data) 100 { 101 $response = json_encode($data); 102 header('X-WOPI-ServerVersion: ' . $GLOBALS['egw_info']['apps']['collabora']['version']); 103 header('X-WOPI-MachineName: ' . 'Egroupware'); 104 header('Content-Length:'.strlen($response)); 105 header('Content-Type: application/json;charset=utf-8'); 106 echo $response; 107 } 108 exit; 109 } 110 111 /** 112 * Create a new share for Collabora to use while editing 113 * 114 * @param string $path either path in temp_dir or vfs with optional vfs scheme 115 * @param string $mode self::LINK: copy file in users tmp-dir or self::READABLE share given vfs file, 116 * if no vfs behave as self::LINK 117 * @param string $name filename to use for $mode==self::LINK, default basename of $path 118 * @param string|array $recipients one or more recipient email addresses 119 * @param array $extra =array() extra data to store 120 * @return array with share data, eg. value for key 'share_token' 121 * @throw Api\Exception\NotFound if $path not found 122 * @throw Api\Exception\AssertionFailed if user temp. directory does not exist and can not be created 123 */ 124 public static function create($path, $mode, $name, $recipients, $extra = array()) 125 { 126 // Hidden uploads are readonly, enforce it here too 127 if($extra['share_writable'] == Wopi::WOPI_WRITABLE && isset($GLOBALS['egw']->sharing) && $GLOBALS['egw']->sharing->share['share_writable'] == static::HIDDEN_UPLOAD) 128 { 129 $extra['share_writable'] = static::WOPI_READONLY; 130 } 131 $result = parent::create('', $path, $mode, $name, $recipients, $extra); 132 133 134 // If path needs password, get credentials and add on the ID so we can 135 // actually open the path with the anon user 136 if(static::path_needs_password($path)) 137 { 138 $cred_id = Credentials::read($result); 139 if(!$cred_id) 140 { 141 $cred_id = Credentials::write($result); 142 } 143 144 $result['share_token'] .= ':'.$cred_id; 145 } 146 147 return $result; 148 } 149 150 /** 151 * Collabora server does not have the share password, and we don't want to 152 * pass it. Check to see if the share needs a password, and if it does 153 * we create a new share with no password and use it for the Collabora server. 154 * 155 * This is used for writable collabora shares (sent via URL), not normal 156 * logged in users. It's in Wopi instead of Bo for access to protected 157 * variables. 158 * 159 * @param Array $share 160 * @return Array share without password 161 */ 162 public static function get_no_password_share(Array $share) 163 { 164 if(!$share['passwd']) 165 { 166 return $share; 167 } 168 $pwd_share = $GLOBALS['egw']->sharing->share; 169 $fstab = $GLOBALS['egw_info']['server']['vfs_fstab']; 170 $writable = Api\Vfs::is_writable($path) && $share['writable'] & 1; 171 Bo::reset_vfs(); 172 $share = Wopi::create($share['path'], $writable ? Wopi::WRITABLE : Wopi::READONLY, '', '', array( 173 'share_passwd' => null, 174 'share_expires' => time() + Wopi::TOKEN_TTL, 175 'share_writable' => $writable ? Wopi::WOPI_WRITABLE : Wopi::WOPI_READONLY, 176 )); 177 $GLOBALS['egw_info']['server']['vfs_fstab'] = $fstab; 178 $GLOBALS['egw']->sharing->share = $pwd_share; 179 180 // Cleanup to match expected 181 foreach($share as $key => $value) 182 { 183 if(substr($key, 0, 6) == 'share_') 184 { 185 $key = str_replace('share_', '', $key); 186 } 187 $token[$key] = $value; 188 } 189 return $token; 190 } 191 192 /** 193 * Get token from url 194 */ 195 public static function get_token() 196 { 197 // Access token is encoded, as it may have + in it 198 $token = urldecode(filter_var($_GET['access_token'],FILTER_SANITIZE_SPECIAL_CHARS)); 199 200 // Strip out possible credentials ID if path needs password 201 list($token, self::$credentials) = explode(':', $token); 202 203 return $token; 204 } 205 206 /** 207 * If credentials are required to access the file, load & set what is needed 208 * 209 * @param boolean $keep_session 210 * @param Array $share 211 */ 212 public static function setup_share($keep_session, &$share) 213 { 214 // need to reset fs_tab, as resolve_url does NOT work with just share mounted 215 if (empty($GLOBALS['egw_info']['server']['vfs_fstab']) || count($GLOBALS['egw_info']['server']['vfs_fstab']) <= 1) 216 { 217 unset($GLOBALS['egw_info']['server']['vfs_fstab']); // triggers reset of fstab in mount() 218 $GLOBALS['egw_info']['server']['vfs_fstab'] = Vfs::mount(); 219 Vfs::clearstatcache(); 220 } 221 $share['resolve_url'] = Vfs::resolve_url($share['share_path'], true, true, true, true); // true = fix evtl. contained url parameter 222 // if share not writable append ro=1 to mount url to make it readonly 223 if (!($share['share_writable'] & 1)) 224 { 225 $share['resolve_url'] .= (strpos($share['resolve_url'], '?') ? '&' : '?').'ro=1'; 226 } 227 //_debug_array($share); 228 229 if ($keep_session) // add share to existing session 230 { 231 $share['share_root'] = '/'.$share['share_token']; 232 233 // if current user is not the share owner, we cant just mount share 234 if (Vfs::$user != $share['share_owner']) 235 { 236 $keep_session = false; 237 } 238 } 239 if (!$keep_session) // do NOT change to else, as we might have set $keep_session=false! 240 { 241 // only allow filemanager app & collabora 242 // (In some cases, $GLOBALS['egw_info']['apps'] is not yet set) 243 $apps = $GLOBALS['egw']->acl->get_user_applications($share['share_owner']); 244 $GLOBALS['egw_info']['user']['apps'] = array( 245 'filemanager' => $GLOBALS['egw_info']['apps']['filemanager'] || true, 246 'collabora' => $GLOBALS['egw_info']['apps']['collabora'] || $apps['collabora'] 247 ); 248 249 $share['share_root'] = '/'; 250 Vfs::$user = $share['share_owner']; 251 252 // Need to re-init stream wrapper, as some of them look at 253 // preferences or permissions 254 $scheme = Vfs\StreamWrapper::scheme2class(Vfs::parse_url($share['resolve_url'],PHP_URL_SCHEME)); 255 if($scheme && method_exists($scheme, 'init_static')) 256 { 257 $scheme::init_static(); 258 } 259 } 260 261 // mounting share 262 Vfs::$is_root = true; 263 if (!Vfs::mount($share['resolve_url'], $share['share_root'], false, false, !$keep_session)) 264 { 265 sleep(1); 266 return static::share_fail( 267 '404 Not Found', 268 "Requested resource '/".htmlspecialchars($share['share_token'])."' does NOT exist!\n" 269 ); 270 } 271 Vfs::$is_root = false; 272 Vfs::clearstatcache(); 273 // clear link-cache and load link registry without permission check to access /apps 274 Api\Link::init_static(true); 275 276 if(self::$credentials && $share) 277 { 278 $access = Credentials::read_credential(self::$credentials); 279 280 $GLOBALS['egw_info']['user']['account_lid'] = Api\Accounts::id2name($share['share_owner'], 'account_lid'); 281 $GLOBALS['egw_info']['user']['passwd'] = $access['password']; 282 } 283 } 284 285 /** 286 * Get the namespaced class for the given share 287 * 288 * @param string $share 289 */ 290 protected static function get_share_class($share) 291 { 292 return __CLASS__; 293 } 294 295 /** 296 * Get the current share object, if set 297 * 298 * @return array 299 */ 300 public static function get_share() 301 { 302 return isset($GLOBALS['egw']->sharing) ? $GLOBALS['egw']->sharing->share : array(); 303 } 304 305 public static function get_path_from_token() 306 { 307 return $GLOBALS['egw']->sharing->share['share_path']; 308 } 309 310 /** 311 * Parent just throws an exception if you try, here we return boolean so 312 * we can take action and make sure the credentials are available 313 * 314 * @param string $path 315 * @return boolean 316 */ 317 public static function path_needs_password($path) 318 { 319 try 320 { 321 parent::path_needs_password($path); 322 } 323 catch (Api\Exception\WrongParameter $e) 324 { 325 return true; 326 } 327 return false; 328 } 329 330 public static function open_from_share($share, $path) 331 { 332 if($share['root'] && Api\Vfs::is_dir($share['root'])) 333 { 334 // Editing file in a shared directory, need to have share for just 335 // the file 336 $dir_share = $GLOBALS['egw']->sharing->share; 337 $fstab = $GLOBALS['egw_info']['server']['vfs_fstab']; 338 $writable = Api\Vfs::is_writable($path) && $share['writable'] & 1; 339 Bo::reset_vfs(); 340 $share = Wopi::create($share['path'] . $path, $writable ? Wopi::WRITABLE : Wopi::READONLY, '', '', array( 341 'share_expires' => time() + Wopi::TOKEN_TTL, 342 'share_writable' => $writable ? Wopi::WOPI_WRITABLE : Wopi::WOPI_READONLY, 343 )); 344 $GLOBALS['egw_info']['server']['vfs_fstab'] = $fstab; 345 $GLOBALS['egw']->sharing->share = $dir_share; 346 347 return $share; 348 } 349 } 350 351 /** 352 * Find out if the share is writable (regardless of file permissions) 353 * 354 * @return boolean 355 */ 356 public static function is_writable() 357 { 358 $share = static::get_share(); 359 return ((intval($share['share_writable']) & 1)); 360 } 361 362 /** 363 * Get a WOPI file ID from a path 364 * 365 * File ID is the lowest fs_id for the path, if available. If no fs_id is 366 * available (eg: samba mount) we use the ID of the lowest active share 367 * for a file. To deal with versioning, we use the lowest fs_id since for 368 * a new version a new fs_id will be generated, and the original file will 369 * be moved to the attic, but the lowest share ID should stay the same. 370 * 371 * @param string $path Full file path 372 * 373 * @param Integer File ID, (0 if not found) 374 */ 375 public static function get_file_id($path) 376 { 377 $path = str_replace(Api\Vfs::PREFIX, '', $path); 378 $file_id = Api\Vfs::get_minimum_file_id($path); 379 380 // No fs_id? Fall back to the earliest valid share ID 381 if (!$file_id) 382 { 383 self::so(); 384 385 $where = array( 386 'share_path' => Api\Vfs::PREFIX.$path, 387 '(share_expires IS NULL OR share_expires > '.$GLOBALS['egw']->db->quote(time(), 'date').')', 388 ); 389 $append = 'ORDER BY share_id ASC'; 390 foreach($GLOBALS['egw']->db->select(self::TABLE, 'share_id', $where, 391 __LINE__, __FILE__,false,$append,false,1) as $row) 392 { 393 $file_id = -1*$row['share_id']; 394 } 395 } 396 397 return (int)$file_id; 398 } 399 400 /** 401 * Get the full file path for the given file ID 402 * 403 * We also take into account the current token permissions, to make sure 404 * the file matches what the token has access for. File IDs with '-' prefixed 405 * (negative numbers) use the share ID, positive numbers are found in SQLfs. 406 * 407 * @param int $file_id 408 * 409 * @return String the path 410 * 411 * @throws Api\Exception\NotFound if it cannot be found or no permission 412 */ 413 public static function get_path_from_id($file_id) 414 { 415 $path = false; 416 417 if(abs((int)$file_id) == (int)$file_id) 418 { 419 $path = Sql_Stream::id2path((int)$file_id); 420 } 421 else if(strpos($file_id,'-') === 0) 422 { 423 $where = array( 424 'share_id' => abs((int)$file_id) 425 ); 426 427 self::so(); 428 foreach($GLOBALS['egw']->db->select(self::TABLE, 'share_path', $where, __LINE__, __FILE__) as $row) 429 { 430 $path = $row['share_path']; 431 } 432 } 433 434 if($path && isset($GLOBALS['egw']->sharing) && $path != ($token_path=self::get_path_from_token()) 435 && !Api\Vfs::is_dir($token_path) && !Api\Vfs::is_link($token_path) 436 ) 437 { 438 // id2path fails with old revisions 439 $versioned_name = $file_id . ' - '.Api\Vfs::basename($path); 440 if(Api\Vfs::basename($token_path) == $versioned_name && strpos($token_path, '/.versions')) 441 { 442 return $token_path; 443 } 444 } 445 return $path; 446 } 447 /** 448 * Generate link to collabora editor from share or share-token 449 * 450 * @param string|array $share share or share-token 451 * @return string full Url incl. schema and host 452 */ 453 public static function share2link($share) 454 { 455 return Api\Vfs\Sharing::share2link($share) . 456 ($GLOBALS['egw_info']['user']['apps']['stylite'] ? '?edit&cd=no' : ''); 457 } 458 459 /** 460 * Delete specified shares and remove credentials, if needed 461 * 462 * @param int|array $keys 463 * @return int number of deleted shares 464 */ 465 public static function delete($keys) 466 { 467 self::$db = $GLOBALS['egw']->db; 468 469 if (is_scalar($keys)) 470 { 471 $keys = array('share_id' => $keys); 472 } 473 474 // Delete credentials, if there 475 Credentials::delete($keys); 476 477 return parent::delete($keys); 478 } 479} 480