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